Why Integration Tests Fit React
Interactive React applications are built around user flows that cross multiple components, hooks, and modules. Integration tests validate those flows directly. They sit between unit tests—which verify isolated pieces of logic—and end-to-end tests, which drive a real browser with tools like Cypress or Selenium. Jest gives you lightweight integration capabilities out of the box: jsdom emulates browser APIs, and built-in mocking stubs external calls without the overhead of a full browser session.
A practical exercise with a simple login form illustrates the difference. The app posts credentials to the reqres.in test API and renders a success state or an API-provided error message. The component tree includes a LoginModule with an onLogin hook and a presentation component. The source is available on GitHub.
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
Unit Tests: Complete Coverage, Fragile Assertions
A file-by-file testing strategy—one test per module, component, and hook—produces a familiar structure:
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
Writing unit tests for each file in the example codebase achieves 100% coverage across four test files:

Testing the onLogin hook in isolation with React Hooks Testing Library is clean and concise:
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,
});
});
That test is also easy to write for the wrong reasons. It asserts that internal state transitions from 'pending' to 'resolved'—an implementation detail no user ever sees. Refactoring the hook forces an update to this test even when behavior doesn't change. And a separate test file would be needed to verify the loading state, the submit button label change, and the success message.
Integration Tests: Flow-Level Validation
Consolidating those checks into a single integration test that renders the real component and walks through the user's perspective yields the same coverage in fewer lines:
LoginModule/
├── components/
⎪ ├─ Login.js
⎪ └── LoginForm.js
├── hooks/
⎪ └── useLogin.js
├── index.js
└── index.integration.test.js

The successful-login integration test renders the form, submits it, and confirms the loading state and the resulting success message appear:
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();
});
});
This test is intentionally agnostic about which module, hook, or internal state makes the behavior work. Rendering, submission, and UI updates are handled by React Testing Library, so the assertions only describe what a user sees. That separation means internal rewrites don't break the test as long as the user experience stays constant.
By contrast, a unit-level approach couples tests to state transitions, loading flags, and other internals. Unit tests still deserve a place, particularly for reusable selectors, utility functions, or hooks that are shared beyond a single feature. Coverage reports from Jest offer a sanity check that consolidated integration tests are hitting the important paths.
Handling Async Assertions Cleanly
Integration tests for forms need to accommodate the delay between the loading and success states:
const button = screen.getByRole('button');
fireEvent.click(button);
expect(button).not.toBeInTheDocument(); // too soon, the button is still there!
Waiting for loading text to disappear and success text to appear uses the waitFor helper, though ordering the two assertions matters:
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
expect(button).not.toBeInTheDocument(); // ahh, that's better
});
An easy mistake is to assert the loading state first outside waitFor, then wait for success inside:
// 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();
That makes the loading check look like a special case, when it's really just a matter of statement order:
// 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();
Grouping all assertions about one UI update inside a single waitFor callback is clearer:
await waitFor(() => {
expect(button).not.toBeInTheDocument();
const confirmationText = screen.getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
});
This works well for quick assertions, but be aware that multiple assertions in one waitFor can slow the test if one of them fails quickly and the helper keeps polling. For longer flows with distinct states, separate waitFor blocks in sequence work well:
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();
});
For single-item waits, the findBy query wraps waitFor internally.
Keeping Longer Tests Maintainable
Writing fewer, longer tests aligns with the testing-library philosophy, but a test that exercises several behaviors creates a debugging problem when one expectation fails:
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();
});
});
Without context, the developer can't tell whether the failure is a real bug or an intentional behavior change. Inline comments that read like it statements document the intent of each assertion, even though the formal test name is the one passed to test:
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();
});
});
Jest's error output includes the surrounding source, so the comments make it easy to locate the failed expectation:

If you need machine-readable messages per assertion, jest-expect-message allows annotating individual expectations:
expect(button, 'button is still in document').not.toBeInTheDocument();
That granularity is often overkill—most it blocks contain several related assertions that share one intent.
Where Unit Coverage Still Matters
The decision about whether a module needs a test comes down to whether consumers will rely on it. LoginModule is external-facing; the onLogin hook is an implementation detail that only exists to support the module. If the hook later proves reusable—or needs to be moved out because another module depends on it—that's the point to put a unit test on it.
Multiple-input validation is another strong unit-test case. An integration test should cover a representative scenario, like an invalid email, while a unit test handles variants of expected validations. Test-driven development can also start at the unit level and move to integration once the behavior is defined.
The effective strategy is to write tests as a team, agree on where integrated coverage suffices, and document that guidance. Coverage is a sanity check, not a per-file quota.



