Testing React Applications

A React test is not a language test. The function under test returns a tree of objects, and what you want to assert about — "the alert says the card was declined" — exists only after a renderer has turned that tree into DOM nodes. Testing React means assembling three pieces: a runner, a DOM implementation, and a query library that inspects the result the way a user would.Figure 2.50: The four pieces that run a React component testEverything here ran on Node 25.8.0 with Vitest 5.0.1, React 19.3.0, React Testing Library 16.3.3, jsdom 30.1.0, user-event 14.6.7, jest-dom 7.0.1 and MSW 2.15.0; the output is copied from those runs.

2.15.1 What to Test

The useful rule is the one Testing Library was built around: test the contract, not the construction. A component's contract is what it promises the person looking at the screen and the parent that renders it. Everything else is construction: which hook holds which value, how often the body ran. Contract Assert with Example What the user sees a query for text or role the alert appears after a failed save What the user can do user-event plus a query submit enables once both fields are filled What the parent gets a mock function onSelect is called with the row id What the outside world gets a request handler a PATCH is sent with the edited fields Table 2.21: The four things a component promises, and how each is checked Renaming a state variable or replacing useState with useReducer changes nothing a user can observe, so it must not change a test.The classic pyramid over-weights unit tests for front-end work, because an isolated React component rarely has interesting logic in it. React bugs live in the seams, so the shape that fits is heaviest in the middle: static checking first (Section 2.13 and Section 2.15.11), unit tests for reducers and custom hooks, integration tests for whole features — a form with its validation and submit handler is one test, not seven — and Playwright for the few flows that must never break.Write each test at the highest level that still fails for a single reason: if a broken form can only be diagnosed from a stack trace the test was too high, and if renaming a label breaks nine tests it was too low. Skip third-party code — React Router tests its own Link — and styling that carries no meaning: an assertion on class="btn btn-primary" fails on a design refresh while a genuinely broken button passes.

2.15.2 Setting Up Vitest

Vitest 5.0.1 is the default choice for a Vite project because it is not a second toolchain: it reads the same vite.config.js, so the JSX transform, path aliases and plugins that build your app also build your tests. Six development dependencies cover a React project — vitest, @vitejs/plugin-react 6.1.1, jsdom 30.1.0, and @testing-library/react 16.3.3, /jest-dom 7.0.1 and /user-event 14.6.7.

/// <reference types="vitest/config" />

import { defineConfig } from 'vite';

import react from '@vitejs/plugin-react';

export default defineConfig({

plugins: [react()],

test: {

environment: 'jsdom', // give every test file a document

globals: true, // test/expect/vi without importing them

setupFiles: './src/setupTests.js',

},

});

Listing 2.120: vite.config.js with a test block The triple-slash reference matters only in TypeScript projects, so the test key type-checks. globals: true is what lets an existing Jest suite run unchanged; without it every file starts with import { test, expect, vi } from 'vitest'.The setup file's whole job is one line — import '@testing-library/jest-dom/vitest' — which turns toBeInTheDocument, toHaveTextContent, toBeDisabled and thirty siblings into real assertions. The /vitest entry point exists because expect.extend has to be called on the right expect; importing plain @testing-library/jest-dom is the usual reason those matchers appear to be missing.

✓ src/Counter.test.jsx > starts at zero 26ms

✓ src/Counter.test.jsx > counts clicks 195ms

Duration 1.81s (environment 64%, tests 14%, setup 11%, import 7%, transform 3%)

Output of Listing 2.120 Per-file lines come from --reporter=verbose; "test": "vitest" gives watch mode, "test:run": "vitest run" a single pass for continuous integration. Read the duration breakdown when a suite feels slow: environment 64% means the time went into a fresh jsdom per worker, not your assertions. Note: jsdom implements the DOM, not a rendering engine. It has no layout, so getBoundingClientRect returns zeros and anything depending on real geometry cannot be tested there. Vitest can run the same files in a real Chromium (vitest --browser=chromium), with vitest-browser-react 2.3.0 supplying render.

2.15.3 Jest

