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
- Set up Jest (or a compatible runner) with the
jsdomenvironment and RTL. - Render a component and query the output with
screenand role-based queries. - Choose between
getBy,queryBy, andfindByqueries. - Simulate user actions with
userEventand wait for asynchronous updates. - Mock network calls and modules without testing implementation details.
- Test a custom hook or reducer directly.
- Write accessible-friendly queries that double as an accessibility check.
- 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:
getByRolewithname(buttons, headings, textboxes, checkboxes); it mirrors how assistive technology sees the page.getByLabelTextfor form fields.getByPlaceholderText,getByText,getByDisplayValue.getByAltText,getByTitle.getByTestId(thedata-testidattribute) 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:
findBywaits (default one second) for the element to appear, so it fits asynchronous updates. For other assertions usewaitFor(() => 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
- Write down the behavior in a sentence: "When the member submits an empty name, an error appears and nothing is sent."
- Render the component in the state the sentence needs, with the props or providers it requires.
- Find elements the way a user would, using roles and labels.
- Perform the action with
userEvent. - Assert on visible output and on callbacks that cross the boundary of the component.
- Break the code on purpose (for example remove the validation) and confirm the test fails. A test that cannot fail has no value.
- Name the test after the behavior, not the implementation:
rejects a too-short name, notcalls setError.
Common pitfalls
- Querying by class name or
container.querySelectorinstead of roles and labels. - Forgetting
awaitonuser.click/user.type, causing assertions to run too early. - Using
getByto test absence, which throws; usequeryBywithnot.toBeInTheDocument(). - Using
getByfor content that arrives later; usefindBy. - Leaking mocks between tests.
- Asserting implementation details such as how many times a component rendered.
- Wrapping everything in
actmanually 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.
queryByfor "should not exist",findByfor "should appear later",getByfor "must exist now".getByRoleis the preferred query;getByTestIdis the last resort.jest.fn()creates a mock function andtoHaveBeenCalledWithchecks its arguments.rendermounts into a simulated DOM provided by thejsdomtest 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 fakefetch. - 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
jsdomenvironment andjest-dommatchers. - I render components and query by role, label, and text.
- I choose
getBy,queryBy, orfindBycorrectly. - I use
userEventwithawaitto simulate users. - I mock
fetchand 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
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 6: Advanced React (opens in a new tab)
- React Testing Library: Introduction (opens in a new tab)
- Testing Library: About Queries (opens in a new tab)
- Testing Library: user-event (opens in a new tab)
- Jest: Getting Started (opens in a new tab)
- React: Testing guidance (opens in a new tab)
