Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I15
26 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I15 · Part 2 · Intermediate

Testing React

Test what a user can do. A test that breaks on a refactor was protecting the wrong thing.

4 min read12 sections2 exercises
26 of 57

In this chapter

  • The principle
  • Queries, in priority order
  • get, query, find
  • userEvent, not fireEvent
  • Mock the network, not your modules

and 7 more

about 4 min
12On this page
  1. The principle
  2. Queries, in priority order
  3. get, query, find
  4. userEvent, not fireEvent
  5. Mock the network, not your modules
  6. Testing a hook
  7. Testing the async and the accessible
  8. Accessibility assertions in the same run
  9. Two things that make suites flaky
  10. What not to write
  11. The shape of a suite
  12. Debugging a failing test

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

QueryUse when
getByRoleAlmost always. { name } matches the accessible name.
getByLabelTextForm fields
getByTextNon-interactive content
getByTestIdLast 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

LayerToolCovers
UnitVitestPure logic — reducers, formatters, validators. Fast and plentiful.
ComponentRTL + MSWA feature behaves correctly, including loading, empty and error states.
End-to-endPlaywright — its own chapterThe 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."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 9 testsBuild a test spyintermediate
  • 6 testsTest what the user sees, not the implementationintermediate
All 66 for this level →
I15

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I14Previous · I14Server state with TanStack Query
  2. I15you are here
  3. Next · I16TypeScript with ReactProps are a function's parameters, so typing a component is typing a function.5 min read

01On this page

12 sections · 0%

  1. 01The principle
  2. 02Queries, in priority order
  3. 03get, query, find
  4. 04userEvent, not fireEvent
  5. 05Mock the network, not your modules
  6. 06Testing a hook
  7. 07Testing the async and the accessible
  8. 08Accessibility assertions in the same run
  9. 09Two things that make suites flaky
  10. 10What not to write
  11. 11The shape of a suite
  12. 12Debugging a failing test

[] chapters · t top