Jest is the runner most React suites were written against. Facebook released it in 2014 and donated it to the OpenJS Foundation in 2022. Jest 30.5.1 is current, and you will meet it in older codebases and in React Native, where it remains the default. The React-specific work is identical in both runners — render, screen, getByRole, userEvent; what differs is the machinery. Vitest 5.0.1 Jest 30.5.1 Transform your Vite config Babel or SWC, configured separately ES modules native native, or transpiled to CommonJS Mock API vi.fn, vi.mock, vi.spyOn jest.fn, jest.mock, jest.spyOn Watch mode Vite module graph file-system watcher Table 2.22: The two mainstream React runners compared The overlap is deliberate — Vitest set out to be a drop-in replacement — so a Jest test usually runs under Vitest once vi replaces jest. Setting Jest up by hand takes three files, because it has no bundler behind it.

export default {

testEnvironment: 'jsdom',

setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],

moduleNameMapper: { '\\.(css|less|scss)$': 'identity-obj-proxy' },

};

Listing 2.121: jest.config.js moduleNameMapper exists because Node cannot import './Button.css'; every non-JavaScript import needs a stub such as identity-obj-proxy. jsdom moved out of the Jest package in version 28, so jest-environment-jsdom must be installed separately — omitting it produces "Test environment jsdom cannot be found". The JSX transform comes from a third file, babel.config.js, with @babel/preset-react in runtime: 'automatic' mode. History: Create React App shipped this configuration prepackaged, which is why a generation of tutorials say testing "just works". CRA was deprecated in February 2025; react-scripts test wraps these same settings. Choose Jest for React Native or for a large suite whose custom transformers would have to be ported, Vitest for anything built on Vite. Never both in one package: two expect implementations in one process produce errors naming matchers you never wrote.

2.15.4 React Testing Library

render mounts a component into a container appended to document.body; screen is a set of queries bound to that body. Cleanup is automatic, because both runners expose a global afterEach. There is no wrapper object, no instance, no way to reach into state — by design.Start with a component that has a bug: Counter renders <p role="status">Clicked {count} times</p> and a button whose handler is () => setCount(c => c + 1), ignoring the step prop it accepts.

import { render, screen } from '@testing-library/react';

import userEvent from '@testing-library/user-event';

import Counter from './Counter';

test('starts at zero', () => {

render(<Counter />);

expect(screen.getByRole('status')).toHaveTextContent('Clicked 0 times');

});

test('adds step on every click', async () => {

const user = userEvent.setup();

render(<Counter step={5} />);

const button = screen.getByRole('button', { name: /click me/i });

await user.click(button);

await user.click(button);

expect(screen.getByRole('status').textContent).toBe('Clicked 10 times');

});

Listing 2.122: src/Counter.test.jsx Neither test mentions useState or the number of renders — only a status message, a button with a visible label, and a number the user can read:

✓ src/Counter.test.jsx > starts at zero 122ms

× src/Counter.test.jsx > adds step on every click 138ms

FAIL src/Counter.test.jsx > adds step on every click

AssertionError: expected 'Clicked 2 times' to be 'Clicked 10 times' // Object.is equality

Expected: "Clicked 10 times"

Received: "Clicked 2 times"

❯ src/Counter.test.jsx:16:50

Output of Listing 2.122 Two clicks produced 2, not 10: step is ignored. Change the handler to setCount(c => c + step) and both tests pass.render also returns an object for the cases screen cannot cover: container is the wrapping <div>, used for snapshots; rerender(<Counter step={2} />) re-renders the same instance with new props; unmount() tears the tree down so you can assert that a cleanup ran; debug() pretty-prints the markup; wrapper renders children inside every provider the tree expects. Under the hood: render calls createRoot(container).render(ui) inside React's act(), which drains the work queue and flushes every effect before returning — which is why an assertion on the line after render already sees the result of useEffect. The library wraps render, rerender, unmount and fireEvent in act, so a manual act(...) is needed only when you advance timers yourself.

2.15.5 Queries

Every Testing Library query is built from three choices combined into one camel-case name: {get|query|find}[All]By{Role|LabelText|PlaceholderText|Text|DisplayValue|AltText|Title|TestId}. Prefix No match One match Several Returns getBy throws the node throws node queryBy null the node throws node or null findBy throws after 1 s the node throws Promise Table 2.23: What each query prefix does, by number of matches The All variants — getAllBy, queryAllBy, findAllBy — behave the same way except that several matches are the expected case, and each returns an array. The choice is mechanical: getBy for something that must be there now, because the thrown error beats expect(null); queryBy only with not.toBeInTheDocument(), the one prefix that can report absence; findBy for anything that arrives after an await.For the suffix the order is ByRole with a name, then ByLabelText, then ByText, with ByTestId last. That is not style advice: a query that finds a node by accessible role and name can only succeed if assistive technology can find it too (Section 2.14). When the role is wrong:

