MENU
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.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',
},
});
â 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%)
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.export default {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
moduleNameMapper: { '\\.(css|less|scss)$': 'identity-obj-proxy' },
};
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');
});
â 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
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}.Ã 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)
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' });
});
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();
});
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);
});
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);
});
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();
});
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>
`);
});
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>
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 } } } },
];
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
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