Skip to content

Advanced React Behavior and Tests

Step 29 of 45

Test React through 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

Configuration points:

// jest.config.js
export default {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
};
// src/setupTests.js
import '@testing-library/jest-dom';

Projects created with Vite often use Vitest, a Jest-compatible runner that shares most of the API (describe, test, expect, vi.fn instead of jest.fn). The Meta course uses Jest, and RTL works with both; the concepts in this lesson transfer unchanged. Tests are typically named Component.test.jsx and run with npm test.

2. Your first component test

// WorkshopCard.jsx
export function WorkshopCard({ title, seatsLeft, onReserve }) {
  return (
    <article>
      <h3>{title}</h3>
      <p>{seatsLeft === 0 ? 'Sold out' : `${seatsLeft} seats left`}</p>
      <button disabled={seatsLeft === 0} onClick={onReserve}>
        Reserve
      </button>
    </article>
  );
}
// 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 No Asserting that something is absent
findBy… / findAllBy… Rejects after a timeout Yes Elements that appear later (after fetch or timers)
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.

If you cannot find a field by label or role, your component may have an accessibility problem: a missing <label> or a div posing as a button. The test failure is a useful signal.

3. Testing forms

function BookingForm({ onSubmit }) {
  const [name, setName] = useState('');
  const [error, setError] = useState('');

  function handleSubmit(event) {
    event.preventDefault();
    if (name.trim().length < 2) {
      setError('Please enter your full 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)} aria-describedby="name-error" />
      {error && <p id="name-error" role="alert">{error}</p>}
      <button type="submit">Book</button>
    </form>
  );
}
test('rejects a too-short 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), 'A');
  await user.click(screen.getByRole('button', { name: /book/i }));

  expect(screen.getByRole('alert')).toHaveTextContent('Please enter your full 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 simulates the full sequence a browser performs (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.

beforeEach(() => {
  global.fetch = jest.fn();
});

test('shows workshops after loading', async () => {
  fetch.mockResolvedValueOnce({
    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.mockResolvedValueOnce({ 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. Fix it by awaiting a findBy query or waitFor, not by adding arbitrary delays.

5. Testing with providers and routers

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

import { MemoryRouter } from 'react-router-dom';

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

Pure functions need no rendering.

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:

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

test('useLocalStorage saves the new value', () => {
  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. Render the page with the cart provider and one workshop.

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

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 too-short 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.

Exam 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:

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.

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');
  await user.keyboard('{Enter}');

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

This version requires a labelled field, uses real keyboard submission, and would fail against the inaccessible div. 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