The jsdom project and the render helper
Stand up the React component testing rig, a third Vitest project running React Testing Library against jsdom, plus a render helper that mirrors your app's providers.
A trigger just fired. The <SubscribeForm> grew a conditional branch: pick a multi-seat plan and a seat-count field appears, and the submit button’s accessible name changes to reflect the total. That is a complex-state interactive component, one of the triggers from the previous lesson, so it earns a component test.
Before you can write that test, you need a rig to run it on, and that rig doesn’t exist yet. Building it is this lesson: a component Vitest project that runs against a fake browser, the four Testing Library packages installed and pinned, one setup file, and a render helper that pre-wires your app’s providers and hands you a ready-to-drive simulated user. The real component test comes in the next lesson; here you build what it rides on.
Most of the pieces already exist. You wired the mock network boundary, the next/navigation mocks, the frozen clock, and the factories when you set up the unit and integration lanes; this lesson mostly connects them to a new lane, plus two genuinely new pieces: the project and the helper.
Two terms before we start. jsdom gives your tests a DOM to render into without a real browser. RTL mounts a React component into that DOM and lets you find elements the way a user would.
Add the component project
Section titled “Add the component project”Your vitest.config.ts already declares two projects. The unit project runs in Node, matches src/**/*.test.ts, and finishes in a few milliseconds per test. The integration project also runs in Node but against a real Postgres, and matches src/**/*.int.test.ts. You left a third project commented out, a component stub. Fill it in now.
// inside test: { projects: [ ... ] }{ name: 'component', environment: 'jsdom', include: ['src/**/*.dom.test.tsx'], setupFiles: ['./vitest.setup.dom.ts'],},Four fields, each earning its place.
name: 'component' labels the slice so you can run it alone with vitest --project component, just as you run --project unit and --project integration.
environment: 'jsdom' is why this project exists separately. This lane gets a DOM; the other two stay pure Node. Booting jsdom costs the 100 to 300 milliseconds per test you saw earlier, once you add the DOM, the render, and the queries. Quarantining it here keeps that tax off the unit and integration suites, where speed is the point.
include: ['src/**/*.dom.test.tsx'] routes files by suffix. You now know the full family:
*.test.tsroutes tounit(Node).*.int.test.tsroutes tointegration(Node, real DB).*.dom.test.tsxroutes tocomponent(jsdom).
The .dom. infix disambiguates: a bare .test.tsx could be any TypeScript test that uses JSX, while .dom.test.tsx says this one wants a DOM. Name a file subscribe-form.dom.test.tsx and the suffix sends it to the jsdom lane with nothing else to configure.
setupFiles: ['./vitest.setup.dom.ts'] points at this lane’s own setup file. It does not exist yet; you build it two sections from now.
In watch mode, vitest --project component re-runs only this slice on each save, while bare vitest runs all three.
*.test.ts *.int.test.ts *.dom.test.tsx The four pinned test dependencies
Section titled “The four pinned test dependencies”The component lane needs four dev dependencies. Install them in one line:
pnpm add -D @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom@testing-library/react v16+ is the render-and-query library. Version 16 is the React 19 line; pairing the older v15 with React 19 is a known peer dependency mismatch, so stay on v16 or later.
@testing-library/user-event v14 simulates real user interactions. Clicking a button does not fire one synthetic event; it dispatches the full sequence a real pointer produces (pointerdown, mousedown, focus, pointerup, mouseup, click) and waits for React to flush the updates, fidelity the end of the lesson returns to.
@testing-library/jest-dom v6 adds DOM-aware matchers to expect, readable assertions like toBeInTheDocument(), toHaveAccessibleName(), and toBeDisabled(). Its /vitest entrypoint registers those matchers against Vitest’s expect.
jsdom is the fake browser the other three run inside, the DOM environment your environment: 'jsdom' setting selects.
Why jsdom over happy-dom, the faster, lighter alternative? Compatibility. jsdom has fewer surprises around what the query layer leans on: focus semantics, Element.checkVisibility, ResizeObserver, and the accessibility-tree details the next lesson’s query ladder depends on. At this test count speed is not your bottleneck; the correctness of the DOM your queries read is, so you default to the more faithful environment.
The setup file for the jsdom lane
Section titled “The setup file for the jsdom lane”Now the file the project entry pointed at: vitest.setup.dom.ts. It runs once before this lane’s tests and does three small jobs. This is the first place where getting it wrong fails silently, so build it one job at a time before you see the whole file.
Job one: register the matchers. A single side-effect import wires toBeInTheDocument and its siblings into expect:
import '@testing-library/jest-dom/vitest';Note the /vitest on the end. The bare @testing-library/jest-dom import targets a generic runner; the /vitest entrypoint registers against Vitest’s expect. Import the wrong one and the matchers are missing when a test calls them.
Job two: clean up after every test, by hand. This is the job people get wrong.
import { afterEach } from 'vitest';import { cleanup } from '@testing-library/react';
afterEach(cleanup);cleanup unmounts whatever the last test rendered, leaving a fresh DOM for the next. Testing Library can register this automatically, but only when the runner exposes a global afterEach. This course runs with globals: false, so there is no global afterEach for RTL to hook into. Auto-cleanup never fires, and nothing warns you.
Skip this line and every rendered tree stays mounted across tests. The next test runs against a DOM that still holds the previous render, so a query like getByRole('button', { name: /submit/i }) finds two buttons and throws, or worse, matches a stale element and passes against the wrong tree. Registering afterEach(cleanup) by hand removes the whole class of failure.
Job three: polyfill the jsdom gaps your components touch. jsdom is faithful but does not implement every browser API; the usual gaps are matchMedia, ResizeObserver, and IntersectionObserver. A component that reaches for one on render throws on first mount, because the API is not there. The fix is the smallest stub that satisfies the contract, not a real implementation: a ResizeObserver with no-op observe, unobserve, and disconnect is enough, as is a matchMedia returning matches: false with no-op listeners.
Stub a gap only when a component actually needs it. An empty setup file that grows one polyfill the day a real component demands it stays honest; one pre-loaded with every shim “just in case” is noise you can never be sure is still needed.
A fourth concern, the mock network boundary and the next/* mocks, is reuse rather than new code, and the next section handles it. Here is the file assembled, stepped through one job at a time.
import { afterEach, vi } from 'vitest';import { cleanup } from '@testing-library/react';import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
class ResizeObserverStub { observe() {} unobserve() {} disconnect() {}}vi.stubGlobal('ResizeObserver', ResizeObserverStub);Registers the DOM matchers against Vitest’s expect. The /vitest entrypoint is the Vitest-specific one; import the bare @testing-library/jest-dom and toBeInTheDocument() is undefined when a test reaches for it.
import { afterEach, vi } from 'vitest';import { cleanup } from '@testing-library/react';import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
class ResizeObserverStub { observe() {} unobserve() {} disconnect() {}}vi.stubGlobal('ResizeObserver', ResizeObserverStub);Unmounts the previous render so each test starts on a clean DOM. RTL auto-cleans only when the runner exposes a global afterEach, which globals: false does not, so you register it by hand. Skip it and rendered trees leak across tests, and queries match stale elements from an earlier render.
import { afterEach, vi } from 'vitest';import { cleanup } from '@testing-library/react';import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
class ResizeObserverStub { observe() {} unobserve() {} disconnect() {}}vi.stubGlobal('ResizeObserver', ResizeObserverStub);The smallest stub that satisfies the contract: no-op methods, no real implementation. Add one only when a component under test reaches for an API jsdom omits.
To fix when each thing runs relative to a test, work through this drill. Drag the lifecycle steps into the order they fire for a single test in the jsdom lane.
Order what happens when one `*.dom.test.tsx` file runs in the component lane. Drag the items into the correct order, then press Check.
*.dom.test.tsx suffix and routes it to the component project afterEach(cleanup) unmounts the rendered tree The drill shows why cleanup matters: cleanup is the seam between tests. Skip it and step five never happens, so step six is false and the next test does not start clean.
Reusing the integration lane’s mocks
Section titled “Reusing the integration lane’s mocks”The integration lane already stood up an MSW server and mocked next/headers, next/navigation, and next/cache in its setup. The jsdom lane reuses both, with no new boilerplate.
The mock boundary. The same server from src/test/msw/server.ts gets imported into the jsdom setup and given the same three-hook lifecycle:
import { server } from '@/test/msw/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));afterEach(() => server.resetHandlers());afterAll(() => server.close());Why would a client component hit the network at all? Rarer than on the server, but it happens: a fetch fired in an effect, or a data-fetching hook polling for updates. The same boundary mocks answer it, and onUnhandledRequest: 'error' still makes an unmatched request a test failure rather than a silent pass-through.
next/navigation. A client component that reads useRouter, usePathname, or useSearchParams crashes in jsdom: no Next.js router is mounted, so those hooks have nothing to return. You register a default mock in the setup file and override it per test, the same default-plus-override pattern as the auth.api.getSession mock.
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), usePathname: () => '/invoices', useSearchParams: () => new URLSearchParams(),}));Registered once, covers the common case. The whole lane gets a working router: push and refresh are spies, usePathname returns a stable path, useSearchParams returns an empty set. Most tests never touch it.
it('pushes to checkout on submit', async () => { const push = vi.fn(); vi.mocked(useRouter).mockReturnValue({ push, refresh: vi.fn() });
const { user } = render(<SubscribeForm />); await user.click(screen.getByRole('button', { name: /subscribe/i }));
expect(push).toHaveBeenCalledWith('/checkout');});The exception, when a test asserts the route. A test that checks which route was pushed overrides the default with vi.mocked(...).mockReturnValue(...) inside the it.
next/cache. The revalidatePath, revalidateTag, and updateTag mocks are already a setupFiles concern. A client component that invokes a Server Action calling one of those inherits the mock with nothing further to wire.
Reset discipline carries over. The afterEach(() => vi.resetAllMocks()) you added to fix structural flake applies here too, since Vitest does not auto-reset mocks declared in setup files between tests. That one line keeps the jsdom lane’s mocks from bleeding call history from one test to the next.
Four concerns, zero new infrastructure: the mock boundary, the navigation mock, the cache mocks, and the reset hook are the integration lane’s machinery pointed at a new setup file.
The render helper
Section titled “The render helper”In production, app/layout.tsx wraps the whole app in providers: a theme provider, the next-intl locale provider, the Toaster portal target, and anything else that must be in scope everywhere. A component under test needs that same stack, or it breaks. A localized component with no locale provider throws on the first translation lookup; a themed component with no theme renders wrong.
Re-typing the stack in every test file works until the day you add a provider in production. Now every test file is out of date, and you are editing dozens of them to add one wrapper. A single seam exists to prevent exactly that.
The seam is a render helper at src/test/render.tsx, the test-side mirror of your root layout. It wraps RTL’s render, pre-applies your layout’s providers, and returns everything RTL returns plus a ready-to-drive simulated user. Add a provider once, here, and every test inherits it. The rule that comes with it is absolute: your tests call this helper, never RTL’s render directly.
import { render as rtlRender } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { NextIntlClientProvider } from 'next-intl';import type { ReactElement, ReactNode } from 'react';import enMessages from '@/messages/en-US.json';
type RenderOptions = { locale?: string; messages?: typeof enMessages;};
export const render = ( ui: ReactElement, { locale = 'en-US', messages = enMessages }: RenderOptions = {},) => { const AllProviders = ({ children }: { children: ReactNode }) => ( <NextIntlClientProvider locale={locale} messages={messages}> {children} </NextIntlClientProvider> );
return { ...rtlRender(ui, { wrapper: AllProviders }), user: userEvent.setup(), };};The provider stack, mirroring app/layout.tsx. Add a production provider once, here, and every test inherits it: that is the seam. Shown with just the locale provider; the theme provider and the Toaster target slot into this same wrapper.
import { render as rtlRender } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { NextIntlClientProvider } from 'next-intl';import type { ReactElement, ReactNode } from 'react';import enMessages from '@/messages/en-US.json';
type RenderOptions = { locale?: string; messages?: typeof enMessages;};
export const render = ( ui: ReactElement, { locale = 'en-US', messages = enMessages }: RenderOptions = {},) => { const AllProviders = ({ children }: { children: ReactNode }) => ( <NextIntlClientProvider locale={locale} messages={messages}> {children} </NextIntlClientProvider> );
return { ...rtlRender(ui, { wrapper: AllProviders }), user: userEvent.setup(), };};The ready user. The helper calls userEvent.setup() once and merges the instance onto RTL’s return, so a test writes const { user } = render(<X />) and drives it with no setup boilerplate. One user per render keeps the seam visible.
import { render as rtlRender } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { NextIntlClientProvider } from 'next-intl';import type { ReactElement, ReactNode } from 'react';import enMessages from '@/messages/en-US.json';
type RenderOptions = { locale?: string; messages?: typeof enMessages;};
export const render = ( ui: ReactElement, { locale = 'en-US', messages = enMessages }: RenderOptions = {},) => { const AllProviders = ({ children }: { children: ReactNode }) => ( <NextIntlClientProvider locale={locale} messages={messages}> {children} </NextIntlClientProvider> );
return { ...rtlRender(ui, { wrapper: AllProviders }), user: userEvent.setup(), };};The i18n thread. Options default to English, so most tests pass nothing; switching locale for one test is a single flip, render(<X />, { locale: 'es-ES', messages: esMessages }). That is what makes the locale-aware tests in the component catalog cheap.
The real discipline is what the helper deliberately does not own. A render helper rots by absorbing responsibilities that belong elsewhere, growing an options list until it is a god object nobody understands. Hold this line:
- Test data belongs to factories. A test that needs data builds it with
buildInvoice(...)and passes it as a prop; the helper never builds a row. - Auth state is not the helper’s job. Components that read
auth()are usually Server Components, which RTL never renders; client components receive auth context as a prop, so the test passes the prop. - Network belongs to the mock boundary from the previous section. The helper does not mock
fetch.
The helper is exactly two things: providers and a user. That is the same instinct behind the “this module exports exactly two things” rule from the auth test helper, a tight, named contract rather than a junk drawer. A growing options list is the warning sign.
The interaction API: await, user-event, findBy, and screen
Section titled “The interaction API: await, user-event, findBy, and screen”You have the rig. To prove it works you need four interaction reflexes. Which query to prefer and why is the next lesson’s job; here you need only enough to drive the rig once.
Await every user-event call. You get user from render, and every interaction you drive with it returns a promise:
await user.click(button);await user.type(input, 'Acme Inc');await user.keyboard('{Enter}');A user.click resolves only after its downstream effects settle: the state updates, the queued microtasks, and any React transitions it kicked off. Drop the await and your next line, the assertion, runs against a DOM that has not updated yet, so it checks stale markup and passes. That is the same hazard as “never assert on a non-awaited promise” from the earlier testing chapter, in a new place. Await every interaction, every time.
Prefer user over fireEvent. fireEvent.click dispatches a single synthetic click; user.click dispatches the full pointer sequence and waits for React to flush, as the pinned-packages section listed. The gap is not academic: a component that validates a field on focus is tripped by user.click, exactly as a real user would, but never by fireEvent.click, which fires no focus at all, so the bug sails through your test. Reach for fireEvent only to isolate one synthetic event with no user-event equivalent, a scroll listener under test, say.
Use an async query for anything that appears after an interaction. When an interaction triggers async work, an effect-driven fetch or a transition, the result is not in the DOM on the next synchronous line, and getByRole finds nothing and throws. Reach for findBy*:
expect(await screen.findByRole('status')).toBeInTheDocument();findBy* returns a promise that retries until the element appears or a default timeout (about a second) elapses. Use it for anything in the DOM. Its lower-level cousin waitFor is for non-DOM observations, such as asserting a mock was eventually called: await waitFor(() => expect(push).toHaveBeenCalled()). Reach for waitFor only when there is no element to find.
Default to screen, not destructured queries. screen.getByRole(...) reads the live global document, so a refactor that changes what render returns never breaks your queries and nothing drifts out of sync. Destructured, container-scoped queries earn their place only when one test mounts two separate trees and you must disambiguate them, which is rare.
Put the four together and here is the rig end to end: render hands back a user, await user.click drives it, findBy waits, and a jest-dom matcher asserts. The real assertions belong to the next lesson; this is not a real test yet.
it('confirms after subscribing', async () => { const { user } = render(<SubscribeForm />);
await user.click(screen.getByRole('button', { name: /subscribe/i }));
expect(await screen.findByRole('status')).toBeInTheDocument();});Drill the four reflexes in the exact spots they get written. Fill each blank from its dropdown.
Fill the blanks to wire the jsdom lane and a passing interaction test. Pick the right option from each dropdown, then press Check.
// vitest.config.ts — inside the component project{ name: 'component', environment: '___', include: ['src/**/*.dom.test.tsx'],}
// vitest.setup.dom.tsafterEach(___);
// subscribe-form.dom.test.tsxit('confirms after subscribing', async () => { const { user } = render(<SubscribeForm />); ___ user.click(screen.getByRole('button', { name: /subscribe/i })); expect(await screen.___('status')).toBeInTheDocument();});Suite size is a discipline signal
Section titled “Suite size is a discipline signal”A healthy end-of-chapter DOM suite runs 15 to 30 tests in three to five seconds. Cheap setup is not a license. If this slice ever creeps past a hundred tests, that is a signal, not a tooling problem to optimize away: the team has drifted past the previous lesson’s trigger and started writing component tests by reflex instead of by rule. The rig makes a test cheap to write; the trigger keeps the count honest. Easy setup with no discipline is how a suite ends up with two hundred tests that catch nothing and double the watch loop.
Storybook’s play function is a capable parallel surface for interactive component tests, but it adds its own config and runner while Vitest already runs the same queries and user-event calls, so one surface is enough.
The next lesson stands real tests on the rig you built, starting with which query to reach for and what “behavior” means at the DOM layer.
External resources
Section titled “External resources”The official guide to the multi-project config you built — one Vitest run, separate environments per lane.
Kent C. Dodds on the exact calls this lesson makes — screen over destructuring, user-event over fireEvent, findBy over waitFor.
The official setup guide, including the custom render wrapper pattern.
Why v14's interactions are async, and the API you await.