Back to Testing
Testing
medium
mid

How do you write unit tests in React using Jest and React Testing Library?

Jest is the test runner; React Testing Library (RTL) is the rendering + querying layer. Philosophy: test BEHAVIOR (what users see and do), not implementation. Use `render`, query by role/text (`getByRole`, `findByText`), simulate with `userEvent`, assert with `expect`. Mock network with msw, not fetch stubs.

8 min read·~15 min to think through

Modern React testing centers on user-visible behavior, not internals.

Setup

bash
npm i -D jest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

jest.config.ts:

ts
export default {
  testEnvironment: 'jsdom',
  setupFilesAfterEach: ['<rootDir>/test/setup.ts'],
};

test/setup.ts:

ts
import '@testing-library/jest-dom';

A first test

tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

test('increments on click', async () => {
  render(<Counter />);
  expect(screen.getByText(/count: 0/i)).toBeInTheDocument();

  await userEvent.click(screen.getByRole('button', { name: /increment/i }));
  expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});

Query priority (RTL's golden rule)

  1. getByRole — accessible roles (button, link, heading). Forces accessible markup.
  2. getByLabelText — form fields by their label.
  3. getByPlaceholderText — second choice for inputs.
  4. getByText — visible text.
  5. getByTestId — escape hatch only.

If you have to reach for testId, ask whether your component is accessible at all.

Async behavior

tsx
test('loads user', async () => {
  render(<UserCard id="1" />);
  expect(await screen.findByText('Alice')).toBeInTheDocument();
});

findBy* waits up to 1 second; waitFor for arbitrary assertions.

Mocking network — use msw, not fetch stubs

ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/user/1', () => HttpResponse.json({ name: 'Alice' })),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

msw intercepts at the network layer so your component code runs the real fetch path.

Testing custom hooks

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

test('useCounter', () => {
  const { result } = renderHook(() => useCounter(0));
  act(() => result.current.inc());
  expect(result.current.count).toBe(1);
});

What NOT to test

  • Implementation details: state names, function references, internal effects.
  • Library code: don't test that React re-renders.
  • Style: visual tests (Playwright/Chromatic) handle this.

Coverage strategy

Pyramid: many small unit tests for utility functions, fewer component tests for behavior, a handful of integration/E2E tests for critical journeys.

Common patterns

  • act() warnings: usually mean an async update happened outside a wait. Use findBy or wrap in waitFor.
  • Snapshot tests: useful sparingly — they break on every layout tweak and tend to be rubber-stamped.
  • userEvent vs fireEvent: prefer userEvent — it simulates the full event sequence (focus, mousedown, click, etc.) that real users trigger.

Follow-up questions

  • Why is queryByRole preferred over queryByTestId?
  • When would you use waitFor vs findBy?
  • How do you test components that consume context?

Common mistakes

  • Testing internal state: `expect(wrapper.state().count)` is Enzyme-era and fragile.
  • Reaching for testId on everything — usually means the component isn't accessible.
  • Mocking fetch directly when msw intercepts at the network layer with less code.

Performance considerations

  • Jest with jsdom is slow at startup. Vitest is significantly faster if you control the stack. Parallelize with --maxWorkers; isolate global setup; avoid heavy beforeEach.

Edge cases

  • Portals: RTL queries the whole document by default, so portaled content is reachable.
  • Async + StrictMode dev double-mount can cause spurious cleanup warnings — make effects idempotent.
  • Timers: use `jest.useFakeTimers()` and `vi.advanceTimersByTime()` for debounce/throttle tests.

Real-world examples

  • Most React open-source libraries (TanStack Query, React Hook Form) use RTL + msw. The pattern is standard enough that 'unit test in React' essentially means 'RTL test' in 2025.

Senior engineer discussion

Senior framing: test the contract, not the wiring. If you refactor a component's internals and your tests pass, they're well-written. If they break, you were over-coupled. Coverage is a guide, not a goal — 80% well-written beats 100% snapshot-stamped.

Related questions