Testing

This section documents React 19.x for the web, using function components and Hooks throughout (class components appear only where React still requires them, such as error boundaries). React Native is out of scope and gets only a pointer. The React 19 additions follow the current official React documentation. This content was generated with the assistance of AI and should be verified against react.dev before being relied on in production, since React APIs continue to evolve between releases.

This section’s bibliography lists the reference material consulted while preparing these pages.

Test React components the way a user experiences them: render, interact, assert on what is visible. The standard stack is a test runner (Jest or Vitest) plus React Testing Library (RTL) for component tests, Mock Service Worker (MSW) for the network, and Playwright or Cypress for a small end-to-end layer.

Test types

  • Unit — a pure function or a small component in isolation. Fast, many.

  • Integration — several components together (a form, a page) with the network mocked. The sweet spot for RTL.

  • End-to-end (e2e) — the real app in a real browser. Slow, few.

The test pyramid: many unit and integration tests, few e2e.

Test-runner setup

Jest is the long-standing default and is what most existing React projects use. Vitest is a drop-in alternative with a near-identical API that reuses your Vite config; prefer it for new Vite projects. Both need a DOM environment and the @testing-library/jest-dom matchers (toBeInTheDocument, toHaveTextContent, …​).

Jest (jest.config.js + jest.setup.js)
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};

// jest.setup.js
import '@testing-library/jest-dom';
Vitest (vitest.config.ts + vitest.setup.ts)
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], globals: true },
});

// vitest.setup.ts
import '@testing-library/jest-dom/vitest';

In the examples below, import …​ from 'vitest' and vi. map one-to-one to Jest globals and jest..

React Testing Library

Query the way users find things — by role, label, or text — and drive interactions with user-event.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';       // or: from '@jest/globals'
import Counter from './Counter';

test('increments on click', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  const button = screen.getByRole('button', { name: /count: 0/i });
  await user.click(button);

  expect(screen.getByRole('button', { name: /count: 1/i })).toBeInTheDocument();
});
  • getBy* throws if not found, queryBy* returns null (assert absence), findBy* is async (waits).

  • RTL wraps state updates in act() for you; you rarely call it directly. An act() warning means a state update happened outside an awaited interaction — usually a missing await user…​. or findBy*. See act.

  • Mock modules with the runner (vi.mock('./api') / jest.mock('./api')); mock the network with MSW (next section) rather than stubbing fetch by hand.

Mocking the network with MSW

Mock Service Worker intercepts requests at the network layer, so the component under test runs its real fetch / axios / query-client code against fake responses. One set of handlers works in unit tests, in the browser, and in Playwright.

src/test/server.ts — shared handlers
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

export const server = setupServer(
  http.get('/api/products/:id', ({ params }) =>
    HttpResponse.json({ id: params.id, name: 'Widget', price: 9.99 })),
);
Setup file — start once per test run
import { server } from './src/test/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());   // undo per-test overrides
afterAll(() => server.close());
A test using it — happy path and a per-test error override
import { render, screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from './src/test/server';
import ProductPage from './ProductPage';

test('renders the product name from the API', async () => {
  render(<ProductPage id="42" />);
  expect(await screen.findByText('Widget')).toBeInTheDocument();
});

test('shows an error when the API fails', async () => {
  server.use(
    http.get('/api/products/:id', () => new HttpResponse(null, { status: 500 })),
  );
  render(<ProductPage id="42" />);
  expect(await screen.findByRole('alert')).toHaveTextContent(/could not load/i);
});

Cross-link Data Fetching for the component side.

Testing custom Hooks

Use renderHook for logic that has no UI of its own:

import { renderHook, act } from '@testing-library/react';
import useToggle from './useToggle';

test('useToggle flips', () => {
  const { result } = renderHook(() => useToggle());
  act(() => result.current[1]());          // call toggle
  expect(result.current[0]).toBe(true);
});

Snapshot tests and coverage

  • Snapshot tests serialize rendered output and diff it on later runs. Use sparingly — large snapshots churn and get rubber-stamped. Prefer explicit assertions.

  • Coverage — jest --coverage or vitest --coverage. Treat it as a gap finder, not a target.

End-to-end tests

E2E tools drive a real browser against the running app. Reserve them for a handful of critical flows (sign-up, checkout, search) and keep them stable. Point them at a dev/preview server, or reuse the MSW handlers above so the backend is deterministic.

Playwright (tests/checkout.spec.ts)
import { test, expect } from '@playwright/test';

test('user can add a product to the cart', async ({ page }) => {
  await page.goto('/products/42');
  await page.getByRole('button', { name: /add to cart/i }).click();
  await expect(page.getByRole('status')).toHaveText(/1 item/i);
});

Run with npx playwright test; npx playwright codegen records a first draft, and traces (--trace on) make failures debuggable in CI.

Cypress (cypress/e2e/checkout.cy.ts)
describe('checkout', () => {
  it('adds a product to the cart', () => {
    cy.visit('/products/42');
    cy.findByRole('button', { name: /add to cart/i }).click();
    cy.findByRole('status').should('contain.text', '1 item');
  });
});

Run with npx cypress open (interactive) or npx cypress run (headless in CI); @testing-library/cypress adds the same findByRole queries used in RTL. See the Playwright docs and the Cypress docs.