Integration tests are a natural fit for interactive websites, like ones you might build with React. They validate how a user interacts with your app without the overhead of end-to-end testing.
This article follows an exercise that starts with a simple website, validates behavior with unit and integration tests, and demonstrates how integration testing delivers greater value from fewer lines of code. The content assumes a familiarity with React and testing in JavaScript. Experience with Jest and React Testing Library is helpful but not required.
There are three types of tests:
- Unit tests verify one piece of code in isolation. They are easy to write, but can miss the big picture.
- End-to-end tests (E2E) use an automation framework — such as Cypress or Selenium — to interact with your site like a user: loading pages, filling out forms, clicking buttons, etc. They are generally slower to write and run, but closely match the real user experience.
- Integration tests fall somewhere in between. They validate how multiple units of your application work together but are more lightweight than E2E tests. Jest, for example, comes with a few built-in utilities to facilitate integration testing; Jest uses jsdom under the hood to emulate common browser APIs with less overhead than automation, and its robust mocking tools can stub out external API calls.
Another wrinkle: In React apps, unit and integration are written the same way, with the same tools.
Getting started with React tests
I created a simple React app (available on GitHub) with a login form. I wired this up to reqres.in, a handy API I found for testing front-end projects.
You can log in successfully:

…or encounter an error message from the API:

The code is structured like this:
LoginModule/
├── components/
⎪ ├── Login.js // renders LoginForm, error messages, and login confirmation
⎪ └── LoginForm.js // renders login form fields and button
├── hooks/
⎪ └── useLogin.js // connects to API and manages state
└── index.js // stitches everything together
Option 1: Unit tests
If you’re like me, and like writing tests — perhaps with your headphones on and something good on Spotify — then you might be tempted to knock out a unit test for every file.
Even if you’re not a testing aficionado, you might be working on a project that’s “trying to be good with testing” without a clear strategy and a testing approach of “I guess each file should have its own test?”
That would look something like this (where I’ve added unit
to test file names for clarity):
LoginModule/
├── components/
⎪ ├── Login.js
⎪ ├── Login.unit.test.js
⎪ ├── LoginForm.js
⎪ └── LoginForm.unit.test.js
├── hooks/
⎪ ├── useLogin.js
⎪ └── useLogin.unit.test.js
├── index.js
└── index.unit.test.js
I went through the exercise of adding each of these unit tests on on GitHub, and created a test:coverage:unit
script to generate a coverage report (a built-in feature of Jest). We can get to 100% coverage with the four unit test files:

100% coverage is usually overkill, but it’s achievable for such a simple codebase.
Let’s dig into one of the unit tests created for the onLogin
React hook. Don’t worry if you’re not well-versed in React hooks or how to test them.
test('successful login flow', async () => {
// mock a successful API response
jest
.spyOn(window, 'fetch')
.mockResolvedValue({ json: () => ({ token: '123' }) });
const { result, waitForNextUpdate } = renderHook(() => useLogin());
act(() => {
result.current.onSubmit({
email: '[email protected]',
password: 'password',
});
});
// sets state to pending
expect(result.current.state).toEqual({
status: 'pending',
user: null,
error: null,
});
await waitForNextUpdate();
// sets state to resolved, stores email address
expect(result.current.state).toEqual({
status: 'resolved',
user: {
email: '[email protected]',
},
error: null,
});
});
This test was fun to write (because React Hooks Testing Library makes testing hooks a breeze), but it has a few problems.
First, the test validates that a piece of internal state changes from 'pending'
to 'resolved'
; this implementation detail is not exposed to the user, and therefore, probably not a good thing to be testing. If we refactor the app, we’ll have to update this test, even if nothing changes from the user’s perspective.
Additionally, as a unit test, this is just part of the picture. If we want to validate other features of the login flow, such as the submit button text changing to “Loading,” we’ll have to do so in a different test file.
Option 2: Integration tests
Let’s consider the alternative approach of adding one integration test to validate this flow:
LoginModule/
├── components/
⎪ ├─ Login.js
⎪ └── LoginForm.js
├── hooks/
⎪ └── useLogin.js
├── index.js
└── index.integration.test.js
I implemented this test and a test:coverage:integration
script to generate a coverage report. Just like the unit tests, we can get to 100% coverage, but this time it’s all in one file and requires fewer lines of code.

