React Testing Library (RTL) renders a component into a DOM (jsdom in Vitest or Jest) and lets you query and interact with it the way a user does: by role, label and text. Testing behaviour means asserting what the user sees and can do, not the component's state, hooks or internal structure.
What it gives you: render, screen with its queries, and @testing-library/user-event for real interactions; @testing-library/jest-dom adds matchers like toBeInTheDocument and toBeDisabled.
What it deliberately omits: no access to component instances, state or a shallow render. Enzyme, which had them, never supported React 18 and is dead.
Implementation details are things a refactor can change without changing behaviour: state variable names, which hook stores the value, CSS class names, the number of child components.
Why it matters: a test tied to internals breaks on a harmless refactor (a false negative) and still passes when the visible UI is broken (a false positive).
Rule of thumb: if the user cannot see or do it, do not assert it.
ReactMiddle
Testing React
React Testing Library, Vitest and Jest, queries and user-event, mocking the network with MSW, testing hooks, Playwright end-to-end
Questions closed
0 / 21
- 0Review
- 9Asked often
Questions
A unit test checks one function or hook alone, an integration test renders a component tree with real children and state and fakes only the network, and an E2E test drives the whole app in a browser. In React most value sits in integration tests.
Static checks: TypeScript and ESLint catch typos and wrong props for free; they are the base layer.
Unit: pure logic (formatters, reducers, validation schemas, custom hooks with renderHook). Fast, precise, but they say nothing about whether the screen works.
Integration: a page or feature rendered with RTL, real router and store, network mocked with MSW. Close to what the user does and still runs in milliseconds in jsdom.
End-to-end: Playwright against a running build: sign-in, checkout, the flows that cost money when they break. Slow and more brittle, so keep them few.
This shape (static, unit, a thick integration layer, thin E2E) is often called the testing trophy, as opposed to the classic pyramid with unit tests at the bottom.
Test-driven development is a short loop: write a test for the next piece of behaviour and watch it fail (red), write the least code that makes it pass (green), then clean the code up with the test as a safety net (refactor).
Pays off when the expected behaviour is clear up front: pure logic (price formatting, validation, reducers, date maths), custom hooks, mapping an API response.
Bug fixes: write a failing test that reproduces the bug, then fix it. The test proves the fix and keeps the bug from coming back; this is the habit worth keeping even without strict TDD.
Components too: RTL queries by role and label, so a test such as "the button is disabled until the email is valid" can be written before the component exists.
Pays off less for prototypes and UI that is still being designed, where the tests would be rewritten daily, and for visuals that a DOM test cannot see.
For a Vite project, Vitest: it reuses the Vite config and transforms, runs ESM and TypeScript without Babel, and keeps a Jest-compatible API (describe, it, expect, vi.fn instead of jest.fn). Jest stays a fine choice where it already runs, for example React Native or a large legacy suite.
Setup: environment: 'jsdom' (or happy-dom, faster but less complete), a setup file that imports @testing-library/jest-dom/vitest, and globals: true so RTL's automatic cleanup after each test works.
Migration from Jest is mostly renaming jest.* to vi.*; the main traps are vi.mock hoisting rules and fake timers.
Browser Mode (stable since Vitest 4) runs the same tests in a real Chromium through Playwright, for code that jsdom fakes badly (layout, IntersectionObserver, canvas).
create-react-app, which shipped Jest preconfigured, is deprecated; new projects start from Vite or a framework.
getBy returns the element or throws if it is missing, queryBy returns null when it is missing, and findBy returns a promise that retries until the element appears or the timeout (1000 ms by default) runs out.
**getBy: the default for anything that must be on screen now; the thrown error prints the current DOM, which is the best debugging output you get.
queryBy**: only for asserting absence, expect(screen.queryByText('Error')).not.toBeInTheDocument().
**findBy**: for elements that appear after something async (a fetch, a lazy component, a debounce). It is getBy wrapped in waitFor, so it must be awaited.
All three throw when they find more than one match; the *AllBy variants (getAllBy, queryAllBy, findAllBy) return arrays.
Start with getByRole plus an accessible name, then getByLabelText for form fields, then getByText; getByTestId is for elements a user cannot identify any other way. Role and label queries test what assistive technology sees, so a passing test also proves the markup is accessible.
Priority from the RTL docs: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, then getByAltText, getByTitle, and finally getByTestId.
**getByRole('button', { name: /save/i })** fails if the "button" is a clickable div or an icon with no label, which is exactly the bug you want caught.
Test ids survive any markup change, which sounds good but means the test no longer notices when the button loses its label or becomes unreachable by keyboard.
**Scope with within** when a page has the same text twice: within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete' }).
When unsure of the role, screen.logTestingPlaygroundURL() or the Testing Playground extension suggests the best query.
getByText throws as soon as the element is missing, so the assertion never runs: the absence check must use queryByText, which returns null and lets not.toBeInTheDocument() pass.
The error message comes from the query, not from expect; reading the stack shows it is thrown on the getByText line.
queryBy* exists for exactly this case and should be used only for it; for presence, getBy* gives a better failure message.
If the message disappears asynchronously (after a request or an animation), use await waitForElementToBeRemoved(() => screen.queryByText(...)) instead.
The ESLint plugin eslint-plugin-testing-library flags this pattern (prefer-presence-queries), so the bug never reaches review.
fireEvent dispatches one synthetic DOM event, while userEvent simulates the whole interaction a browser produces: a click is pointer, mouse and focus events, typing is keydown, input and keyup for every character. That catches bugs fireEvent silently skips.
Realism: user.click moves focus to the element and refuses to click one under pointer-events: none; fireEvent.click only dispatches click, so focus never changes and blur-based validation never runs.
Typing: user.type updates the value key by key, moves focus and respects maxLength; fireEvent.change just sets the final value.
API (v14): create a session with userEvent.setup() before render, and await every call, because the methods are async.
**fireEvent is still fine** for events userEvent does not model, such as scroll, a custom event or a media element's ended.
Await the final state the user would see, with findBy* or await waitFor(...). The act warning means a state update happened after the test stopped waiting, usually a fetch or timer that resolved later, so the test asserted on an intermediate UI.
**act** makes React flush all pending renders and effects before the next line. RTL already wraps render, fireEvent and userEvent in it, so calling act yourself is rarely needed.
**The fix is almost never wrapping more code in act**: it is waiting for the thing that triggered the late update, for example await screen.findByText('Saved').
**waitFor retries its callback until it stops throwing (every 50 ms, up to 1000 ms by default). Keep one assertion inside and no side effects, because the callback can run many times.
React 19**: act is imported from react; the old react-dom/test-utils export is deprecated.
A test that ends while a request is still in flight can also leak into the next test; MSW plus awaited assertions keeps each test closed.
waitFor returns a promise and nothing awaits it, so the test function finishes right after the click and is reported as passed; the failing assertion runs later, after the test is over.
waitFor, findBy* and waitForElementToBeRemoved are all async; a missing await turns them into no-ops from the test's point of view.
At best the rejection surfaces as an unhandled error attributed to no particular test; at worst the runner has already exited and nobody sees it.
The simplest fix here is findByText, which reads better than waitFor with getByText inside.
eslint-plugin-testing-library (await-async-utils, await-async-queries) and TypeScript's @typescript-eslint/no-floating-promises catch this at lint time.
Mock Service Worker (MSW) intercepts requests at the network level and answers them from handlers you define, so the component, its data library and the real fetch code all run unchanged. Mocking fetch or an API module replaces code you ship and couples tests to how the request is made.
One set of handlers serves Vitest (setupServer from msw/node), Storybook and the browser during development (setupWorker).
Refactor-proof: switching from fetch to axios, or from an effect to TanStack Query, does not touch the tests.
Per-test overrides with server.use(...) cover errors, empty lists and slow responses; resetHandlers in afterEach undoes them.
**onUnhandledRequest: 'error'** fails any test that makes a request you did not expect, instead of letting it hit a real server.
MSW v2 uses the Fetch API types: http.get, HttpResponse.json, delay().
Write a custom render helper that wraps the UI in the same providers the app uses, creating fresh instances for every test, and use it instead of RTL's bare render.
**A new QueryClient per test: a shared one keeps cached data between tests, so a test passes or fails depending on what ran before it.
retry: false for queries, otherwise an error test waits through three retries with backoff and times out.
Router**: wrap in MemoryRouter with initialEntries, or build the real route tree with createMemoryRouter when the component uses loaders.
**Return the user** from the helper too, so every test gets a userEvent session without boilerplate.
RTL's render(ui, { wrapper }) option does the same for one provider; the helper is the clean way once there are several.
If the hook is used by one component, test it through that component. When it is shared, test it directly with renderHook from @testing-library/react, which renders a tiny host component and exposes the latest return value as result.current.
**Wrap updates in act**: calling a setter returned by the hook outside a React event needs act(() => result.current.increment()), or React warns and result.current is stale.
**Read result.current after the update, never a value destructured before it: the destructured copy is the old render's snapshot.
Changing arguments**: pass initialProps and call rerender(newProps).
Providers: the wrapper option, same as for render.
@testing-library/react-hooks is deprecated; renderHook has been part of RTL since React 18.
vi.fn creates a standalone mock function, vi.spyOn wraps an existing method so you can watch or replace it and restore it later, and vi.mock swaps a whole module for every import in the test file. Mock at the boundaries you do not own, not the code you are testing.
Good targets: the network (better with MSW), time (vi.useFakeTimers), randomness, window.matchMedia, analytics SDKs, heavy third-party widgets.
Bad targets: your own child components, hooks and utilities. Mocking them tests a fiction: the test passes while the real pieces no longer fit together.
**vi.mock is hoisted** above the imports, so its factory cannot use variables declared in the file; vi.hoisted is the escape hatch.
Restore between tests: restoreMocks: true (or vi.restoreAllMocks() in afterEach) puts spies back, so one test's mock cannot leak into the next.
vi.mocked(fn) gives the mocked function its mock type in TypeScript without a cast.
vi.mock is hoisted to the top of the file, above the const fakeGetUser line, so when the factory runs during the import of ./greet, fakeGetUser is still in its temporal dead zone. Create the mock inside vi.hoisted, which is hoisted together with vi.mock.
Vitest rewrites the file so every vi.mock runs before any import; that is what lets the mocked module replace the real one for ./greet too.
Jest has the same hoisting but allows variables whose names start with mock; Vitest has no such exception, so code ported from Jest often breaks this way.
An alternative is automocking with vi.mock('./api') and setting the value per test through vi.mocked(getUser).mockResolvedValue(...).
Switch to fake timers with vi.useFakeTimers(), tell userEvent to advance them (advanceTimers: vi.advanceTimersByTime), then move the clock forward explicitly inside act. The test runs instantly and checks the exact moment the debounce fires.
**Why advanceTimers**: userEvent waits between keystrokes with setTimeout; under fake timers that wait never ends unless the library can advance the clock.
**Why act**: advancing the clock runs the timer callback, which sets state; act flushes the resulting render before you assert.
Restore real timers in afterEach, or every later test in the file runs on a frozen clock.
**findBy and waitFor under fake timers**: they poll with timers too, so with Vitest prefer explicit advancing and synchronous getBy assertions.
vi.setSystemTime(date) fixes Date.now() for code that formats "5 minutes ago".
Snapshots suit small, stable outputs where any change deserves a look: a serializer, an error message, ideally as toMatchInlineSnapshot. Large component snapshots break on every markup tweak, get updated with -u unread, and stop protecting anything.
What they check: that output did not change, not that it is correct. The first snapshot records whatever the code produced, bugs included.
Large DOM snapshots couple the test to every wrapper div and class name, which are exactly the implementation details RTL tries to avoid.
Review cost: a 400-line snapshot diff in a PR is approved blind; an explicit getByRole assertion states the intent.
For visual changes, screenshot comparison in Playwright (expect(page).toHaveScreenshot()) or a visual review tool catches what a DOM snapshot cannot: CSS.
Coverage reports which lines, branches and functions ran during the tests. It shows what is untested, not that the tested code is correct: a test that renders a component and asserts nothing still covers it, so a 100% target rewards running code rather than checking it.
Branch coverage (both sides of every if, ?:, && and ??) says more than line or function coverage, which only count what ran.
A target changes behaviour: tests get written for the number, such as snapshot-only tests, tests of trivial getters, tests that mock everything and tests tied to implementation details that make every refactor expensive.
Diminishing returns: the last 10 to 20% is mostly error branches for cases that cannot happen and framework glue, which cost more to cover than the bugs they would catch.
Use it as a map and a floor: look for untested critical paths (payments, permissions, validation) and set a threshold that blocks regressions, stricter for critical folders.
Mutation testing (Stryker) measures the tests themselves: it flips conditions or removes calls and checks that some test fails.
Vitest 4 reports only the files the tests imported unless coverage.include is set, so untouched modules drop out and the percentage looks better than it is.
Playwright drives real Chromium, Firefox and WebKit from a test runner and checks the built app the way a user does. Use it for the few flows that must never break end to end (sign-in, checkout, onboarding), and leave the variants to cheaper RTL tests.
Why it is less flaky
Auto-waiting: actions like click wait until the element is attached, visible, stable and enabled, so there are no manual sleeps.
Web-first assertions: await expect(locator).toBeVisible() retries until it passes or times out, instead of checking once.
Locators are lazy and re-resolved on every use, so a re-render does not leave a stale element handle; getByRole and getByLabel mirror RTL.
Isolation: each test gets a fresh browser context (cookies, storage), and tests run in parallel workers.
Practical setup
Auth once: log in in a setup project and reuse storageState, instead of typing credentials in every test.
Network control: page.route stubs third parties or a flaky dependency; run the real backend where the contract matters.
Debugging CI failures: trace: 'on-first-retry' records a trace with DOM snapshots, network and console; open it with npx playwright show-trace.
Retries only in CI (retries: 2), and a test that needs them is a bug to fix, not a pass.
Split the work by layer: test Server Actions and data functions as plain async functions in Vitest, test Client Components with RTL by passing them props and a fake action, and cover the assembled page, where async components and the RSC protocol run, with Playwright against a production build.
Why RTL stops at the page
RTL renders with the client renderer, which does not support async components: React reports that the component "is an async Client Component", and the test sees only the Suspense fallback. Next.js itself recommends end-to-end tests for async Server Components.
A synchronous Server Component that only takes props is an ordinary function and renders fine.
render(await Page({ params: Promise.resolve({ id: '1' }) })) calls the component directly and renders what it returns. It works one level deep, and breaks as soon as a child is async too or the code calls cookies() or headers() outside a request.
Server Actions
They are exported async functions: build a FormData, call the action, assert the returned state and what changed in the database.
Mock only the framework edges: next/cache for revalidatePath, your session helper for auth(), and server-only, which throws when imported outside the server build. redirect() and notFound() work by throwing, so mock next/navigation or expect the rejection.
Test the refusals, a guest and a user who does not own the record, since every action is a public endpoint.
A real test database (a container or SQLite) catches wrong queries; a mocked ORM only proves that you called it.
End to end
Playwright against next build and next start, not next dev, which renders and caches differently.
Cover what only the assembled app shows: streamed sections appear, a form posts and the page shows the new data after revalidation, a guest is redirected.
Random failures almost always come from shared state, real time or unawaited async work; slowness, from rendering too much per test. Reproduce first (alone, shuffled, in a loop), then remove the shared dependency instead of adding retries or longer timeouts.
Typical causes of flaky tests
State leaking between tests: a module-level store, a shared QueryClient, localStorage, mocks not restored. Fresh instances per test plus restoreMocks fix it.
Unawaited async: a missing await on findBy or user.*, or an assertion made before the request finished. Lint rules from eslint-plugin-testing-library catch most of it.
Real time: debounces, animations and Date.now(). Fake timers and vi.setSystemTime make them deterministic.
Real network: an unmocked request that sometimes succeeds. MSW with onUnhandledRequest: 'error' turns it into a clear failure.
Order dependence: run with --sequence.shuffle to expose it.
Making it faster
Profile first: vitest --reporter=verbose prints every test's duration and shows where the time goes.
Test logic below the component: a reducer or Zod schema needs no DOM; render only what the behaviour needs.
**retry: false** in test query clients, and short MSW responses instead of delay().
Environment: happy-dom can be faster than jsdom for simple trees; keep isolate on unless you have proven the suite is leak-free.
CI: shard across machines (vitest --shard=1/4, Playwright --shard) and cache dependencies.
Quarantine a flaky test with a ticket rather than letting the team learn to rerun CI until it is green.