I15 · Part 2 · Intermediate
Testing React
Test what a user can do. A test that breaks on a refactor was protecting the wrong thing.
12On this page
The principle
The more a test resembles how the software is used, the more confidence it gives. That is Testing Library's stated philosophy and it is the whole discipline: assert on what appears on screen and what happens when you interact with it, never on internal state, props, or which components rendered.
test("shows the total after adding an item", async () => {
const user = userEvent.setup();
render(<Cart />);
await user.click(screen.getByRole("button", { name: /add to cart/i }));
expect(screen.getByText("Total: ₹499")).toBeInTheDocument();
});
Nothing in that test knows whether Cart uses
useState, useReducer or a store. Rewrite the
internals and it still passes — which is exactly what a test is for.
Queries, in priority order
| Query | Use when |
|---|---|
getByRole | Almost always. { name } matches the accessible name. |
getByLabelText | Form fields |
getByText | Non-interactive content |
getByTestId | Last resort — nothing else can identify it |
Why role queries are worth the effort
getByRole("button", { name: "Save" }) fails if the element is a
<div onClick>, or if the button has no accessible name.
Querying the way a screen reader does means your tests quietly enforce
accessibility — you cannot write a passing test for an unlabelled
control.
get, query, find
getByText("x") // throws if missing — the default
queryByText("x") // null if missing — for asserting absence
await findByText("x") // waits — for anything async
expect(screen.queryByText("Error")).not.toBeInTheDocument();
expect(await screen.findByText("Saved")).toBeInTheDocument();
Using getBy for something that has not arrived yet is the most
common failing test. findBy retries until it appears or times
out.
userEvent, not fireEvent
await user.type(screen.getByLabelText("Email"), "a@b.com");
await user.click(screen.getByRole("button", { name: "Submit" }));
await user.selectOptions(screen.getByRole("combobox"), "pune");
fireEvent.change dispatches one synthetic event.
userEvent.type fires the whole sequence a real user produces
— focus, keydown, keypress, input, keyup — which is what catches
handlers that only work for one of them. It is async; forgetting the
await is the second most common failing test.
Mock the network, not your modules
const server = setupServer(
http.get("/api/todos", () => HttpResponse.json([{ id: 1, title: "Ship it" }]))
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
MSW intercepts at the network layer, so the component is untouched — it
really calls fetch. Mocking your own data module instead couples
the test to the implementation, and the test keeps passing after you swap
fetch for a query library that the mock no longer covers.
The same handlers can serve local development, which is the second reason to prefer it.
Testing a hook
const { result } = renderHook(() => useCounter(5));
act(() => result.current.increment());
expect(result.current.count).toBe(6);
Worth it for a hook with real logic that many components use. For a hook used in one place, test the component — that tests the hook too, through the interface that matters.
Testing the async and the accessible
test("shows an error when saving fails", async () => {
server.use(http.post("/api/todos", () => HttpResponse.error()));
const user = userEvent.setup();
render(<TodoForm />);
await user.type(screen.getByLabelText(/title/i), "Ship it");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/could not save/i);
});
Overriding one handler per test is how you cover the failure paths — the
states most suites never exercise. Querying the error by
role="alert" asserts on the accessible experience at the same
time: if the message is not announced, the test fails.
Accessibility assertions in the same run
import { axe } from "jest-axe";
test("has no obvious accessibility violations", async () => {
const { container } = render(<Checkout />);
expect(await axe(container)).toHaveNoViolations();
});
It catches the mechanical third — missing names, bad contrast, duplicate ids. It cannot tell you whether the flow makes sense with a keyboard, which is still five minutes and your hands off the mouse.
Two things that make suites flaky
- Real timers. Use
vi.useFakeTimers()for debounces and intervals, and advance them explicitly. Waiting 300ms in a test is a race you will lose on a slow CI machine. - Shared state between tests. Reset MSW handlers, clear
localStorage, and give each test its own render. A suite that only passes in order is not a suite.
What not to write
- Snapshot tests of whole trees. They fail on every change, get updated without being read, and assert nothing anybody chose.
- Tests that a component rendered. "It does not crash" is what the type checker and the next real test already tell you.
- Assertions on state or props. Those are implementation. Assert on the output.
- A test per function. Coverage of the money path beats a percentage.
The shape of a suite
| Layer | Tool | Covers |
|---|---|---|
| Unit | Vitest | Pure logic — reducers, formatters, validators. Fast and plentiful. |
| Component | RTL + MSW | A feature behaves correctly, including loading, empty and error states. |
| End-to-end | Playwright — its own chapter | The few flows that must never break: sign in, checkout, the thing that takes the money. |
Reducers are the cheapest tests you will ever write — a plain function, no render, no DOM — which is another argument for moving transitions into one.
Debugging a failing test
screen.debug(); // print the DOM
screen.logTestingPlaygroundURL(); // open it in a query builder
"Unable to find an element with the role button" usually means the element has no accessible name, or has not rendered yet. Both are worth knowing about.
Interview answer, one sentence
"I test behaviour rather than implementation: query by role and accessible name, drive it with userEvent, and intercept the network with MSW so the component is untouched. Snapshot tests of whole trees and assertions on internal state are the two things I avoid, because both break on refactors that changed nothing a user can see."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series