FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·113·16 MIN READ

113: React Testing — Components, Network, Accessibility, Contracts, and E2E

TOPICS COVERED: React Testing — Components, Network, Accessibility, Contracts, and E2E

Learning objectives

By the end of this lesson, you should be able to:

  • test behavior rather than implementation details;
  • use Vitest with React Testing Library and user-event;
  • mock network boundaries with MSW;
  • test TanStack Query v5 components reliably;
  • test route behavior;
  • include accessibility assertions;
  • define contract tests;
  • use Playwright for critical browser journeys;
  • avoid brittle timing and DOM-shape assertions.

The common thread is choosing the cheapest test that proves a meaningful risk. A component test should give you fast feedback about user-visible behavior, while an end-to-end test should be reserved for wiring that genuinely crosses the browser, server, and application boundaries.

Test pyramid for React

A useful test strategy looks like this:

text
many fast pure/unit tests
many component/integration tests
some contract tests
few high-value E2E journeys

The pyramid is a guide to test placement, not a quota that every project must hit. Keep most feedback fast and local, then use broader tests where a narrower test cannot establish the behavior with enough confidence.

Do not force every behavior into E2E. A browser test costs more to run and usually gives a less precise failure than a focused component or integration test.

Do not mock so much that component tests prove only that the mocks were called. Mock at a boundary that matters, such as HTTP, and allow the component, query client, and UI to work together.

Install

bash
npm install -D \
  vitest \
  jsdom \
  @testing-library/react \
  @testing-library/user-event \
  @testing-library/jest-dom \
  msw

These packages cover the test runner, a DOM-like environment, React rendering, realistic user interactions, useful DOM matchers, and HTTP-level request interception. Playwright is used later for browser journeys and is normally installed and configured separately.

User-centered query priority

When a test needs to find an element, begin with the same information a user or assistive technology would use. For example, a button can be located by its role and accessible name:

jsx
screen.getByRole(
  'button',
  {
    name:
      /save task/i,
  },
);

Then use a label when the control is most naturally identified by its associated form label:

jsx
screen.getByLabelText(
  /title/i,
);

Avoid test IDs when a user-facing semantic query exists. A test ID describes an implementation hook; a role or label checks that the interface is exposed in a way users can actually use.

If getByRole is difficult, that can reveal a real accessibility problem. The difficulty may mean the element has no useful role, has an unclear accessible name, or is not structured as the control the test assumes it is.

Component behavior test

Here is a behavior-focused test for creating a task:

jsx
test(
  'creates a task',
  async () => {
    const user =
      userEvent.setup();

    const onCreate =
      vi.fn();

    render(
      <TaskForm
        onCreate={
          onCreate
        }
      />,
    );

    await user.type(
      screen
        .getByLabelText(
          /title/i,
        ),
      'Review tests',
    );

    await user.click(
      screen
        .getByRole(
          'button',
          {
            name:
              /save/i,
          },
        ),
    );

    expect(
      onCreate,
    ).toHaveBeenCalledWith(
      expect.objectContaining({
        title:
          'Review tests',
      }),
    );
  },
);

The test follows the interaction a user performs: enter a title, activate Save, and verify the observable callback payload. It does not know the name of the useState variable, how many renders occurred, or whether the form later changes from local state to a reducer or a store. Those implementation choices can change without invalidating this behavior contract.

Async queries

When UI appears asynchronously, wait for the expected result rather than guessing how long the operation might take:

jsx
await screen.findByText(
  /task loaded/i,
);

findBy... queries retry until the element appears or the testing-library timeout is reached. This makes the test synchronize with an observable UI result.

Use:

jsx
screen.queryByText(...)

when asserting absence. A queryBy... query returns null instead of throwing when nothing matches, which is the useful behavior for a negative assertion.

Avoid arbitrary sleeps:

jsx
await new Promise(
  (resolve) =>
    setTimeout(
      resolve,
      500,
    ),
);

