Lesson 1 of 9
Testing React
"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds
You have spent seven parts of this Volume learning how React works — how it renders, holds state, runs effects, and recovers from errors. This part is about proving it still works tomorrow, after the next refactor, the next feature, the next tired Friday commit. That proof is what tests are. But there is a right way and a wrong way to write them, and the difference is the single most important idea in this chapter: a good test knows what the user does, not how the component is built. Interviewers love this topic precisely because it separates people who can write React from people who can ship it and sleep at night — and because the wrong instinct here is so common that saying the right thing out loud is a strong signal.
You want tests that catch real bugs but survive honest refactors. So what exactly should a React test look at — the pixels a user sees, or the state and props inside the component — and why does that one choice decide whether your test suite is an asset or a liability?
Core Concepts
Start with the philosophy, because every API decision flows from it. Test behaviour, not implementation. A user of your app never knows you kept the count in useState versus useReducer, never sees a prop named onToggle, never reads a CSS class. They see text, they click buttons, they read what changed. So that is what a test should assert on too. Rename a state variable, swap a useState for a store, split one component into three — if the visible behaviour is unchanged, a behaviour-focused test stays green. That is the whole game: tests that break only when the user's experience breaks. A test that asserts wrapper.state().count === 1 breaks on a refactor that changed nothing the user can perceive. That test isn't protecting you; it's taxing you.
The toolkit
Four pieces, each with one job:
| Tool | Role |
|---|---|
| Vitest (or Jest) | The runner — finds test files, executes them, reports pass/fail. Vitest is the modern default in Vite/Next apps: fast, ESM-native, Jest-compatible API. |
| React Testing Library (RTL) | Renders your component into a DOM and gives you queries to find things the way a user would. |
| jsdom | A fake browser DOM running in Node, so there's a document to render into without a real browser. |
| @testing-library/user-event | Simulates realistic interaction — a real click dispatches pointer, mouse, and focus events, not just one synthetic onClick. |
RTL deliberately gives you no way to reach a component's state or props. That constraint is the feature — it makes writing a bad test harder than writing a good one.
The core RTL loop: render, act, assert
Here is the component under test, live below so you can play with it. Its test sits in the snippet right after — read them together.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import { Counter } from './Counter';
test('increments the count when the button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
// Query the way a user would: by the button's accessible role + name.
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /increment/i }));
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});Three moves, always in this order. Render the component into jsdom. Act — drive the interaction with user-event (note await: user-event v14 is async because real events are). Assert with expect(...) and a matcher like toBeInTheDocument() (that matcher comes from @testing-library/jest-dom). Notice what the test never mentions: no useState, no setCount, no internal count variable. It clicks a button and checks the screen — exactly what a user does.
Testing implementation is like inspecting a car by checking that a specific wire runs from the ignition to a specific relay. Rewire the car more cleanly and your "test" fails though the car starts perfectly. Testing behaviour is turning the key and confirming the engine runs. The mechanic who insists on the wire diagram will condemn every good repair; the one who turns the key catches every real breakdown. Write tests that turn the key.
Queries: prefer the ones a user (and a screen reader) can use
RTL offers many query methods, and their priority order is not arbitrary — it mirrors how accessible your UI is. Reach for them top-down:
| Priority | Query | Why |
|---|---|---|
| 1 | getByRole (+ name) |
How assistive tech sees the page. A button, a heading, a textbox — with its accessible name. Almost everything should be reachable this way. |
| 2 | getByLabelText |
The right way to find form fields — by the visible <label> a user reads. |
| 3 | getByPlaceholderText / getByText |
For non-interactive content and labelless inputs. |
| 4 | getByTestId |
Last resort. A data-testid the user never sees; use only when nothing semantic identifies the element. |
The lesson hides in the ranking: if you can't query by role or label, that's often a real accessibility bug your test just surfaced. Reaching for getByTestId too early hides that bug instead of fixing it.
What experienced engineers actually reach for:
• getBy throws, queryBy returns null, findBy awaits. Use getBy for "must exist now", queryBy only to assert absence (expect(queryByText(...)).not.toBeInTheDocument()), and findBy for "will appear after async work".
• screen.debug() prints the current DOM — the fastest way to see why a query found nothing.
• The { name } option on getByRole is the accessible name — button text, aria-label, or associated label. It's what makes role queries precise.
• Always await user-event. Forgetting the await is the number-one cause of flaky, half-run interactions.
Async: things that appear after a tick
Real components fetch data. You can't assert on a result that hasn't arrived — so RTL gives you findBy* (a getBy that retries until the element shows up or times out) and waitFor (retry an arbitrary assertion). And you don't hit the network in a unit test; you mock it with vi.mock (Vitest) so the test is fast and deterministic.
import { render, screen } from '@testing-library/react';
import { vi, expect, test } from 'vitest';
import { UserCard } from './UserCard';
// Replace the real module with a fake for this test file.
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ name: 'Ada Lovelace' }),
}));
test('shows the user name after it loads', async () => {
render(<UserCard id="42" />);
// Present immediately while the promise is pending:
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// findBy retries until the awaited element appears (or times out ~1s):
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});Enzyme, the dominant React testing tool of the 2010s (built at Airbnb), let you reach straight into a component: wrapper.state(), wrapper.instance(), setProps(). It made implementation-coupled tests the norm — and made refactors terrifying. In 2018 Kent C. Dodds released React Testing Library with the opposite thesis baked into its API: give people no handle on internals, only on what the user sees. When React 18 arrived, Enzyme famously had no official adapter and effectively stalled, while RTL — betting on behaviour, not internals — became the recommended default in React's own docs. The philosophy won by outliving the tool that ignored it.
Testing custom hooks, and what lives above unit tests
Most logic should be tested through the component that uses it. But a genuinely reusable hook can be tested directly with renderHook, which mounts it inside a throwaway component and hands you its return value via result.current (wrap updates in act):
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});RTL tests run in jsdom — fast, but not a real browser. For "does the whole flow work in Chrome, across pages, with real navigation and network," you climb to end-to-end tests with Playwright or Cypress, which drive an actual browser. The rule of thumb (the "testing trophy"): lots of fast integration tests in RTL, a thin layer of E2E for critical journeys like sign-up and checkout, few pure-unit tests.
The Academy's enrollment form is the money path, so its RTL test reads like a user story: render the form, getByLabelText('Email'), user.type an invalid address, click Submit, and assert the error message appears — then a valid address, and assert the success state (with the submit API vi.mocked so no real charge fires). None of it touches component state. A later refactor moved that form from local useState to a reducer; every test stayed green because the user's experience never changed. On top of that thin RTL layer sits one Playwright journey — sign up, pay, land on the dashboard — run in a real browser before each deploy.
Common Mistakes
- Asserting on state or props.
expect(component.state.count).toBe(1)couples the test to the implementation. Assert on rendered output instead — it's what the user sees and what should actually be protected. - Overusing
data-testid. Reaching forgetByTestIdfirst skips the accessible query it should have used, and quietly hides an accessibility gap. Test IDs are the escape hatch, not the front door. - Forgetting to
awaitinteractions.user.click(...)andfindBy*return promises. Drop theawaitand assertions run against a half-updated DOM — the classic flaky test. - Using
getByto check something is absent.getBythrows when it finds nothing, failing the test before your assertion runs. UsequeryBywith.not.toBeInTheDocument()for absence. - Testing third-party libraries. Don't write tests that verify React Router navigates or that a date library formats correctly — that's their job, already done. Test your code's use of them.
- Chasing 100% coverage. Coverage counts lines executed, not behaviours verified. A suite can be 100% green, 100% covered, and still assert nothing meaningful. Test the behaviours that matter, not every line.
You have two test suites for the same component. Suite A asserts that clicking "Add to cart" makes the text "1 item" appear. Suite B asserts that clicking calls setCartCount(1) and that an internal isAdded flag flips to true. Now a teammate refactors the cart from local state into a shared store — same buttons, same visible result. Which suite still passes? Suite A, untouched, because the user's experience never changed. Suite B fails on every test though nothing broke, so your teammate "fixes" the tests to match the new internals — learning that green means "the code hasn't changed", not "the app still works". Which suite would you rather have owned during that refactor?
Interview Corner
render the component, find the button with screen.getByRole('button', { name: /…/ }), click it with await userEvent.click(...), then assert the new text is on screen with expect(screen.getByText(...)).toBeInTheDocument(). I query by role and assert on visible output because that's what the user actually experiences."getByRole over getByTestId?" → Role queries mirror how a user and a screen reader find the element; if I can't use one, that's often a real accessibility problem the test just caught.vi.mock so the test is fast and deterministic, assert the loading state is shown immediately, then use findBy* — which retries until the element appears — to assert the loaded result. I avoid waitFor around a simple element when a findBy query says the same thing more clearly."waitFor then?" → When the thing I'm waiting on isn't an element — e.g. waiting for a mocked function to have been called, or for text to disappear.await on interactions and async queries, or real timers/network leaking in — I'd enforce user-event setup, mock the network at the module boundary, and use fake timers deliberately. Slowness means too many heavy E2E or over-mounted trees doing integration's job — I'd rebalance toward the testing trophy: many fast RTL integration tests, a thin Playwright layer for the few critical journeys. Then I'd wire it into CI as a required gate so the standard holds without me policing PRs."act relates to React's batching and effect flushing, and where the query priority comes from. Be ready to defend a testing philosophy, not just recite an API.- Take the
Counterin the editor above and, on paper, write the test for the Reset button: increment twice, click Reset, assert the screen showsCount: 0— using only role and text queries. - Rewrite one assertion two ways: prove the count is 2 with
getByText, then prove a "Count: 3" element is absent withqueryByTextand.not.toBeInTheDocument(). Say whygetByTextwould be the wrong tool for the absence check. - Sketch the test for a component that fetches a username:
vi.mockthe API, assert the loading text withgetByText, then the loaded name withfindByText. Note exactly which lines needawait. - Out loud (as in a micro1 screen), explain in two sentences why a test that reads a component's internal state is a liability during a refactor.
Chapter Summary
- The guiding principle: test behaviour, not implementation — assert on what the user sees and does, never on internal state, props, or structure. Such tests break only when the experience breaks.
- The toolkit: Vitest/Jest (runner) + React Testing Library (render + queries) + jsdom (fake DOM) + user-event (realistic interaction).
- The loop is always render → act → assert:
render(), drive withawait userEvent..., check withexpect(...).toBeInTheDocument(). - Query priority — role > label > text > testid — mirrors accessibility; needing
getByTestIdis often a sign of a real a11y gap. - Async:
findBy*retries until an element appears,waitForretries an assertion, andvi.mockfakes the network so tests are fast and deterministic. Alwaysawaitinteractions and async queries. - Test hooks directly with
renderHookonly when they're truly reusable; climb to Playwright/Cypress E2E for a thin layer of critical, real-browser journeys. - Don't test third-party libraries, don't chase 100% coverage, and never assert on internals.
Notice what kept surfacing as we chose queries: role, label, accessible name. That wasn't a coincidence of the API — it was accessibility leaking into every good test we wrote, because a UI a screen reader can navigate is a UI a test can query. The next chapter, Accessibility in React, turns that from a happy side effect into a discipline: semantic HTML, ARIA where it's genuinely needed, focus management, and keyboard flows — the craft that makes your app usable by everyone and, as a bonus you've now seen, eminently testable.
Editorial reflection — the deepest lesson of testing isn't a library; it's a stance. Every time you choose to assert on what the user sees rather than how you built it, you are quietly deciding that your code exists to serve people, not to preserve its own structure. That stance outlives Vitest, outlived Enzyme, and will outlive whatever replaces RTL — because it is really just empathy, written down in a form the machine can check. Learn the API, yes. But carry the stance; it is the part that makes you senior.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.