Skip to content

Advanced React: Hooks, Composition, Testing, and Performance

Step 29 of 45

React Testing Library and Jest: Test User Behavior

Introduction

This is lesson 5 of chapter 6, aligned with course 6 of the Meta Front-End Developer pathway ("Advanced React", domain advanced-react). You can build rich interfaces, but how do you know a change did not break the booking form? Automated tests answer that question. In chapter 2 you wrote your first Jest tests for plain JavaScript functions. This lesson applies the same ideas to React components with React Testing Library (RTL).

RTL's guiding principle is: test the software the way users use it. A user does not call setState; they read text, type in fields, and click buttons. So tests find elements by what the user sees (roles, labels, text), perform realistic interactions, and assert on what appears on screen. This makes tests resilient: you can refactor internals (rename state, switch from useState to useReducer) and the tests keep passing as long as the behavior stays the same.

The scenario is original: tests for the Harbor Workshops booking form, the workshop list that loads data, and the cart reducer.

Learning objectives

  1. Set up Jest (or a compatible runner) with the jsdom environment and RTL.
  2. Render a component and query the output with screen and role-based queries.
  3. Choose between getBy, queryBy, and findBy queries.
  4. Simulate user actions with userEvent and wait for asynchronous updates.
  5. Mock network calls and modules without testing implementation details.
  6. Test a custom hook or reducer directly.
  7. Write accessible-friendly queries that double as an accessibility check.
  8. Decide what to test, and what not to test.

1. Tooling

A typical setup uses these packages:

Package Role
jest Test runner, assertions (expect), mocks, watch mode
jest-environment-jsdom Simulated browser DOM (document, window) in Node
@testing-library/react render, screen, waitFor
@testing-library/user-event Realistic user interactions (typing, clicking, tabbing)
@testing-library/jest-dom Extra matchers such as toBeInTheDocument, toBeDisabled, toHaveValue
A transformer (Babel or similar) Converts JSX and modern syntax for Jest

A standalone JavaScript/JSX setup can use Jest 30, Babel 7, and user-event 14.

Bash
npm install --save-dev jest@30 jest-environment-jsdom@30 babel-jest@30 @babel/core@7 @babel/preset-env@7 @babel/preset-react@7 @testing-library/react @testing-library/dom @testing-library/user-event@14 @testing-library/jest-dom

Add "test": "jest" to the existing package.json scripts, keeping the app dependencies and scripts. Use CJS configuration filenames even in a Vite project with type: module:

JavaScript
// jest.config.cjs
module.exports = {
  testEnvironment: 'jsdom',
  testEnvironmentOptions: { url: 'https://example.test/' },
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  transform: { '^.+\\.jsx?$': 'babel-jest' },
};
JavaScript
// babel.config.cjs
module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' }, modules: 'commonjs' }],
    ['@babel/preset-react', { runtime: 'automatic' }],
  ],
};
JavaScript
// src/setupTests.js
import '@testing-library/jest-dom';
import { TextEncoder, TextDecoder } from 'node:util';
globalThis.TextEncoder ??= TextEncoder;
globalThis.TextDecoder ??= TextDecoder;

The encoding globals support libraries expecting browser APIs in jsdom. Components importing CSS/assets or aliases need corresponding Jest mappings; Jest does not execute Vite plugins. Vitest can use the Vite configuration, but mocks, setup, and globals require its own configuration. The examples below consistently use Jest. jsdom does not reproduce layout, painting, or assistive technology; complement it with real-browser and accessibility checks.

2. Your first component test

JSX
// WorkshopCard.jsx
export function WorkshopCard({ title, seatsLeft, onReserve }) {
  return (
    <article>
      <h3>{title}</h3>
      <p>{seatsLeft === 0 ? 'Sold out' : `${seatsLeft} seats left`}</p>
      <button type="button" disabled={seatsLeft === 0} onClick={onReserve}>
        Reserve
      </button>
    </article>
  );
}
JSX
// WorkshopCard.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { WorkshopCard } from './WorkshopCard';

test('shows the seats and reports a reservation click', async () => {
  const user = userEvent.setup();
  const onReserve = jest.fn();

  render(<WorkshopCard title="Wheel pottery" seatsLeft={3} onReserve={onReserve} />);

  expect(screen.getByRole('heading', { name: /wheel pottery/i })).toBeInTheDocument();
  expect(screen.getByText('3 seats left')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: /reserve/i }));
  expect(onReserve).toHaveBeenCalledTimes(1);
});

test('disables reservation when sold out', () => {
  render(<WorkshopCard title="Linocut" seatsLeft={0} onReserve={() => {}} />);
  expect(screen.getByText('Sold out')).toBeInTheDocument();
  expect(screen.getByRole('button', { name: /reserve/i })).toBeDisabled();
});