A sleep can be too short on a busy run and unnecessarily long on a fast one. Tests should wait for observable behavior, such as an alert, a button state, or rendered data.

MSW

Mock Service Worker lets the test intercept HTTP at the network boundary. The component still calls its normal request code, so the test exercises request handling, query behavior, and rendering without contacting a real service.

Set up handlers:

jsx
import {
  http,
  HttpResponse,
} from 'msw';

import {
  setupServer,
} from 'msw/node';

export const server =
  setupServer(
    http.get(
      '/api/tasks',
      () =>
        HttpResponse.json({
          tasks: [
            {
              id: 't1',
              title:
                'Review MSW',
              completed:
                false,
            },
          ],
        }),
    ),
  );

Test setup:

jsx
beforeAll(() => {
  server.listen({
    onUnhandledRequest:
      'error',
  });
});

afterEach(() => {
  server.resetHandlers();
});

afterAll(() => {
  server.close();
});

Failing on unhandled requests prevents accidental real network usage. It also makes a missing handler visible immediately instead of allowing a test to pass or fail based on an external service.

Network error case

Override the normal handler for a test that needs a server failure:

jsx
server.use(
  http.get(
    '/api/tasks',
    () =>
      new HttpResponse(
        null,
        {
          status: 500,
        },
      ),
  ),
);

render(<TaskScreen />);

expect(
  await screen
    .findByRole(
      'alert',
    ),
).toHaveTextContent(
  /could not load/i,
);

The test drives the component through its actual error path and checks the user-facing result. For a query-backed screen, test the full state range:

  • pending;
  • success;
  • empty;
  • error;
  • retry.

Testing TanStack Query v5

Each test should usually get its own QueryClient. Query state is a cache, and reusing one client can make one test observe data, errors, or staleness created by another test.

jsx
function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
      mutations: {
        retry: false,
      },
    },
  });
}

Wrapper:

jsx
function renderWithQuery(
  ui,
) {
  const client =
    createTestQueryClient();

  return render(
    <QueryClientProvider
      client={client}
    >
      {ui}
    </QueryClientProvider>,
  );
}

Retries are valuable production behavior, but they usually obscure a unit or integration test's first failure. Configure them deliberately for tests, and test retry behavior separately when retrying is itself part of the product contract.

Do not reuse one cache across tests unless isolation is intentionally managed.

Optimistic mutation test

For an optimistic mutation, test the visible contract in order:

  1. initial row;
  2. click Complete;
  3. optimistic UI appears;
  4. server fails;
  5. rollback is visible;
  6. error is announced.

This sequence checks more than whether a mutation function ran. It checks the temporary state, the failure recovery, and the communication of failure to the user.

Do not assert private mutation callback ordering unless that ordering itself is the library contract under test. Internal callback order is an implementation detail; what matters to the application is what the user sees and what durable state remains.

Reducer/store tests

Pure reducer tests remain useful because a reducer has a simple input/output contract and can be tested without rendering.

Redux component tests should generally render with a real test store and interact with the UI rather than mocking useSelector/useDispatch. A real test store exercises the connection between dispatched actions, state changes, selectors, and rendered output. Mocking those hooks can make a test pass even when the component is wired incorrectly.

Router tests

Use the router APIs to create an in-memory route environment. This gives the test real route matching and navigation behavior without requiring a browser or a running application server.

Test:

  • route rendering;
  • params;
  • redirects;
  • loader errors;
  • action validation;
  • not-found state.

Avoid mocking the router Hooks individually if a real in-memory router gives a more meaningful test. Mocking a hook may verify that a call was made while missing a bad route definition, an incorrect parameter, or a broken redirect.

Accessibility tests

Automated accessibility checks are useful but incomplete. They can catch structural problems that are easy to miss in a visual review, but they cannot understand the quality of an entire interaction in the way a keyboard user or screen-reader user experiences it.

Add an axe-based scan where appropriate, but also manually test:

  • keyboard order;
  • focus visibility;
  • modal focus behavior;
  • screen-reader labels;
  • live error announcements;
  • 200% zoom;
  • reduced motion.