× src/LoginForm.test.jsx > has a heading 141ms

→ Unable to find an accessible element with the role "heading" and name `/sign in/i`

Here are the accessible roles:

textbox:

Name "Email":

<input id="email" name="email" placeholder="you@example.com" />

... (each attribute printed on its own line; the button, name "Sign in", follows)

Output of Listing 2.122 The error lists every role in the tree with its accessible name, usually enough to fix the test without opening the component. Note what is missing: <input type="password"> has no ARIA role at all, so no getByRole call can reach it — which is why getByLabelText('Password') exists.String matching is exact and whitespace-normalized; pass a regular expression for anything more precise, since /^Save$/ cannot match "Save as draft". within(node) rebinds the query set to one subtree, which saves you in a table where getByRole('button', { name: 'Delete' }) finds twelve.

2.15.6 Simulating User Interaction

There are two ways to drive a component and they are not equivalent. fireEvent.click(node) dispatches one click event. userEvent.click(node) dispatches what a browser dispatches when a person clicks: pointerover, pointerdown, mousedown, focus, pointerup, mouseup, click. If your component listens on mousedown, moves focus, or opens on hover, only the second reproduces the bug. user.type goes further still — it fires keydown, keypress, input and keyup per character, respects maxlength and refuses to type into a disabled input. Call userEvent.setup() once per test before render: it returns an API bound to a fresh session with its own clipboard and pointer state.

test('submits what the user typed', async () => {

const user = userEvent.setup();

const onSubmit = vi.fn();

render(<LoginForm onSubmit={onSubmit} />);

await user.type(screen.getByRole('textbox', { name: 'Email' }), 'ada@example.com');

await user.type(screen.getByLabelText('Password'), 'lovelace');

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

expect(onSubmit).toHaveBeenCalledTimes(1);

expect(Object.fromEntries(onSubmit.mock.calls[0][0]))

.toEqual({ email: 'ada@example.com', password: 'lovelace' });

});

Listing 2.123: src/LoginForm.test.jsx: filling in and submitting a form That test passes in 540 ms. vi.fn() records every call in mock.calls, so the assertion can be about the argument — here the FormData the submit handler built — rather than the fact that something happened.A second test, "keyboard alone can reach the button", is worth writing for any form: tab order is the accessibility defect component tests usually miss. Three await user.tab() calls walk the focus ring, then toHaveFocus() proves the submit button is reachable without a mouse. The rest of the API is dblClick, hover, unhover, keyboard('{Enter}'), selectOptions, upload and clear. Warning: Every user-event call is asynchronous. A missing await produces a test that passes locally and fails on a slower machine, and React logs "An update to X inside a test was not wrapped in act(...)" — treat that as a missing await, not as noise to silence.

2.15.7 Testing Asynchronous Behavior

Anything that arrives later — a fetch, a transition, a lazy chunk, a debounce — needs a query that retries rather than one that looks once. findBy* polls the DOM through a MutationObserver until the query succeeds or the timeout expires (1000 ms by default), and its promise resolves inside act, so React's queued work is flushed first: expect(await screen.findByRole('heading')).toHaveTextContent('Hello there'). waitFor(callback) is the general form, re-running a callback until it stops throwing, and suits things that are not DOM nodes. waitForElementToBeRemoved(() => screen.getByText('Loading...')) is the correct way to wait for a spinner to vanish; a bare waitFor asserting absence passes before the spinner has even appeared. Never await a fixed delay: slower than necessary on fast machines, flaky on slow ones.Timers are the exception: waiting a real five seconds for a toast to hide is unacceptable, so replace the clock with one you advance by hand — inside act, since advancing it triggers a state update.

beforeEach(() => vi.useFakeTimers());

afterEach(() => vi.useRealTimers());

test('disappears after five seconds', () => {

render(<Toast message="Saved" />);

expect(screen.getByRole('status')).toHaveTextContent('Saved');

act(() => vi.advanceTimersByTime(4999));

expect(screen.getByRole('status')).toBeInTheDocument();

act(() => vi.advanceTimersByTime(1));

expect(screen.queryByRole('status')).not.toBeInTheDocument();

});

