Testing React like a user
A good test fails when Ama cannot do her job, and stays green when you only tidy the code. This lesson shows you how to write React tests that behave like a store manager: find things by their labels, type and click, wait for the server, and check what appears on the screen. Then you add one end-to-end test for the whole morning flow.
By the end you can
- Explain why tests that check implementation details break on refactors and still miss real bugs
- Find elements with Testing Library's query priority, starting with
getByRoleand an accessible name - Drive a component with
user-eventand wait for async UI withfindByandwaitFor - Fake the API at the network boundary with MSW, including error responses
- Decide what belongs in a unit test, a component test and one Playwright end-to-end test
The everyday picture
Head office can check a store in two ways. The first auditor walks into the stockroom with the floor plan and ticks boxes: rice on shelf 2 of aisle 4, price labels in the new font, the trolley bay where the plan says. The second is a mystery shopper. She walks in like any customer, follows the signs to find rice, asks a staff member about a price, pays at the till and writes down what happened.
Now the store moves rice to aisle 6 and updates the signs. The first auditor files a failure report, although customers notice nothing. The next week a sign falls down. The stockroom audit passes, and only the mystery shopper reports that she could not find rice. The mystery shopper fails when customers would fail, and only then.
Testing Library turns your tests into mystery shoppers. They find things by the signs a person uses (labels, roles, visible text), do what a person would do, and report what they saw. When a refactor moves code around but the signs stay, the tests stay green. When a sign disappears, they fail.
Test what people can see, not how the component works
A React component has two kinds of users. One is the person at the screen: Ama with her mouse, a staff member with a keyboard, someone with a screen reader. The other is the developer who renders the component and passes it props. An implementation detail is anything neither of them can see: the name of a state variable, whether you used useState or useReducer, CSS class names, a private helper function, how many times the component rendered.
Tests that check implementation details fail in two directions. They give false alarms: you move the adjustment panel from useState to a reducer, the screen works exactly as before, and ten tests go red because they read state that no longer exists. And they give false comfort: someone changes the Save button into an icon with no label, the class name stays the same, every test passes, and a screen reader user hears only "button". After a few false alarms, people "fix" red tests by updating them to match the code, and then the tests check nothing.
Testing Library is built around one idea, stated on its guiding principles page: the closer your tests are to the way the software is used, the more confidence they give you. Before you write an assertion, ask: could the person at the screen, or the parent component, notice this? They can notice text and numbers on the screen, whether a button is enabled, which element has focus, an error next to a field, a request that reaches the server, a callback prop such as onSaved being called, and the URL. They cannot notice your state, your hooks or your class names.
// Breaks when you refactor, passes when people are stuck
vi.mock('@/lib/api'); // the real fetch code never runs
const { container } = render(<AdjustForm storeId="S027" item={rice} />);
fireEvent.change(container.querySelector('#delta')!, { target: { value: '-2' } });
fireEvent.click(container.querySelector('.btn-primary')!); // finds an icon button too
expect(saveAdjustment).toHaveBeenCalled(); // "it called my function"
expect(container.querySelector('.toast--ok')).not.toBeNull();// Fails only when Ama could not do this
const user = userEvent.setup();
render(<AdjustForm storeId="S027" item={rice} />);
await user.type(screen.getByLabelText('Change'), '-2');
await user.click(screen.getByRole('button', { name: 'Save adjustment' }));
await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent('Saved: Rice 5 kg is now 10'));The second test reads like Ama's morning: find the Change field by its label, type, press the button called "Save adjustment", see the confirmation. It survives any rewrite that keeps that experience, and it fails if the label, the button name or the message disappears. You still use unit tests for logic (Errors, debugging and testing covers Vitest); this lesson is about the screen on top of that logic.
Quick check
Which assertion about the adjustment form tests behaviour?
A screen reader announces a field's description when the field gets focus, so Ama's staff can notice it. The .field-error class looks close, but it passes even when the message is not connected to the field, and it breaks when a designer renames the class. State and render counts are invisible to everyone.