A passing automated scan does not prove an accessible workflow. Treat the scan as one signal, not as a substitute for interaction testing.

Contract tests

A client/server contract includes:

  • method;
  • URL;
  • request fields;
  • headers/auth mode;
  • success body;
  • error body;
  • status codes.

Example:

json
{
  "request": {
    "method": "POST",
    "path": "/api/tasks",
    "body": {
      "title": "Plan"
    }
  },
  "success": {
    "status": 201,
    "body": {
      "task": {
        "id": "t1",
        "title": "Plan",
        "completed": false
      }
    }
  }
}

Use shared fixtures, OpenAPI checks, or schema checks when appropriate. A contract test should make an incompatible change visible at the boundary rather than allowing the client and its mock to evolve independently.

Do not let MSW drift away from the real API contract. A mock that returns a convenient but impossible response gives the test suite false confidence.

E2E with Playwright

A high-value journey might be:

text
sign in
create task
filter task
complete task
reload page
verify server truth

E2E proves browser/system wiring that isolated component tests cannot: real navigation, browser behavior, authentication integration, server persistence, and the connection between those pieces.

Keep E2E focused on critical business journeys. Do not duplicate every component state in a slower browser suite when a component or integration test can prove it more directly.

Flaky test diagnosis

Common causes:

  • arbitrary waits;
  • shared state/cache between tests;
  • real network calls;
  • race-prone assertions;
  • animation not disabled;
  • time/date dependence;
  • unstable generated IDs;
  • test order dependence.

Fix the root cause; do not simply increase timeout values. A larger timeout can hide synchronization problems while making every failure slower to diagnose.

Test implementation details to avoid

Avoid assertions against private component state:

jsx
expect(
  component.state.open,
).toBe(true);

Avoid assertions against internal DOM structure:

jsx
expect(
  wrapper.find(
    '.internal-class',
  ),
).toHaveLength(1);

Prefer user-visible behavior. If the user cannot observe the state field or internal class, a test based on it is coupled to a refactoring detail rather than the product contract.

Deprecated test renderer awareness

Modern React recommends testing with current testing-library strategies rather than relying on react-test-renderer for application behavior. Render the application boundary the user interacts with and assert the resulting behavior instead of treating a renderer's internal tree as the API under test.

Exercises

  1. Test controlled TaskForm behavior.
  2. Test query pending/success/error through MSW.
  3. Test optimistic rollback.
  4. Test a route loader 404.
  5. Add one automated accessibility scan and one manual keyboard checklist.
  6. Define an API contract fixture.
  7. Write one Playwright critical journey.

Exit questions

  1. Why does RTL prefer role-based queries?
  2. What does MSW mock?
  3. Why should each query test get a fresh QueryClient?
  4. What can an E2E test prove that a component test cannot?
  5. Why are arbitrary sleeps a test smell?
  6. Why can accessibility not be fully automated?

Official references


Deep dive: test confidence comes from realistic boundaries

A test should fail when user-visible behavior breaks, not whenever you refactor the implementation. That distinction is the basis for choosing realistic boundaries: test a component through its rendered interface, and test network behavior through HTTP rather than replacing every function below it.

Weak

jsx
expect(useState).toHaveBeenCalledTimes(3);

Strong

jsx
await user.click(screen.getByRole('button', { name: /complete/i }));

expect(
  screen.getByRole('button', { name: /reopen/i }),
).toBeVisible();

The second test survives internal changes from useState to reducer/store/query. It also describes the behavior that matters: completing a task changes the available action in the interface.

Test environment

Configure jsdom for DOM component tests. It gives test code a useful browser-like document without launching a full browser.

But jsdom is not a real browser.

It does not fully model:

  • layout;
  • CSS rendering;
  • scrolling geometry;
  • actual navigation;
  • browser permissions;
  • all accessibility APIs.