Listing 2.124: src/Toast.test.jsx: five seconds in one millisecond of real time Five seconds of simulated waiting take 119 ms of real time. The 4999/1 split pins the boundary, so a later refactor that changes the delay fails loudly. Restore real timers in afterEach: a leaked fake clock makes every later test hang until Vitest's own timeout fires, and the failure then appears in an unrelated file. Warning: userEvent.setup() also uses timers, so mixing it with vi.useFakeTimers() deadlocks: the library waits for a delay the frozen clock never delivers. Pass userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) when a test needs both.

2.15.8 Testing Hooks

A hook cannot be called outside a component, so it cannot be called from a test either. renderHook closes the gap: it renders a throwaway component whose only job is to call your hook and publish the return value on result.current. Here useUndoable keeps a history array and an index, returning { value, set, undo, canUndo }.

test('records values and steps back through them', () => {

const { result } = renderHook(() => useUndoable('a'));

expect(result.current.value).toBe('a');

expect(result.current.canUndo).toBe(false);

act(() => result.current.set('b'));

act(() => result.current.set('c'));

expect(result.current.value).toBe('c');

act(() => result.current.undo());

expect(result.current.value).toBe('b');

expect(result.current.canUndo).toBe(true);

});

Listing 2.125: src/useUndoable.test.jsx That test and a second one pass in 17 ms. Two rules explain every mistake made here. Anything that updates state goes inside act, because renderHook has no events to piggyback on. And result.current is a snapshot, not a live binding: destructuring const { set } = result.current captures the first render's closure, so later reads go stale.renderHook takes the same options as render, plus initialProps, which pairs with rerender to test how a hook responds to changing arguments. The second test starts from initialProps: { start: 'x' }, calls rerender({ start: 'y' }), and asserts the value is still 'x' — real behavior, since useState(start) reads its argument only on the first render (Section 2.3.5). Pass wrapper when the hook consumes context, or it silently reads the default value. Tip: Reserve renderHook for hooks you publish or reuse. A hook used by one component is better tested through that component, where the assertions describe behavior a user can see.

2.15.9 Mocking Modules and the Network

Two things in a component test have to be replaced: modules that cannot run under jsdom — a maps SDK, anything touching canvas or WebGL — and the network. vi.mock(path, factory) swaps a module for whatever the factory returns, hoisted above the imports so the mock is in place before the component loads, which is why the mocked module can also be imported normally in the same file.

import { reverseGeocode } from './geo'; // resolves to the mock below

vi.mock('./geo', () => ({ reverseGeocode: vi.fn(async () => 'Kuala Lumpur') }));

test('shows the city the geocoder returned', async () => {

render(<Where lat={3.14} lon={101.7} />);

expect(screen.getByText('Locating...')).toBeInTheDocument();

expect(await screen.findByText('Kuala Lumpur')).toBeInTheDocument();

expect(reverseGeocode).toHaveBeenCalledWith(3.14, 101.7);

});