The pattern is Arrange, Act, Assert: render with props, interact, then check the visible result. jest.fn() creates a mock function that records its calls so you can assert that a callback was triggered.

Query types

Three families of queries exist, each with variants for one or many elements:

Prefix If not found Async Use it for
getBy… / getAllBy… Throws an error No Elements that must be present now
queryBy… / queryAllBy… Returns null / [] respectively No Asserting that something is absent
findBy… / findAllBy… Rejects after a timeout Yes Elements that appear later (after fetch or timers)
JSX
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); // absence
expect(await screen.findByText('Sourdough basics')).toBeInTheDocument(); // appears later

Query priority

RTL recommends this order, from most to least preferred:

  1. getByRole with name (buttons, headings, textboxes, checkboxes); it mirrors how assistive technology sees the page.
  2. getByLabelText for form fields.
  3. getByPlaceholderText, getByText, getByDisplayValue.
  4. getByAltText, getByTitle.
  5. getByTestId (the data-testid attribute) only when nothing else works.

Some controls, such as password inputs, have no implicit role and are best queried by label. A missing accessible name or expected semantic role can reveal an accessibility problem: a missing <label> or a div posing as a button. The test failure is a useful signal.

3. Testing forms

Use controlled React forms and validation to define the expected input, error, and submit behavior.

JSX
// BookingForm.jsx
import { useState } from 'react';
export function BookingForm({ onSubmit }) {
  const [name, setName] = useState('');
  const [error, setError] = useState('');

  function handleSubmit(event) {
    event.preventDefault();
    if (!name.trim()) {
      setError('Please enter your name.');
      return;
    }
    setError('');
    onSubmit({ name: name.trim() });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Full name</label>
      <input id="name" value={name} onChange={e => { setName(e.target.value); setError(''); }}
             aria-invalid={Boolean(error)} aria-describedby={error ? 'name-error' : undefined} />
      {error && <p id="name-error" role="alert">{error}</p>}
      <button type="submit">Book</button>
    </form>
  );
}
JSX
// BookingForm.test.jsx: import BookingForm and the RTL utilities shown above
 test('rejects an empty name and then submits a valid one', async () => {
  const user = userEvent.setup();
  const onSubmit = jest.fn();
  render(<BookingForm onSubmit={onSubmit} />);

  await user.type(screen.getByLabelText(/full name/i), ' ');
  await user.click(screen.getByRole('button', { name: /book/i }));

  expect(screen.getByRole('alert')).toHaveTextContent('Please enter your name.');
  expect(onSubmit).not.toHaveBeenCalled();

  await user.clear(screen.getByLabelText(/full name/i));
  await user.type(screen.getByLabelText(/full name/i), 'Maya Ortiz');
  await user.click(screen.getByRole('button', { name: /book/i }));

  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  expect(onSubmit).toHaveBeenCalledWith({ name: 'Maya Ortiz' });
});

Prefer userEvent over the lower-level fireEvent: it models sequences of browser interactions (focus, key down, key up, input) and catches issues that a single synthetic event would hide. Always await its methods and create the instance with userEvent.setup().

4. Testing asynchronous components

Remember the WorkshopList from lesson 2, which fetches data. In tests you do not call the real server. Replace fetch with a mock that returns controlled data.

JSX
// WorkshopList.test.jsx: import WorkshopList from './WorkshopList.jsx'.
const originalFetch = globalThis.fetch;
beforeEach(() => { globalThis.fetch = jest.fn(); });
afterEach(() => {
  if (originalFetch === undefined) delete globalThis.fetch;
  else globalThis.fetch = originalFetch;
});

test('shows workshops after loading', async () => {
  fetch.mockResolvedValue({
    ok: true,
    json: async () => [{ id: 'w1', title: 'Sourdough basics' }],
  });

  render(<WorkshopList category="cooking" />);

  expect(screen.getByRole('status')).toHaveTextContent(/loading/i);
  expect(await screen.findByText('Sourdough basics')).toBeInTheDocument();
  expect(screen.queryByRole('status')).not.toBeInTheDocument();
});

test('shows an error message when the server fails', async () => {
  fetch.mockResolvedValue({ ok: false, status: 500 });

  render(<WorkshopList category="cooking" />);

  expect(await screen.findByRole('alert')).toHaveTextContent(/could not load/i);
});

Key points:

  • findBy waits (default one second) for the element to appear, so it fits asynchronous updates. For other assertions use waitFor(() => expect(...)).
  • Test each state the user can see: loading, success, empty, and error.
  • Do not mock your own components unless necessary; mock the boundary (network, timers, browser APIs).
  • Reset mocks between tests (beforeEach, jest.resetAllMocks()) so one test cannot leak into another.