Use Playwright/browser tests for those behaviors. A jsdom test can confirm that an element exists, but it cannot reliably establish how pixels are laid out, how focus behaves in the browser, or whether a real navigation and permission prompt work.

Test setup discipline

Example setup:

jsx
import '@testing-library/jest-dom/vitest';

MSW lifecycle:

jsx
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Also clear:

  • fake timers;
  • localStorage if used;
  • mocks;
  • query clients;
  • global DOM side effects.

Tests must not leak state. Cleanup is not just housekeeping: without it, a later test may pass because it inherited a mock, cache entry, storage value, or DOM mutation from an earlier test.

act

React Testing Library wraps most user interactions and render updates appropriately. In normal tests, do not manually wrap everything in act by habit.

If warnings appear, investigate asynchronous work that the test did not await. The warning is often evidence that the test finished while the component still had a state update in flight.

Often the correct fix is:

jsx
await user.click(...)
await screen.findBy...

not:

jsx
await act(async () => {
  await new Promise(resolve => setTimeout(resolve, 100));
});

Waiting for the actual interaction and result synchronizes the test with behavior. A timed act block only delays the test and may still miss the relevant update.

userEvent

Use:

jsx
const user = userEvent.setup();

await user.type(input, 'Task title');
await user.click(button);

This models interaction sequences better than:

jsx
fireEvent.change(input, { target: { value: 'Task title' } });

Typing and clicking can involve more than one low-level event, and real controls can respond to that sequence differently from a single synthetic change. fireEvent is still useful for lower-level events, but userEvent should be the default for user behavior.

Query priority

Prefer:

  1. role;
  2. label;
  3. placeholder/text where semantically appropriate;
  4. test ID last.

If you cannot query a button by role/name, inspect accessibility. The query failure may identify a missing name or incorrect control semantics rather than a testing-library limitation.

MSW contract discipline

An MSW handler should model the API that the application actually consumes, including validation and response status:

jsx
http.post('/api/tasks', async ({ request }) => {
  const body = await request.json();

  if (body.title.length < 3) {
    return HttpResponse.json(
      {
        error: {
          code: 'VALIDATION_ERROR',
          fields: {
            title: 'Too short',
          },
        },
      },
      { status: 422 },
    );
  }

  return HttpResponse.json(
    {
      task: {
        id: 't1',
        ...body,
        completed: false,
      },
    },
    { status: 201 },
  );
});

This mock should match the actual API contract. The invalid input produces a structured field error with status 422; valid input produces a created task with status 201. Those details are part of the client/server behavior, not incidental test data.

Prevent drift through:

  • shared OpenAPI schemas;
  • generated types/validators;
  • integration contract tests against backend.

Query test retry behavior

Production may retry transient queries:

text
retry transient queries

Tests often disable retry so that a deliberate failure is reported promptly:

jsx
new QueryClient({
  defaultOptions: {
    queries: { retry: false },
    mutations: { retry: false },
  },
});

Otherwise error tests can take longer and appear flaky. Test retry itself separately if it is product behavior; disabling it for ordinary tests should not mean the production policy goes untested.

Testing query cache behavior

You can test observable cache behavior such as:

  1. first render requests tasks;
  2. second observer reuses cache;
  3. invalidation triggers refetch;
  4. staleTime prevents unnecessary refetch;
  5. mutation update appears across consumers.

Do not assert internal private Query object implementation. Assert observable requests, UI, and cache public APIs. The cache is valuable because it changes application behavior; its private object layout is not a stable contract.

Optimistic concurrency test

A valuable test is:

text
toggle task → optimistic Done visible
before response, edit title → optimistic title visible
toggle request fails
edit succeeds
final title remains edited
completed rolls back

This models two overlapping changes rather than a single mutation in isolation. If your rollback restores the full old object, this test exposes data loss: the rollback could accidentally erase the title edit that succeeded independently.

This is much stronger than a single happy-path optimistic test because it checks how concurrent updates interact when one succeeds and another fails.

Router testing