Here’s the integration test covering a successful login flow:
test('successful login', async () => {
jest
.spyOn(window, 'fetch')
.mockResolvedValue({ json: () => ({ token: '123' }) });
render(<LoginModule />);
const emailField = screen.getByRole('textbox', { name: 'Email' });
const passwordField = screen.getByLabelText('Password');
const button = screen.getByRole('button');
// fill out and submit form
fireEvent.change(emailField, { target: { value: '[email protected]' } });
fireEvent.change(passwordField, { target: { value: 'password' } });
fireEvent.click(button);
// it sets loading state
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Loading...');
await waitFor(() => {
// it hides form elements
expect(button).not.toBeInTheDocument();
expect(emailField).not.toBeInTheDocument();
expect(passwordField).not.toBeInTheDocument();
// it displays success text and email address
const loggedInText = screen.getByText('Logged in as');
expect(loggedInText).toBeInTheDocument();
const emailAddressText = screen.getByText('[email protected]');
expect(emailAddressText).toBeInTheDocument();
});
});
I really like this test, because it validates the entire login flow from the user’s perspective: the form, the loading state, and the success confirmation message. Integration tests work really well for React apps for precisely this use case; the user experience is the thing we want to test, and that almost always involves several different pieces of code working together.
This test has no specific knowledge of the components or hook that makes the expected behavior work, and that’s good. We should be able to rewrite and restructure such implementation details without breaking the tests, so long as the user experience remains the same.
I’m not going to dig into the other integration tests for the login flow’s initial state and error handling, but I encourage you to check them out on GitHub.
So, what does need a unit test?
Rather than thinking about unit vs. integration tests, let’s back up and think about how we decide what needs to be tested in the first place. LoginModule
needs to be tested because it’s an entity we want consumers (other files in the app) to be able to use with confidence.
The onLogin hook, on the other hand, does not need to be tested because it’s only an implementation detail of LoginModule
. If our needs change, however, and onLogin
has use cases elsewhere, then we would want to add our own (unit) tests to validate its functionality as a reusable utility. (We’d also want to move the file because it wouldn’t be specific to LoginModule
anymore.)
There are still plenty of use cases for unit tests, such as the need to validate reusable selectors, hooks, and plain functions. When developing your code, you might also find it helpful to practice test-driven development with a unit test, even if you later move that logic higher up to an integration test.
Additionally, unit tests do a great job of exhaustively testing against multiple inputs and use cases. For example, if my form needed to show inline validations for various scenarios (e.g. invalid email, missing password, short password), I would cover one representative case in an integration test, then dig into the specific cases in a unit test.
Other goodies
While we’re here, I want to touch on few syntactic tricks that helped my integration tests stay clear and organized.
Unambiguous waitFor Blocks
Our test needs to account for the delay between the loading and success states of LoginModule
:
const button = screen.getByRole('button');
fireEvent.click(button);
expect(button).not.toBeInTheDocument(); // too soon, the button is still there!
We can do this with DOM Testing Library’s waitFor
helper:
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
expect(button).not.toBeInTheDocument(); // ahh, that's better
});
But, what if we want to test some other items too? There aren’t a lot of good examples of how to handle this online, and in past projects, I’ve dropped additional items outside of the waitFor
:
// wait for the button
await waitFor(() => {
expect(button).not.toBeInTheDocument();
});
// then test the confirmation message
const confirmationText = getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
This works, but I don’t like it because it makes the button condition look special, even though we could just as easily switch the order of these statements:
// wait for the confirmation message
await waitFor(() => {
const confirmationText = getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
});
// then test the button
expect(button).not.toBeInTheDocument();
It’s much better, in my opinion, to group everything related to the same update together inside the waitFor
callback:
await waitFor(() => {
expect(button).not.toBeInTheDocument();
const confirmationText = screen.getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
});
I really like this technique for simple assertions like these, but it can slow down your tests in certain cases waiting for failures that would happen right away outside of the waitFor
. See “Having multiple assertions in a single waitFor
callback” in Common mistakes with React Testing Library for an example of this.
For tests with a few steps, we can have multiple waitFor
blocks in row:
const button = screen.getByRole('button');
const emailField = screen.getByRole('textbox', { name: 'Email' });
// fill out form
fireEvent.change(emailField, { target: { value: '[email protected]' } });
await waitFor(() => {
// check button is enabled
expect(button).not.toBeDisabled();
expect(button).toHaveTextContent('Submit');
});
// submit form
fireEvent.click(button);
await waitFor(() => {
// check button is no longer present
expect(button).not.toBeInTheDocument();
});
If you’re waiting for just one item to appear, you can use the findBy
query instead. It uses waitFor
under the hood.
Inline it comments
Another testing best practice is to write fewer, longer tests; this allows you to correlate your test cases to significant user flows while keeping tests isolated to avoid unexpected behavior. I subscribe to this approach, but it can present challenges in keeping code organized and documenting desired behavior. We need future developers to be able to return to a test and understand what it’s doing, why it’s failing, etc.
For example, let’s say one of these expectations starts to fail:
it('handles a successful login flow', async () => {
// beginning of test hidden for clarity
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Loading...');
await waitFor(() => {
expect(button).not.toBeInTheDocument();
expect(emailField).not.toBeInTheDocument();
expect(passwordField).not.toBeInTheDocument();
const confirmationText = screen.getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
});
});
A developer looking into this can’t easily determine what is being tested and might have trouble deciding whether the failure is a bug (meaning we should fix the code) or a change in behavior (meaning we should fix the test).
My favorite solution to this problem is using the lesser-known test
syntax for each test, and adding inline it
-style comments describing each key behavior being tested:
test('successful login', async () => {
// beginning of test hidden for clarity
// it sets loading state
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Loading...');
await waitFor(() => {
// it hides form elements
expect(button).not.toBeInTheDocument();
expect(emailField).not.toBeInTheDocument();
expect(passwordField).not.toBeInTheDocument();
// it displays success text and email address
const confirmationText = screen.getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
});
});
These comments don’t magically integrate with Jest, so if you get a failure, the failing test name will correspond to the argument you passed to your test
tag, in this case 'successful login'
. However, Jest’s error messages contain surrounding code, so these it
comments still help identify the failing behavior. Here’s the error message I got when I removed the not
from one of my expectations:

