Testing React
Components, hooks, context, server components and async state — what to test in a React application, what to leave alone, and how to avoid act warnings.
2 min read · updated 19 September 2026
React has no dependency injection, so doubles arrive as props or as module mocks, and almost everything worth testing is observable in the rendered output.
#The default shape
import { render, screen } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import { BasketLine } from './BasketLine';
test('recalculates the line total when the quantity changes', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<BasketLine sku="book-1" unitCents={1_200} quantity={1} onChange={onChange} />);
await user.clear(screen.getByRole('spinbutton', { name: 'Quantity' }));
await user.type(screen.getByRole('spinbutton', { name: 'Quantity' }), '3');
expect(screen.getByText('£36.00')).toBeVisible();
expect(onChange).toHaveBeenLastCalledWith({ sku: 'book-1', quantity: 3 });
});Props in, DOM and callbacks out. No state inspection, no instance methods — see Testing Library for why that restriction is the feature.
#The act warning
An update to Basket inside a test was not wrapped in act(...)
It means state changed after the test's synchronous body finished. Almost always a promise resolving after the assertions ran.
// Causes the warning: the fetch resolves after the test body ends.
test('loads the basket', () => {
render(<Basket />);
expect(screen.getByText('Loading')).toBeVisible();
});
// Fixed: await the post-load state, so the test outlives the update.
test('loads the basket', async () => {
render(<Basket />);
expect(screen.getByText('Loading')).toBeVisible();
expect(await screen.findByRole('list', { name: 'Basket' })).toBeVisible();
});Wrapping things in act() by hand is almost never the right fix. render
and userEvent are already act-wrapped; the problem is the test finishing
too early.
#Async data, with MSW
Mocking fetch skips your real data layer. Intercept the network instead:
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/basket', () =>
HttpResponse.json({ lines: [{ sku: 'book-1', quantity: 2, unitCents: 1_200 }] })
)
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('shows an error and a retry when the basket cannot be loaded', async () => {
server.use(http.get('/api/basket', () => HttpResponse.json({}, { status: 500 })));
render(<Basket />);
expect(await screen.findByRole('alert')).toHaveTextContent(/could not load/i);
expect(screen.getByRole('button', { name: 'Try again' })).toBeEnabled();
});More on this in jest mocking and integration testing with stubs.
#Hooks
import { renderHook, act } from '@testing-library/react';
test('usePagination clamps to the last page', () => {
const { result } = renderHook(() => usePagination({ total: 42, perPage: 20 }));
act(() => result.current.goTo(99));
expect(result.current.page).toBe(3);
expect(result.current.isLast).toBe(true);
});
// With the providers the hook needs
const { result } = renderHook(() => useBasket(), { wrapper: AllProviders });renderHook is right for a reusable hook with real logic. For a hook used
by exactly one component, testing through the component covers more and
couples less.
#Context
// Render with the provider, not by mocking useContext.
render(
<FeatureFlags value={{ newCheckout: true }}>
<Checkout />
</FeatureFlags>
);
expect(screen.getByRole('heading', { name: 'Checkout' })).toBeVisible();Mocking the context module works and produces a test that passes when the provider is wired up wrongly. Render the real provider with test values.
#Server components
React Server Components are async functions that execute on the server and emit a payload. Testing Library cannot render them, and the workarounds are not worth the trouble.
The practical split:
// Server component: keep it thin, push logic into plain functions.
export default async function OrdersPage({ searchParams }: Props) {
const filter = parseOrderFilter(await searchParams); // ← unit test this
const orders = await listOrders(filter); // ← integration test this
return <OrderTable orders={orders} />; // ← component test this
}Test parseOrderFilter as a pure unit, listOrders against a real
database, OrderTable with Testing Library, and the assembled page with
Playwright. That is the
pyramid applied to the App Router, and it
is cheaper than any attempt to unit-test the server component itself.
#What not to test
- That a prop is passed through. It is the implementation.
- Component internals — state, refs, effect call counts.
- Library behaviour. React Query caches; you do not need to prove it.
- Every render permutation via snapshots. A 400-line snapshot fails on every change and is approved without reading.
#What to test
- Conditional rendering: empty, loading, error, populated
- Interaction outcomes: what the user sees after acting
- Accessibility affordances: roles, names, focus management,
aria-live - Edge data: zero items, one item, a very long name, a missing optional field
#Configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEach: [],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
transform: { '^.+\\.(t|j)sx?$': '@swc/jest' },
moduleNameMapper: { '^@/(.*)$': '<rootDir>/$1', '\\.(css|svg)$': '<rootDir>/__mocks__/file.js' }
};// jest.setup.ts
import '@testing-library/jest-dom';
// Fail a test on an unexpected React warning — most of them are real bugs.
const error = console.error;
beforeAll(() => {
console.error = (...args) => {
error(...args);
throw new Error(`console.error in a test: ${args[0]}`);
};
});Vitest is the better fit for a Vite-based React app; the test bodies are identical either way.
Common questions
- Should I test custom hooks directly or through a component?
- Through a component when the hook is used in one place — the test then covers the real integration. Directly with renderHook when the hook is a reusable primitive with logic of its own, such as a pagination or form-state hook.
- What causes "An update to X inside a test was not wrapped in act"?
- State updated after the test's synchronous body finished — usually a promise resolving. The fix is almost never to wrap things in act manually; it is to await the assertion (findBy, waitFor) so the test does not finish before the update lands.
- How do I test React Server Components?
- Not with Testing Library — they are async functions that run on the server and produce a payload, not a DOM. Test the data functions they call as plain units, and cover the rendered result with a browser test.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/web-frameworks/react-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Testing LibraryThe query API shared across React, Vue, Angular and Svelte — its guiding principle, the queries, user-event, and the mistakes it is designed to prevent.
- DOM TestingTesting the layer every front-end framework produces — queries by role and accessible name, event simulation, jsdom's limits, and why selector choice decides maintainability.
- JestThe default JavaScript test runner — configuration, projects, snapshot testing, fake timers, and the choices that keep a large Jest suite fast.
- Jest MockingModule mocks, spies, manual mocks and timer control — how Jest's mocking works, and the patterns that keep it from taking over a suite.
- Snapshot TestingRecording output and comparing it on every run — where snapshots earn their place, the approval reflex that destroys their value, and better alternatives.
- VitestA Vite-native test runner with a Jest-compatible API — faster startup, native ESM and TypeScript, and the cases where it is the better default.
- Writing Tests in TypeScriptThe TypeScript testing toolchain — Jest or Vitest, typed doubles, async idioms, and the type-level tricks that make tests both safer and more readable.
- Component TestingTesting one deployable in isolation through its real interface, with its own dependencies containerised and everything beyond its boundary stubbed.