Build a memory router with route definitions. This gives the test the same matching, loader, action, and error-boundary relationships as the application while keeping navigation in memory.

Test deep routes directly:

text
initialEntries: ['/tasks/t1?mode=edit']

Then assert:

  • loader request;
  • route component;
  • param;
  • action;
  • redirect;
  • error boundary.

Do not start every router test at / and click through five pages unless the navigation path itself is the behavior under test. Starting at the relevant entry makes a deep-route failure easier to isolate and avoids testing unrelated navigation repeatedly.

Form test matrix

For an important form, cover the meaningful outcomes rather than chasing a coverage percentage alone:

CaseExpected
blankrequired error
shortclient/schema error
validsubmit request
422server field error
409conflict UI
500root error
double clickno duplicate harmful write
successreset/navigate
failurepreserve draft

This matrix is more useful than coverage percentage alone because it names the user and system states that can cause harm or confusion. It also gives each test a clear reason to exist.

Accessibility automated testing

Using axe can catch:

  • missing names;
  • invalid ARIA;
  • some contrast depending environment;
  • landmarks.

But it cannot fully verify:

  • useful focus order;
  • announcement quality;
  • cognitive clarity;
  • keyboard custom-widget semantics;
  • zoom/reflow;
  • motion.

Manual accessibility remains required. Automated rules are excellent for repeatable structural checks, but a technically valid name can still be confusing, and a passing scan cannot tell whether a keyboard user can complete the workflow.

Focus tests

For a modal, the expected interaction is:

text
open
→ focus enters dialog
Tab cycles appropriately
Escape closes
focus returns to trigger

This is interaction behavior worth browser/component testing. The focus return is part of the contract too; without it, closing the dialog can leave keyboard users in an unexpected location.

Time testing

Avoid tests depending on the real current time. Real time makes results depend on when the suite happens to run and can create boundary failures around dates or expiry.

Inject a clock or use fake timers for:

  • debounce;
  • retries;
  • expiry;
  • relative timestamps.

After fake timers, restore real timers. Do not fake timers globally if userEvent or a library interaction relies on real scheduling without configuration. Timer control is useful only when it remains compatible with the behavior being simulated.

Visual regression

Visual regression testing is useful for:

  • design system;
  • responsive layout;
  • complex charts;
  • accidental CSS changes.

It is not a replacement for semantic behavior tests. A screenshot can look unchanged while a button loses its accessible name, and it can change because of harmless rendering noise. Pixel changes can be noisy; choose stable environments.

E2E authentication

Avoid slow UI login for every E2E test if the test framework can safely create authenticated state. Reusing a controlled authenticated state keeps the suite focused and fast.

But keep at least one real login journey. That test verifies the login UI and the authentication wiring rather than assuming the state setup itself is correct.

Never bypass authorization in production code solely for tests. Test-only state creation belongs in a controlled test boundary, not in a production endpoint or code path that weakens access control.

Network control in E2E

Playwright can:

  • intercept;
  • simulate failure;
  • throttle;
  • assert requests.

Use real backend integration for selected journeys to verify contracts. Interception is useful for deterministic failure and latency scenarios, while a real backend journey catches mismatches that a test-controlled response cannot.

Balance speed and realism. The right suite contains both controlled scenarios and a small number of tests that cross the real integration boundary.

Flake triage

When E2E flakes, collect:

  • trace;
  • screenshot;
  • video if enabled;
  • console;
  • network;
  • server logs/correlation ID.

These artifacts let you locate the boundary where behavior diverged: the browser, the request, the server, or the data. Do not rerun until green and ignore the root cause. A rerun can classify a failure as intermittent, but it does not explain or fix it.

Coverage

Code coverage can reveal untested branches.

It cannot tell whether tests represent valuable behavior. A 100% covered broken form is possible when tests execute every line but never assert the correct user outcome or error recovery.

Use risk-based coverage for areas such as:

  • money;
  • permissions;
  • destructive actions;
  • state transitions;
  • concurrency;
  • critical navigation.