Listing 2.126: src/Where.test.jsx: standing in for a geocoding module The imported reverseGeocode is the mock, so one binding serves as both the stub and the spy. A second test calls reverseGeocode.mockResolvedValueOnce('Penang'), overriding a single call without touching the module-level default; afterEach(() => vi.clearAllMocks()) resets the recorded calls between tests, while vi.resetAllMocks() also discards the implementations. Mocking a component works the same way — return a stub rendering only the props you care about.Do not mock fetch. A stubbed fetch asserts that your component called a function, not that it sent a correct request, and it keeps passing after you migrate to Axios or TanStack Query. Mock Service Worker (mswjs/msw (https://github.com/mswjs/msw)) 2.15.0, installed with npm i -D msw, intercepts at the transport layer instead, so your code performs a genuine request and MSW answers it.

import { http, HttpResponse } from 'msw';

import { setupServer } from 'msw/node';

const url = 'https://api.example.com/greeting';

const server = setupServer(http.get(url, () => HttpResponse.json({ greeting: 'Hi there' })));

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

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

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

test('reports a server error', async () => {

server.use(http.get(url, () => new HttpResponse(null, { status: 500 })));

render(<Greeting url={url} />);

await userEvent.setup().click(screen.getByRole('button'));

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

expect(screen.getByRole('button')).toBeEnabled();

});

Listing 2.127: src/Greeting.test.jsx: a server that lives in the test file That test and a happy-path sibling pass in 243 ms. Three lines carry most of the value. onUnhandledRequest: 'error' fails the run when a component calls an endpoint nobody declared; server.use(...) adds a handler for one test only and resetHandlers() removes it. Because handlers are ordinary functions, the same file can drive the dev server through setupWorker.

2.15.10 Snapshot Testing

A snapshot assertion has no expected value written by hand: the first run records what the component rendered, and every later run compares against that record. Write toMatchInlineSnapshot() with empty parentheses, run once, and the runner rewrites the test file in place:

test('renders a warning badge', () => {

const { container } = render(<Badge level="warning">Late</Badge>);

expect(container.firstChild).toMatchInlineSnapshot(`

<span

class="badge badge-warning"

>

Late

</span>

`);

});

Listing 2.128: src/Badge.test.jsx after Vitest filled in the snapshot Now change the component — swap the class for a Bootstrap 5 utility, add a data attribute — and the mismatch is reported as a diff:

FAIL src/Badge.test.jsx > renders a warning badge: snapshot mismatched

- Expected

+ Received

<span

- class="badge badge-warning"

+ class="badge text-bg-warning"

+ data-level="warning"

>

Late

</span>

Output of Listing 2.128 You did not have to predict the markup, and nothing can change without somebody approving it. Press u in watch mode, or run vitest run -u, to accept the new output. toMatchSnapshot() writes to __snapshots__/Badge.test.jsx.snap instead, which suits large trees; the inline form keeps the expectation in the pull request diff. Both are committed — a snapshot nobody reviews is not a test.The failure mode is social rather than technical. A large snapshot that breaks on every unrelated change trains the team to run -u without reading the diff, and once that habit forms the snapshot detects nothing. Keep them small — one element, not a page — and never snapshot anything unstable: a date, a random id, a generated class name. Prefer an explicit assertion whenever you can name what you mean: expect(badge).toHaveClass('text-bg-warning') says that this class matters and the rest do not. Snapshots earn their place locking down a design-system primitive and characterizing legacy code before a refactor.

2.15.11 Linting Hooks

The cheapest tests in a React project are not tests. eslint-plugin-react-hooks 7.1.1 reports violations no runtime test would catch, because the broken cases appear only on a path you did not exercise. Install it with npm i -D eslint eslint-plugin-react-hooks and spread its recommended flat config:

import reactHooks from 'eslint-plugin-react-hooks';

export default [

reactHooks.configs.flat.recommended,

{ files: ['**/*.{js,jsx}'],

languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } },

];

Listing 2.129: eslint.config.js The plugin identifies hooks by name — any function starting with use and a capital letter — and requires that they be called only from a PascalCase function or another useSomething, unconditionally, in the same order on every render (Section 2.9.1 explains why). Point it at a useState inside an if and an effect that reads a prop it never declared:

7:21 error React Hook "useState" is called conditionally. React Hooks must be

called in the exact same order in every render rules-of-hooks

15:6 warning React Hook useEffect has a missing dependency: 'id'. Either include

it or remove the dependency array exhaustive-deps

Output of Listing 2.129 exhaustive-deps is the rule people disable. Resist: an effect whose array omits a value it reads keeps using the value from the render in which it last ran — exactly the stale fetch above.Version 7 is a bigger tool than its name suggests. Since React Compiler reached 1.0 in October 2025, the recommended preset also enables rules built on the compiler's analysis — set-state-in-effect, purity, refs, immutability and a dozen more — which check the Rules of React, not only the Rules of Hooks. The compiler itself need not be installed:

8:5 error Calling setState synchronously within an effect can trigger

cascading renders

> 8 | setCount(items.length);

| ^^^^^^^^ Avoid calling setState() directly within an effect

12:14 error Cannot call impure function during render

> 12 | const id = Math.random();

...

14:47 error Cannot access refs during render

Output of Listing 2.129 Every one is a real defect: the effect forces a second render pass, Math.random() gives the element a new id each render, and reading ref.current during render returns whatever the previous commit left behind (Section 2.2.8 and Section 2.4.9). Run the linter beside the tests — "verify": "eslint . && vitest run" — so a pull request cannot be green while breaking the rules React depends on. Adopt them as warnings first on an existing codebase; enabling seventeen at once produces a wall nobody reads.