Testing
Test behavior, not implementation
Section titled “Test behavior, not implementation”The dominant approach for testing React components is React Testing Library (RTL), usually run on Vitest (or Jest). Its guiding principle:
The more your tests resemble the way your software is used, the more confidence they give you.
That means you don’t test internal state, prop names, or which hooks a component calls. You render the component, interact with it the way a user would (click, type), and assert on what the user can see. When you refactor the internals — swap useState for useReducer, extract a hook — behavior-based tests keep passing, because the behavior didn’t change. Implementation-based tests would break for no real reason.
A widget test, end to end
Section titled “A widget test, end to end”import { useState } from 'react';
export default function Counter() { const [count, setCount] = useState(0); return ( <button onClick={() => setCount((c) => c + 1)}>count: {count}</button> );}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 when the button is clicked', async () => { const user = userEvent.setup(); render(<Counter />);
// Query the way a user finds things: by role and accessible name. const button = screen.getByRole('button', { name: /count: 0/i });
await user.click(button);
// Assert on what the user sees, not on internal state. expect(screen.getByRole('button', { name: /count: 1/i })).toBeInTheDocument();});This is a real, harness-run test — npm run verify -- --test tooling-and-production/testing compiles and executes it against a real Vite + React 19.3 + Vitest + RTL probe. The important habits:
- Query by role/label/text, in that priority —
getByRoleis best because it mirrors how assistive tech (and users) find elements, which also nudges you toward accessible markup. - Use
userEvent, not rawfireEvent, for interactions — it simulates real user actions (focus, key sequences) more faithfully, and itawaits its own internal act-flushing for you. - Assert on the rendered output (
toBeInTheDocument, visible text), never oncomponent.state.
What the “not wrapped in act(…)” warning actually means
Section titled “What the “not wrapped in act(…)” warning actually means”This is the warning every React tester eventually hits and doesn’t understand the first time. Root cause: a state update happened outside any act() scope RTL knows about — usually because something scheduled it asynchronously (a setTimeout, an un-awaited promise) after your test’s synchronous interaction already returned.
import { useState } from 'react';
export default function LateSetState() { const [msg, setMsg] = useState('idle'); function handleClick() { setTimeout(() => { setMsg('done'); // state update scheduled outside any act() scope }, 0); } return <button onClick={handleClick}>{msg}</button>;}Trigger it with fireEvent (which does not wait for anything after dispatching) and don’t await the timeout, and the warning fires for real:
import { fireEvent, render, waitFor } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';import LateSetState from './LateSetState';
afterEach(() => vi.restoreAllMocks());
test('an un-awaited setTimeout state update triggers the "not wrapped in act" warning', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const { getByRole } = render(<LateSetState />); fireEvent.click(getByRole('button')); // schedules the timeout, does not await it await new Promise((r) => setTimeout(r, 20)); // let the timeout fire, unmanaged expect(errorSpy.mock.calls.some((args) => String(args[0]).includes('not wrapped in act'))).toBe(true);});
test('waitFor wraps the same update in act — no warning, and the DOM is asserted safely', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const { getByRole } = render(<LateSetState />); fireEvent.click(getByRole('button')); await waitFor(() => expect(getByRole('button')).toHaveTextContent('done')); expect(errorSpy.mock.calls.some((args) => String(args[0]).includes('not wrapped in act'))).toBe(false);});The exact text React logs, captured from the first test above (not paraphrased):
An update to %s inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => { /* fire events that update state */});/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser.Learn more at https://react.dev/link/wrap-tests-with-act(%s is filled in with the component name — LateSetState here.) The fix in the second test is not to silence the warning — it’s to wait for the update using a helper that itself wraps things in act: waitFor, findBy*, or an await user.click(...) (userEvent already does this internally). The warning is real signal: it means your assertion might run before React has actually applied the update, making the test flaky under different timing rather than reliably correct.
Testing async transitions
Section titled “Testing async transitions”startTransition(async () => …) updates land after an await, which means a synchronous getBy* right after the click will run too early. Use findBy* — it polls until the element appears, which is exactly what an awaited transition needs:
import { useState, useTransition } from 'react';
declare function searchApi(query: string): Promise<string[]>;
export default function SearchResults({ search }: { search: typeof searchApi }) { const [results, setResults] = useState<string[]>([]); const [isPending, startTransition] = useTransition();
function handleSearch() { startTransition(async () => { const found = await search('react'); // Per react.dev: a `set` call after an `await` inside a transition needs // its OWN startTransition to still be treated as part of the transition. startTransition(() => setResults(found)); }); }
return ( <div> <button onClick={handleSearch}>Search</button> {isPending ? <p>Searching…</p> : null} <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul> </div> );}import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { expect, test } from 'vitest';import SearchResults from './SearchResults';
test('a startTransition(async) search updates the DOM once the promise resolves', async () => { const user = userEvent.setup(); const search = () => Promise.resolve(['hooks', 'suspense']); render(<SearchResults search={search} />);
await user.click(screen.getByRole('button', { name: 'Search' }));
// findBy* polls (default 1000ms) until the element appears — the right // tool for an update that lands after an awaited async transition, instead // of a synchronous getBy* that would run before the promise settles. expect(await screen.findByText('hooks')).toBeInTheDocument(); expect(screen.getByText('suspense')).toBeInTheDocument();});Testing Suspense
Section titled “Testing Suspense”use() reading a Promise suspends the component until it settles. React 19.2+ adds a stricter check here worth knowing before it surprises you:
import { Suspense, use } from 'react';
function ProfileName({ resource }: { resource: Promise<string> }) { const name = use(resource); // suspends until the promise settles return <p>{name}</p>;}
export default function Profile({ resource }: { resource: Promise<string> }) { return ( <Suspense fallback={<p>Loading…</p>}> <ProfileName resource={resource} /> </Suspense> );}import { act, render, screen } from '@testing-library/react';import { expect, test } from 'vitest';import Profile from './Profile';
test('shows the resolved content after the Suspense boundary settles', async () => { // The promise must be created ONCE, outside render, and passed down stable — // a NEW promise on every render would suspend forever (covered in the // concurrent-and-suspense module's use()-and-data-fetching lesson). const resource = new Promise<string>((resolve) => { setTimeout(() => resolve('Ada Lovelace'), 10); });
// React 19.2+ warns "A component suspended inside an act scope, but the // act call was not awaited" if a plain, synchronous render() triggers a // Suspense boundary — wrap it in an async act so React can flush the // suspend. (This also means act resolves only once the promise settles, // so there is no separate moment left to assert the fallback here.) await act(async () => { render(<Profile resource={resource} />); });
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument(); expect(screen.queryByText('Loading…')).not.toBeInTheDocument();});Skip the await act(async () => …) wrapper and render synchronously instead, and this is the real warning that fires:
A component suspended inside an `act` scope, but the `act` call was not awaited.When testing React components that depend on asynchronous data, you must awaitthe result:
await act(() => ...)Mocking fetch with vi.fn
Section titled “Mocking fetch with vi.fn”Stub the global fetch rather than mocking your own data-fetching module — it keeps the component’s real code path (the actual fetch call, the actual .json() parse) under test:
import { useEffect, useState } from 'react';
export default function UserCard({ userId }: { userId: string }) { const [name, setName] = useState('loading'); useEffect(() => { let cancelled = false; fetch(`/api/users/${userId}`) .then((res) => res.json()) .then((data: { name: string }) => { if (!cancelled) setName(data.name); }); return () => { cancelled = true; }; }, [userId]); return <p>{name}</p>;}import { render, screen } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';import UserCard from './UserCard';
afterEach(() => vi.unstubAllGlobals());
test('mocks fetch with vi.fn so the component never touches the network', async () => { const mockFetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ name: 'Grace Hopper' }), }); vi.stubGlobal('fetch', mockFetch);
render(<UserCard userId="42" />);
expect(await screen.findByText('Grace Hopper')).toBeInTheDocument(); expect(mockFetch).toHaveBeenCalledExactlyOnceWith('/api/users/42');});vi.stubGlobal + afterEach(() => vi.unstubAllGlobals()) keeps the stub from leaking into other tests — a real, common bug in test suites that mock globals without cleanup.
Snapshot tests: when, and when not
Section titled “Snapshot tests: when, and when not”A snapshot test asserts rendered output matches a stored value, without you writing out each expectation by hand. Vitest’s toMatchInlineSnapshot with an explicit literal argument keeps the expected value reviewable right in the test, with no separate .snap file and no auto-write step to trust:
export default function Badge({ label }: { label: string }) { return <span className="badge">{label}</span>;}import { render } from '@testing-library/react';import { expect, test } from 'vitest';import Badge from './Badge';
test('a small, stable snapshot is reviewable in a diff', () => { const { container } = render(<Badge label="beta" />); // An explicit inline snapshot: the expected markup lives right here, so a // reviewer reads the assertion without opening a separate .snap file. expect(container.innerHTML).toMatchInlineSnapshot( `"<span class="badge">beta</span>"`, );});Reach for a snapshot when the whole point is “this exact small, stable markup” — a one-line component, a formatted string, a config object. Avoid it for anything that changes often or is large: a snapshot of a whole page’s DOM turns every unrelated change into a diff nobody reads carefully, and “update snapshots” becomes a reflex instead of a real review. If you can write the assertion as a specific getByRole/toHaveTextContent check instead, that assertion documents what matters — a giant snapshot documents everything, which is the same as documenting nothing.
Component tests vs. hook tests: renderHook
Section titled “Component tests vs. hook tests: renderHook”Most of your tests render a component. When the logic under test is a hook itself — with no meaningful host component around it — renderHook skips writing one:
import { useCallback, useState } from 'react';
export function useCounter(initial = 0) { const [count, setCount] = useState(initial); const increment = useCallback(() => setCount((c) => c + 1), []); return { count, increment };}import { act, renderHook } from '@testing-library/react';import { expect, test } from 'vitest';import { useCounter } from './useCounter';
test('renderHook tests a hook directly, with no host component to render', () => { const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
act(() => { result.current.increment(); });
// `result.current` is re-read after the act() flush — it is NOT the same // object reference as before the update, by design. expect(result.current.count).toBe(6);});Reach for renderHook when a hook has enough of its own logic to deserve isolated tests (a reducer-like custom hook, a data-fetching hook) — and reach for a full component render whenever the behavior you actually care about is what the user sees, which is most of the time. A hook test can’t tell you the button rendered the right label; a component test can’t isolate one branch of complex internal hook logic as cheaply.
The testing pyramid, React edition
Section titled “The testing pyramid, React edition”| Level | Tool | What it covers |
|---|---|---|
| Unit | Vitest | Pure functions, custom hooks (renderHook), reducers |
| Component | RTL + Vitest | A component’s behavior in isolation (most of your tests) |
| End-to-end | Playwright / Cypress | Real browser, full user flows across pages |
flowchart TB e2e["End-to-end (few) Playwright — real browser, whole flows"] comp["Component (many) RTL + Vitest — behavior in isolation"] unit["Unit (some) Vitest — pure logic, hooks, reducers"] e2e --> comp --> unit
Most of your effort lives in the middle — RTL component tests — because that’s where React logic actually is. A few end-to-end tests cover the critical happy paths across the real app.
This course’s own harness: “compile every snippet” as a testing pattern
Section titled “This course’s own harness: “compile every snippet” as a testing pattern”Every tsx/ts fence in this course whose first line is a path comment (like the ones in this lesson) is written to a real Vite + React + TypeScript project — tools/probe/ — and either type-checked with tsc --noEmit or, if its path ends .test.ts/.test.tsx, actually run with vitest run. That’s the exact same idea this lesson has been teaching, one level up: don’t trust that example code compiles or behaves as claimed — prove it, every time the source changes.
npm run verify # type-check every fencenpm run verify -- --test tooling-and-production/testing # run this lesson's test fencesIf you generate example code for documentation, a blog post, or a course of your own, the same principle applies: an untested code block is a claim, not a fact, and claims about behavior (not just syntax) need something that actually runs the code — tsc alone proves it compiles, not that it does what the prose says.
Mental Model
Section titled “Mental Model”RTL’s queries and userEvent exist to make your test see the page the way a real user’s browser and assistive technology would — that’s the whole reason getByRole outranks querySelector. act() (and everything built on it — waitFor, findBy*, userEvent) exists for a narrower, different reason: it’s React’s own signal to itself that “everything caused by this interaction has finished,” so your very next assertion sees the settled DOM, not a half-updated one. The “not wrapped in act” warning isn’t RTL nagging you about internals — it’s the one honest signal that your assertion’s timing is unreliable.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
userEvent over fireEvent | Closer to real user behavior; auto-wraps in act | Slightly slower per interaction; occasionally more setup for exotic events |
findBy* over getBy* for async updates | Correctly waits, no manual waitFor boilerplate | Wrong choice for content that should be there immediately — it hides a real synchronous bug behind a retry window |
renderHook over a full component render | Isolates hook logic cheaply, no host component to design | Tells you nothing about how the hook’s result is actually rendered/used |
| An explicit inline snapshot (literal argument) | Reviewable in the diff, no auto-write step to trust | More typing than a bare toMatchSnapshot() — worth it for exactly that reason |
Stubbing global fetch vs. mocking a data module | Exercises the component’s real fetch call and parsing | Doesn’t test your actual fetch wrapper/client if you have one — mock that layer instead when it exists |
Common Misconceptions
Section titled “Common Misconceptions”- “The act warning means my test is broken.” It means a state update happened outside a scope React could track — the test might still pass by luck on this run and fail intermittently later. Treat it as “this assertion’s timing is unproven,” not decoration to suppress.
- “
getByRoleandfindByRoleare interchangeable.”getBy*throws immediately if nothing matches yet;findBy*retries for up to a second. UsinggetBy*for content that appears after a promise resolves is a real, timing-dependent bug waiting to happen — it usually “works” locally and flakes in CI. - “Snapshot tests are strictly worse than explicit assertions.” Not when the snapshot is small, explicit (an inline literal), and the point genuinely is “this exact stable output” — the failure mode is large, opaque, frequently-regenerated snapshots, not snapshots as a category.
- “You always need a running component to test a hook.”
renderHookexists specifically because a hook with meaningful logic of its own (a reducer, a subscription) is often cheaper and clearer to test directly than through some arbitrary host component.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does an async assertion use
getBy*right after a click/submit that triggers afetch, transition, oruse()? That’s a race, not a bug you’ll always see — check forfindBy*/waitForwherever the update isn’t synchronous. - Is
fireEventused where the interaction has any async consequence (an effect, a promise, a transition)?userEventis the safer default — it wraps its own act-flushing;fireEventdoes not wait for anything after the dispatched event. - Does a mocked
fetch/global get cleaned up (vi.unstubAllGlobals(),vi.restoreAllMocks()) inafterEach? A stub left in place leaks into the next test file’s run order and produces failures that look unrelated to the actual bug. - Is there a giant, unreviewed
toMatchSnapshot()(no inline literal) covering a whole page or a complex object? Check whether the snapshot file has ever actually been read by a human, or just regenerated on every failure. - For a hook-only test, is
act()used around every state-changing call onresult.current? A missingact()here produces the exact “not wrapped in act” warning this lesson explains, just from a hook test instead of a component test.
🐛 Spot the bug
test('shows the user name after fetching', async () => {render(<UserCard userId="42" />);expect(screen.getByText('Grace Hopper')).toBeInTheDocument();});Show the answer
UserCard fetches asynchronously and starts out rendering "loading" — the name only appears after the fetch promise resolves and the effect’s .then chain runs a re-render. getByText throws immediately if nothing matches right now; it never waits. Proven in this lesson’s own test: the working version uses expect(await screen.findByText('Grace Hopper')).toBeInTheDocument() — findByText polls (up to its default timeout) instead of checking once. This exact swap (getBy* for findBy*) is one of the most common mistakes in generated RTL tests for anything involving fetch, a transition, or use() — and it often “passes” against a fast local mock and fails only against a slower real network or CI runner, which is exactly why it’s dangerous instead of merely wrong.