Test doubles hierarchy

Prefer realistic boundary doubles:

text
MSW HTTP

over mocking:

text
useQuery
fetch
axios internals
router hooks

when integration behavior matters. An HTTP-level mock lets the application request code and the component integration run normally while keeping the external service deterministic.

Mock low-level dependencies only when isolation is the explicit goal. For example, a focused unit test may need a low-level mock, but that choice should be made because the unit boundary is the behavior being tested, not because mocking is easier.

Deep-dive exercises

  1. Build a complete form test matrix.
  2. Test Query cache reuse between two consumers.
  3. Write optimistic concurrency rollback test.
  4. Test direct nested route with memory router.
  5. Add accessibility scan + manual keyboard checklist.
  6. Add Playwright network failure scenario.
  7. Configure contract fixture to match backend schema.
  8. Investigate one intentionally flaky test using traces rather than increasing timeout.

Mastery check

Explain:

  • behavior testing;
  • jsdom limitations;
  • MSW boundary value;
  • Query test isolation;
  • optimistic concurrency testing;
  • accessibility manual requirements;
  • risk-based E2E strategy.

Production case study: test the state-owner boundaries, not only individual components

For the final Task Workspace, write a test that exercises several state owners together:

text
URL status=open
→ Query requests open tasks
→ response shows one task
→ user completes task
→ mutation optimistic state appears
→ server succeeds
→ list query invalidates
→ refreshed response has zero open tasks
→ Empty state appears
→ URL remains status=open

This proves:

  • Router owns filter;
  • Query key uses filter;
  • mutation updates/invalidation work;
  • empty state is correct;
  • no Redux/local copy keeps stale task visible.

A component-only test for TaskRow cannot prove this architecture. It can prove the row's own rendering and interaction, but not whether routing, query keys, invalidation, and the empty state remain coordinated.

An E2E test could prove even more, but an integration test with memory router + real QueryClient + MSW is faster and still exercises critical boundaries.

Use the cheapest test level that proves the risk. Broader scope is valuable only when the narrower level cannot establish the behavior you care about.


Additional depth: testing Suspense, Actions, and Error Boundaries

These cases involve asynchronous or exceptional rendering, so the same rule still applies: control the boundary, then assert what the user can observe.

Suspense

Render with a controlled Promise/resource. The controlled resource lets the test hold rendering in the suspended state and then release it deterministically.

Assert:

text
fallback visible
resolve
content visible

Avoid relying on arbitrary time. The promise resolution, not a sleep, should determine when the assertion can proceed.

Error Boundary

Use a test component:

jsx
function Broken({ fail }) {
  if (fail) {
    throw new Error('Boom');
  }

  return <p>Working</p>;
}

Wrap in a boundary and assert the fallback. This checks the boundary's user-visible recovery path rather than merely asserting that some component threw.

Suppress expected console noise only within the test and restore it afterward. Error boundaries and React may log the expected error while the test runs; limit that suppression so unrelated errors remain visible in the suite.

Action/form pending

Submit via userEvent.

Assert:

text
button pending/disabled
status visible
server resolves
success state

For server validation:

text
submit
MSW returns 422
field error associated
draft preserved

The pending assertion verifies that duplicate interaction is controlled while the request is active. The validation assertions verify both association of the error with its field and preservation of the user's draft after the server rejects the submission.

Activity

If testing hidden Activity behavior, assert user-observable behavior and state preservation rather than internal Effect scheduling, unless scheduling is exactly what your component contract depends on. A test should not fail merely because React changes how inactive work is scheduled internally.

React Compiler

Do not unit-test "compiler memoized this component" as application behavior.

Compiler correctness is a tooling responsibility.

Test performance-sensitive product behavior through profiling/benchmarks where necessary, not fragile render-count assertions. A render count can change because of an implementation or compiler improvement without changing the product behavior users depend on.

Reader page: /react/lesson/113/react-testing-components-network-accessibility-contracts-and-e2e