#Testing philosophy and tools
AddedA test is only worth what it tells you when it fails. A good React test fails when something a user would notice breaks: the button stops saving, the error message never appears, the list shows the wrong items. A bad test fails when you rename a state variable, swap useState for useReducer, or split a component in two, even though nothing changed for the user. Tests like that make refactoring slower instead of safer.
Here's the mental model: test from the outside, through the screen. Pretend the component is a black box and the only way in is what a user (or a screen reader) has: text, labels, roles, clicks and key presses. State, hook names, props of child components and CSS class names are implementation details. They aren't the contract, so tests shouldn't depend on them. React Testing Library is built around this idea. It deliberately gives you no way to read a component's state.
Tests come in layers, and each one buys a different kind of confidence:
| Layer | What it checks | Tools (2026) | Speed |
|---|---|---|---|
| Static | Types, typos, rules of hooks | TypeScript, ESLint (eslint-plugin-react-hooks) | Instant |
| Unit | A pure function or a single hook | Vitest | Milliseconds |
| Integration | A component with its children, providers and a mocked network | Vitest + React Testing Library + user-event + MSW | Tens of milliseconds |
| End-to-end | The real app in a real browser, often against a real backend | Playwright, Cypress | Seconds |
| Visual | That components still look right | Storybook + a screenshot service, or Playwright screenshots | Seconds |
The "testing trophy" (Kent C. Dodds' twist on the old pyramid) puts the most weight on integration tests: render a real feature with its real children, mock only the network, and drive it like a user. They're nearly as fast as unit tests and catch the bugs that matter.
The tools, old and new
| Job | Modern (2026) | Older, still in codebases |
|---|---|---|
| Test runner | Vitest 5: uses your Vite config, native ESM, Jest-compatible API (describe, test, expect, vi.fn) | Jest (now 30): the default in the Create React App era, run by react-scripts test |
| DOM in Node | jsdom (or happy-dom) | jsdom |
| Rendering and queries | React Testing Library 16 | Enzyme (shallow rendering, reading state; there is no official adapter for React 18 or 19), react-test-renderer (deprecated in React 19) |
| Interactions | user-event 14 | fireEvent |
| Network mocking | MSW 3 (Mock Service Worker) | jest.fn() on fetch, axios mock adapters |
| End-to-end | Playwright 1.64, Cypress 16 | Selenium, Protractor |
This is the setup used for every example on this site: Vite's config file gets a test block, and a setup file adds the jest-dom matchers.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/setup.ts"],
},
});plugins: [react()]is the same plugin the app uses, so test files get the same JSX transform and module resolution as production code. That's Vitest's big advantage over Jest: no second Babel config to keep in sync.environment: "jsdom"gives every test file a fake browser:document,window, events and layout-free DOM APIs. Without it,renderfails because there's nodocumentto render into.globals: truemakesdescribe,test,expect,vi,beforeEachandafterEachavailable without importing them, like Jest. It also matters for React Testing Library: RTL registers its automatic cleanup with the globalafterEach, so without globals you'd have to callcleanup()yourself.setupFilesruns before every test file. Ours contains one line,import "@testing-library/jest-dom/vitest";, which adds matchers liketoBeInTheDocument()andtoBeDisabled()to Vitest'sexpect.
The first test, for the reusable Button from my notes (the notes' "Testing a button component" section was a screenshot that couldn't be read, so this one is mine):
export function Button({ label, onClick, disabled = false, variant = "primary" }) {
return (
<button type="button" className={`btn btn-${variant}`} onClick={onClick} disabled={disabled}>
{label}
</button>
);
}import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";
describe("Button", () => {
test("shows its label", () => {
render(<Button label="Save" />);
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
});
test("calls onClick when clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<Button label="Save" onClick={onClick} />);
await user.click(screen.getByRole("button", { name: "Save" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
test("does not call onClick when disabled", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<Button label="Save" onClick={onClick} disabled />);
const button = screen.getByRole("button", { name: "Save" });
expect(button).toBeDisabled();
await user.click(button);
expect(onClick).not.toHaveBeenCalled();
});
test("applies the variant class", () => {
render(<Button label="Delete" variant="danger" />);
expect(screen.getByRole("button")).toHaveClass("btn", "btn-danger");
});
});$ npx vitest run src/ch20/Button.test.jsx
RUN v5.0.3 /…/.sandbox/react/ch20-21
✓ src/ch20/Button.test.jsx (4 tests) 73ms
Test Files 1 passed (1)
Tests 4 passed (4)- Each test follows arrange, act, assert: render the component with some props, do what a user would do, then check what the user would see.
rendermounts the button into a real (jsdom) DOM node attached todocument.body. screen.getByRole("button", { name: "Save" })finds the element the way assistive technology does: by its role and its accessible name (here, the button's text). If someone changed the<button>to a<div onClick>, this query would fail, and it should, because keyboard and screen-reader users could no longer use it.vi.fn()creates a mock function that records its calls.toHaveBeenCalledTimes(1)checks the prop was called once per click. That's the button's whole contract with its parent.- In the disabled test,
toBeDisabled()checks the attribute, thenuser.clicktries to click anyway. user-event behaves like a browser: a disabled button doesn't fireclick, soonClickis never called. No error is thrown either, which is also what happens when a real user clicks a disabled button. - The class test is the borderline one. Classes are styling details, but here
variantis part of the public API and the class is the only observable result in jsdom (there's no real CSS). It's fine for a design-system component; for a feature component you'd assert on behaviour instead.
Here's the point about implementation details, made concrete. The same toggle written two ways, and one test file that runs against both:
import { useReducer, useState } from "react";
// Version 1: useState
export function ToggleState() {
const [on, setOn] = useState(false);
return (
<button aria-pressed={on} onClick={() => setOn(!on)}>
{on ? "On" : "Off"}
</button>
);
}
// Version 2: the same component refactored to useReducer
export function ToggleReducer() {
const [on, toggle] = useReducer((s) => !s, false);
return (
<button aria-pressed={on} onClick={toggle}>
{on ? "On" : "Off"}
</button>
);
}import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ToggleState, ToggleReducer } from "./Toggle";
describe.each([
["useState", ToggleState],
["useReducer", ToggleReducer],
])("Toggle (%s)", (_, Toggle) => {
test("switches on and off", async () => {
const user = userEvent.setup();
render(<Toggle />);
const button = screen.getByRole("button", { name: "Off" });
expect(button).toHaveAttribute("aria-pressed", "false");
await user.click(button);
expect(button).toHaveTextContent("On");
expect(button).toHaveAttribute("aria-pressed", "true");
await user.click(button);
expect(button).toHaveTextContent("Off");
});
}); ✓ src/ch20/Toggle.test.jsx > Toggle (useState) > switches on and off 67ms
✓ src/ch20/Toggle.test.jsx > Toggle (useReducer) > switches on and off 16msdescribe.eachruns the same block once per row, so the identical test runs againstToggleStateand thenToggleReducer.%sin the title is replaced by the first column.- The test only touches what a user can perceive: the button's text and its
aria-pressedstate (which a screen reader announces as "toggle button, pressed"). buttonis found once and reused after the clicks. That works because React updates the same DOM node: it changes the text and attribute in place instead of creating a new button.- Both versions pass. The refactor from
useStatetouseReducerchanged the implementation completely and the test didn't notice, which is exactly what you want from a test. - An Enzyme-era test written as
expect(wrapper.state("on")).toBe(true)couldn't have done this. It reads state directly, so it would break on the refactor (and.state()never worked on function components at all).
In a 2016–2020 codebase you'll see tests like this. It's Enzyme, for React 16 class components, and it doesn't run on React 18 or 19 (Enzyme has no official adapter for them), so it wasn't executed here:
import { shallow } from "enzyme";
import Toggle from "./Toggle";
it("toggles", () => {
const wrapper = shallow(<Toggle />); // renders one level deep, children are stubs
wrapper.find("button").simulate("click"); // calls the onClick prop directly, no DOM event
expect(wrapper.state("on")).toBe(true); // reads the class component's state
});shallowrendered only the top component, leaving children as placeholders. Fast, but you never tested the components working together.simulate("click")didn't dispatch a real event. It just called theonClickprop, so it skipped event bubbling,disabledand everything else a browser does.wrapper.state("on")tied the test to the state's name and shape. Rename it, or convert the class to hooks, and the test fails even though the UI is identical. This is the habit React Testing Library was created to break.
Jest with Create React App. CRA shipped Jest preconfigured: npm test ran react-scripts test (Jest in watch mode with jsdom), and src/setupTests.js imported @testing-library/jest-dom. Outside CRA, Jest needs testEnvironment: "jsdom" (the jest-environment-jsdom package, separate since Jest 28) and babel-jest or ts-jest to transform JSX. The API maps almost one to one onto Vitest:
| Jest | Vitest |
|---|---|
jest.fn(), jest.spyOn() | vi.fn(), vi.spyOn() |
jest.mock("./api") | vi.mock("./api") |
jest.useFakeTimers(), jest.advanceTimersByTime() | vi.useFakeTimers(), vi.advanceTimersByTime() |
import "@testing-library/jest-dom" | import "@testing-library/jest-dom/vitest" |
jest.config.js | the test block in vite.config.ts |
One difference bites people later: Testing Library has built-in support for Jest's fake timers but not Vitest's. See Testing asynchronous behaviour.
Where tests live: my notes had a project-structure screenshot ("Tests can be like this") that couldn't be read. The common layout is to co-locate a component's test next to it (Button.jsx and Button.test.jsx), keep shared setup and a custom render in src/test/, and put browser tests in a top-level e2e/ (Playwright) or cypress/ folder.
What to test, in practice:
- Behaviour users rely on: what renders for each state (loading, empty, error, success), what happens on click, type and submit, what's sent to the server.
- Edge cases: empty lists, long text, a failed request, a double click on "Pay".
- Accessibility for free: querying by role and label fails when markup isn't accessible.
- Not: third-party libraries themselves, CSS details, which hook a component uses, or large snapshots.
toMatchSnapshot()on a whole page breaks on every harmless change and people start updating snapshots without reading them.