Skip to content
End To End Tester

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

tsx
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.

tsx
// 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:

tsx
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

tsx
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

tsx
// 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:

tsx
// 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

javascript
// 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' }
};
typescript
// 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 ↗
  • TypeScripttypescript/src/web-frameworks/react-testing

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?