For even more explicit errors, there’s package called jest-expect-message that allows you to define error messages for each expectation:
expect(button, 'button is still in document').not.toBeInTheDocument();
Some developers prefer this approach, but I find it a little too granular in most situations, since a single it
often involves multiple expectations.
Next steps for teams
Sometimes I wish we could make linter rules for humans. If so, we could set up a prefer-integration-tests rule for our teams and call it a day.
But alas, we need to find a more analog solution to encourage developers to opt for integration tests in a situation, like the LoginModule
example we covered earlier. Like most things, this comes down to discussing your testing strategy as a team, agreeing on something that makes sense for the project, and — hopefully — documenting it in an ADR.
When coming up with a testing plan, we should avoid a culture that pressures developers to write a test for every file. Developers need to feel empowered to make smart testing decisions, without worrying that they’re “not testing enough.” Jest’s coverage reports can help with this by providing a sanity check that you’re achieving good coverage, even if the tests are consolidated that the integration level.
I still don’t consider myself an expert on integration tests, but going through this exercise helped me break down a use case where integration testing delivered greater value than unit testing. I hope that sharing this with your team, or going through a similar exercise on your codebase, will help guide you in incorporating integration tests into your workflow.
Hi,
very nice article!
I am trying to write integration ui tests for my application as well! but i am having a problem that the mocks are always changing, so i need to constantly update my json mocks and then integration tests become useless.
How do you deal with api mocks?
Thanks Mariam!
I guess it would depend on why the mocks are always changing. If your API isn’t stable yet and you feel you are losing time from updating your mocks and tests, then you might want to disable the tests until you get to a stable point. If the mocks are serving double-duty as mocks for tests and mocks for the UI (while the API is getting built, maybe) then you would want to split up the mocks so that a small change like tweaking a title wouldn’t break your tests. Jest’s general matches like “expect.any” and “expect.objectContaining” can also help making more flexible tests. Hope this helps!
Great article, I use the same libraries in my projects and I also believe that testing the behaviour is better than testing the implementation to have refactoring proof tests ;-).
There was one thing though, instead of writing a test with the keyword “test” ans each “it” in comment. You could just wrap your tests in a “describe” and keep declaring them with the “it” keyword. Like that you have tiny tests grouped in one describe representing the all scenario. This way the console output shows you exactly which step of the scenario is broken without the need to write comments and maintain them.
Thanks for reading!
I think using describe/it would technically work in this case, and definitely produce a nice console output, but I’d be hesitant to do that project-wide, because any “it”/”test” block qualifies as a test in Jest and will set off beforeEach/afterEach flags, or the something like the clearMocks configuration flag (which I tend to use: https://jestjs.io/docs/en/configuration.html#clearmocks-boolean).
Interesting article, but I have to say I am less and less into integration tests. I mean what are their purpose actually. They test a part of the UI but in isolation. So they depend on the developer will and knowledge. I think that testing might be though in a larger way. Through unit test I can test pure logic which in general is easier to test with pure functions for instance. And it encourages you to have views with very few element of logic inside. So your dumb component should only be the reflection of your more complex logic. Plus unit test are way faster to run and fix. On the other end, I think e2e testing is more fitted for your use cases and they run in actual browsers ! Of course they are also way slower to run. But if you want critical paths, I think they are best. My point here is not to say that you are wrong I mean if it works for you it’s great. But I think tests should be a part of a larger scope which is way bigger than dev time.
Interesting point! I think it depends on the shape of your app. Often in my apps I have complex components or modules that it makes sense to test on an integration level, because they can appear on multiple pages, but I still need to test multiple pieces together.
It’s not a real integration test because it doesn’t run on the browser and you cannot test the full extent of many possible scenarios, for example a Component that opens another component in a Portal and that component has dynamic measurements relying on DOM api or what not.
For these developers, like myself, have been using in recent years Jest+Puppeteer.
I think “integration tests” have a few definitions depending on who you ask, but I tend to call what you’re talking about end-to-end or functional tests. I pretty much subscribe to the breakdown in this blog post: https://kentcdodds.com/blog/unit-vs-integration-vs-e2e-tests
Puppeteer is really cool though!
Great Article Man!