The act warning

If you see "not wrapped in act(...)", React state updated while the test was not waiting. RTL wraps its own utilities in act for you; the warning usually means an assertion ran too early. Await the interaction and async result. Timer advancement or updates initiated outside RTL may need an explicit act wrapper; also clean up pending work. Do not suppress the warning with arbitrary delays.

5. Testing with providers and routers

Components that read context or use routing need those wrappers during tests. Create a helper:

JSX
import { MemoryRouter } from 'react-router';
import { CartProvider, useCart } from './CartContext.jsx';

function renderWithProviders(ui, { route = '/' } = {}) {
  return render(
    <MemoryRouter initialEntries={[route]}>
      <CartProvider>{ui}</CartProvider>
    </MemoryRouter>
  );
}

MemoryRouter keeps the history in memory, so tests do not depend on a browser URL. Use it with initialEntries to start on a specific route and assert on what that route renders.

6. Testing reducers and hooks

Test pure React reducers and action contracts directly when checking business rules without DOM behavior.

JavaScript
import { cartReducer } from './cartReducer.js';

test('removed stores the item for undo', () => {
  const start = { items: [{ id: 'w1', title: 'Sourdough', seats: 2 }], promo: null, lastRemoved: null };
  const next = cartReducer(start, { type: 'removed', id: 'w1' });
  expect(next.items).toEqual([]);
  expect(next.lastRemoved).toEqual({ id: 'w1', title: 'Sourdough', seats: 2 });
});

For a custom hook, use renderHook from RTL:

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

test('useLocalStorage saves the new value', () => {
  localStorage.clear(); // isolate storage from previous tests
  const { result } = renderHook(() => useLocalStorage('theme', 'light'));
  expect(result.current[0]).toBe('light');

  act(() => result.current[1]('dark'));

  expect(result.current[0]).toBe('dark');
  expect(localStorage.getItem('theme')).toBe(JSON.stringify('dark'));
});

When a hook is used by only one component, it is often simpler to test it through that component.

7. What to test, and what not to

Test this Avoid this
What the user sees and can do: text, buttons, validation messages Internal state values, class component instance methods
Behavior across states: loading, error, empty, success Exact class names or DOM structure unless they carry meaning
Important business rules (seat limits, price totals) in reducers The library's own behavior (React, React Router)
Accessibility-relevant output (labels, roles, aria messages) Snapshots of large trees that fail on every harmless change

Snapshot tests store a rendering and compare future renderings. They are cheap to write but easy to approve blindly, so reserve them for small, stable outputs.

Worked example: testing the seat limit

Statement. The cart allows at most 6 seats per workshop. Write a test that proves it through the interface and explain why this style is better than checking state.

Step 1: arrange. Use the CartProvider/useCart from lesson 3 and this small fixture page, then render it with the provider helper.

JSX
function WorkshopPage({ workshopId }) {
  const { cart, dispatch } = useCart();
  const count = cart.items.find(item => item.id === workshopId)?.seats ?? 0;
  return (
    <>
      <p>Seats in your cart: {count}</p>
      <button type="button" disabled={count >= 6}
              onClick={() => dispatch({ type: 'added', id: workshopId, title: 'Sourdough' })}>
        Add seat
      </button>
    </>
  );
}

Step 2: act. Click "Add seat" eight times.

JSX
test('does not allow more than 6 seats for one workshop', async () => {
  const user = userEvent.setup();
  renderWithProviders(<WorkshopPage workshopId="w1" />);

  const add = screen.getByRole('button', { name: /add seat/i });
  for (let i = 0; i < 8; i += 1) {
    await user.click(add);
  }

  expect(screen.getByText('Seats in your cart: 6')).toBeInTheDocument();
});

Step 3: assert on what the user sees. The text "Seats in your cart: 6" proves the rule works through the real UI.

Step 4: why not inspect state. If a refactor moves the cart from useState to useReducer, a test reading component.state.seats breaks even though the product is unchanged. The user-visible test continues to pass, and still fails if the rule is broken. Optionally add a second assertion that the button becomes disabled at the limit, if that is the specified behavior.

Worked example: debugging a failing query

Statement. A new test fails with "Unable to find an accessible element with the role 'button' and name /reserve/i". The component clearly displays the word "Reserve". Find the cause.

Step 1: read the error output. RTL prints the accessible elements it did see, with their roles and names. Here it lists a heading and a generic element containing the text "Reserve", but no button.

Step 2: inspect the component. The markup is <div className="btn" onClick={reserve}>Reserve</div>. A div has no button role, cannot receive keyboard focus, and does not react to Enter or Space.

Step 3: fix the component, not the test. Replace the div with <button type="button" onClick={reserve}>Reserve</button>. The test now passes, and keyboard and screen reader users gain the control too.

Step 4: why the alternatives are worse. Switching to getByText('Reserve') would make the test pass but would hide the accessibility defect. Adding data-testid would do the same. Adding role="button" and tabIndex to the div would partly work, but a native element is simpler and more robust.

Useful debugging tools. screen.debug() prints the current DOM; screen.logTestingPlaygroundURL() suggests the best query for an element; within(element) limits a query to one region, for example the row of a particular workshop.

A practical workflow for writing a test

  1. Write down the behavior in a sentence: "When the member submits an empty name, an error appears and nothing is sent."
  2. Render the component in the state the sentence needs, with the props or providers it requires.
  3. Find elements the way a user would, using roles and labels.
  4. Perform the action with userEvent.
  5. Assert on visible output and on callbacks that cross the boundary of the component.
  6. Break the code on purpose (for example remove the validation) and confirm the test fails. A test that cannot fail has no value.
  7. Name the test after the behavior, not the implementation: rejects a blank name, not calls setError.

Common pitfalls

  • Querying by class name or container.querySelector instead of roles and labels.
  • Forgetting await on user.click/user.type, causing assertions to run too early.
  • Using getBy to test absence, which throws; use queryBy with not.toBeInTheDocument().
  • Using getBy for content that arrives later; use findBy.
  • Leaking mocks between tests.
  • Asserting implementation details such as how many times a component rendered.
  • Wrapping everything in act manually when RTL already does it.
  • Testing third-party libraries rather than your own behavior.
  • Writing brittle snapshots of entire pages.

Study tips

  • RTL's philosophy: tests resemble how the software is used. Answers about testing state or instance methods are usually wrong.
  • queryBy for "should not exist", findBy for "should appear later", getBy for "must exist now".
  • getByRole is the preferred query; getByTestId is the last resort.
  • jest.fn() creates a mock function and toHaveBeenCalledWith checks its arguments.
  • render mounts into a simulated DOM provided by the jsdom test environment.

Mini case study: the test that passed while the button was broken

Situation. A team's test for the newsletter form reads:

JSX
test('subscribe', () => {
  const { container } = render(<Newsletter onSubscribe={mock} />);
  container.querySelector('.btn-primary').click();
  expect(mock).toHaveBeenCalled();
});

After a redesign, the button no longer has a label and the keyboard cannot reach it (it became a div with a click handler). The test still passes, but users with keyboards cannot subscribe.

Analysis. The test locates the element by a CSS class, which proves nothing about accessibility, and calls .click() directly on the DOM node, bypassing focus and keyboard rules. It also validates nothing about the input (an empty email).

Rewrite.

JSX
test('subscribes with a valid email', async () => {
  const user = userEvent.setup();
  const onSubscribe = jest.fn();
  render(<Newsletter onSubscribe={onSubscribe} />);

  await user.type(screen.getByLabelText(/email/i), 'maya@example.com');
  const submit = screen.getByRole('button', { name: /subscribe|join/i });
  await user.tab();
  expect(submit).toHaveFocus();
  await user.keyboard('{Enter}');

  expect(onSubscribe).toHaveBeenCalledWith('maya@example.com');
});

A labelled input and Enter submission alone may still work when a visual button is an inaccessible div. The role query and focus assertion above explicitly require a keyboard-operable submit button; user-event remains a simulation. It also asserts the data passed, not only that the function ran.

Key terms

  • React Testing Library (RTL): a library for testing components through their rendered output.
  • Jest: a JavaScript test runner with assertions and mocking.
  • jsdom: a Node implementation of the browser DOM used in tests.
  • Query: a function that finds elements, such as getByRole.
  • userEvent: a library that simulates realistic user interactions.
  • Mock: a replacement for a dependency, such as jest.fn() or a fake fetch.
  • Matcher: an assertion method such as toBeDisabled.
  • act: a wrapper ensuring React processes updates before assertions.
  • Snapshot: a stored rendering compared against future output.
  • renderHook: a utility for testing hooks without a visible component.

Summary checklist

  • I configure the jsdom environment and jest-dom matchers.
  • I render components and query by role, label, and text.
  • I choose getBy, queryBy, or findBy correctly.
  • I use userEvent with await to simulate users.
  • I mock fetch and test loading, success, empty, and error states.
  • I wrap components with the providers and router they need.
  • I test reducers directly and hooks with renderHook.
  • I avoid implementation details and fragile snapshots.

Official sources