Skip to content
Chapter 89Lesson 2

The jsdom project and the render helper

Stand up the React component testing rig, a third Vitest project running React Testing Library against the jsdom fake browser, plus a render helper that mirrors your app's providers.

A trigger just fired. The <SubscribeForm> grew a conditional branch: when the customer picks a multi-seat plan, 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 named triggers from the previous lesson, and you have decided it earns a component test.

You are about to write that test. But before you write a single assertion, you need a rig to run it on, and that rig does not exist yet.

Building it is the whole job of this lesson. By the end you will have a working 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 back a ready-to-drive simulated user. This is everything the next two lessons assert into. You will not write a real component test here, since that is the next lesson; you will build the thing it rides on.

The good news shapes the entire lesson: most of the pieces are already wired. You built the mock network boundary, the next/navigation mocks, the frozen clock, and the factories in the previous chapters when you set up the unit and integration lanes. This lesson is mostly about connecting what you have to a new lane, plus two pieces that are genuinely new: the project itself, and the helper. Every section leads with what you already have and the small delta you add.

Two terms before we start. jsdom is a pure-JavaScript implementation of the browser’s DOM that runs inside Node: no browser, no window painting, just an in-memory document your component code can render into and your tests can query. RTL , React Testing Library, is the render-and-query library: it mounts a React component into that DOM and gives you functions to find elements the way a user would.

Start from what you have. Your vitest.config.ts already declares two projects. The unit project runs in Node, matches src/**/*.test.ts, and is fast, a few milliseconds per test. The integration project also runs in Node, but against a real Postgres, and matches src/**/*.int.test.ts. Back when you first built this config, you also left a third project commented out: a component stub, waiting for the day a trigger fired.

Today is that day. Here is the project entry.

vitest.config.ts
// inside test: { projects: [ ... ] }
{
name: 'component',
environment: 'jsdom',
include: ['src/**/*.dom.test.tsx'],
setupFiles: ['./vitest.setup.dom.ts'],
},

Four fields, and each one earns its place.

name: 'component' labels the slice. Once it is named, you can run it alone with vitest --project component, exactly the way you already run --project unit and --project integration.

environment: 'jsdom' is the reason this project exists separately at all. This lane gets a DOM; the other two stay pure Node. Booting jsdom is not free: it is the cost figure you met earlier, on the order of 100 to 300 milliseconds per test once you add the DOM, the render, and the queries. You do not want that tax on your unit and integration suites, where the whole point is speed. So you isolate it. One lane pays for the DOM; the other two never see it.

include: ['src/**/*.dom.test.tsx'] is the discriminator. The .dom.test.tsx suffix is how a file picks its lane and, with it, its environment. You now know the full family:

  • *.test.ts routes to unit (Node).
  • *.int.test.ts routes to integration (Node, real DB).
  • *.dom.test.tsx routes to component (jsdom).

The .dom. infix is doing real work. A bare .test.tsx would read ambiguously sitting next to .test.ts in a file list: is that a component test, or just a TypeScript test that happens to use JSX? .dom.test.tsx says unmistakably that this one wants a DOM. When you name your file subscribe-form.dom.test.tsx next to subscribe-form.tsx, the suffix routes it to the jsdom lane and nothing else has to be configured.

setupFiles: ['./vitest.setup.dom.ts'] points at this lane’s own setup file. That file does not exist yet; you build it two sections from now.

One command is worth committing to memory. vitest --project component in watch mode re-runs only this slice on every save, the same per-project pattern you already use for the other two lanes. Bare vitest runs all three.

unit
environment
node
file suffix
*.test.ts
per-test cost
~5 ms
integration
environment
node + Postgres
file suffix
*.int.test.ts
per-test cost
~20–80 ms
component
environment
jsdom
file suffix
*.dom.test.tsx
per-test cost
~100–300 ms
Three Vitest projects, three environments. The file suffix routes each test to its lane, and the `component` lane is the only one that pays for a DOM.

The diagram above lays the three lanes side by side. Read it left to right and the cost climbs: the unit lane is nearly free, the integration lane pays for a real database round-trip, and the component lane pays the most because it boots a DOM. That ordering is exactly why each lane is a separate slice. You keep the expensive environment quarantined so the cheap lanes stay cheap.

The component lane needs four dev dependencies. Install them in one line:

Terminal window
pnpm add -D @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

This is a pin list, so each package gets one sentence, not a deep dive.

@testing-library/react v16+ is the render-and-query library itself. Version 16 is the React 19-aware line; it dropped the legacy React 18 code path. Pairing the older v15 with React 19 is a known peer dependency mismatch, where a package’s declared constraint on what should be installed alongside it disagrees with what you actually have. The fix is to be on v16 or later.

@testing-library/user-event v14 simulates real user interactions. When this library clicks a button, it does not fire one synthetic event. It dispatches the whole sequence a real pointer produces (pointerdown, then mousedown, then focus, then pointerup, mouseup, and finally click) and waits for React to flush the resulting updates. That fidelity matters, and the end of the lesson comes back to why.

@testing-library/jest-dom v6 adds DOM-aware matchers to your expect: readable assertions like toBeInTheDocument(), toHaveAccessibleName(), and toBeDisabled(). Its /vitest entrypoint is the one that registers those matchers against Vitest’s expect specifically.

jsdom is the DOM environment your project’s environment: 'jsdom' selects. The first three packages are about React and assertions; this one is the fake browser they run inside.

One decision is worth a sentence of justification: jsdom over happy-dom. happy-dom is the faster alternative, since it boots quicker and is genuinely lighter. The course pins jsdom anyway, for one reason: Testing Library compatibility. jsdom has fewer surprises around the things the query layer leans on, including focus semantics, Element.checkVisibility, ResizeObserver, and the accessibility-tree details the query ladder depends on in the next lesson. 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, and happy-dom stays a named alternative you are not reaching for.

Now the file the project entry pointed at: vitest.setup.dom.ts. It runs once before the tests in this lane, and it does three small jobs, each with a reason. This is the first place in the lesson where getting it wrong fails silently, so it is worth building job by job 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 is the one built to register against Vitest’s expect. Import the wrong one and your matchers are not there when a test calls them.

Job two: clean up after every test, by hand. This is the one that catches people.

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 one. Here is the subtle part: Testing Library can register this for you automatically, but only when the test runner exposes a global afterEach. This course runs with globals: false (you set that when you first built the Vitest config), which means there is no global afterEach for RTL to hook into. So the auto-cleanup does not fire, nothing warns you, and your tests just start behaving strangely.

If you skip this line, every rendered tree stays mounted across tests. The second test runs against a DOM that still contains the first test’s render. A query like getByRole('button', { name: /submit/i }) suddenly finds two buttons and throws a “found multiple elements” error. Worse, it can find a stale element from a previous test so your assertion passes against the wrong tree. You can spend an afternoon convinced your component is broken when the real problem is one missing line in the setup file. Register afterEach(cleanup) by hand and the whole class of failure disappears.

Job three: polyfill the jsdom gaps your components touch. jsdom is faithful, but it does not implement every browser API. The usual gaps are matchMedia, ResizeObserver, and IntersectionObserver. A component that reaches for one of those on render throws on first mount in jsdom, because the API simply 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 methods is enough; a matchMedia that returns matches: false and no-op listeners is enough.

The discipline here is to stub a gap only when a component actually needs it, rather than pre-stub the world. An empty setup file that grows one polyfill the day a real component demands it stays honest. A setup file pre-loaded with every shim “just in case” is noise you can never be sure is still needed.

There is a fourth concern, the mock network boundary and the next/* mocks, but those are not new code: they are reuse, and the next section handles them. For now, 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 simply not defined 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, and under globals: false it does not, so you register this by hand. Skip it and rendered trees leak across tests, so 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 a stub like this only when a component under test reaches for an API jsdom omits, rather than pre-stub the world.

1 / 1

To make the ordering stick, so you know when each thing runs relative to a test, work through this drill. Drag the lifecycle steps into the order they actually fire for a single test in the jsdom lane.

Order what happens, in sequence, when one `*.dom.test.tsx` file runs in the component lane. Drag the items into the correct order, then press Check.

Vitest matches the file by its *.dom.test.tsx suffix and routes it to the component project
The jsdom environment is provided for the file
The setup file runs: matchers registered, polyfills installed
A test renders a component into the DOM
afterEach(cleanup) unmounts the rendered tree
The next test starts against a clean DOM

The drill shows why the cleanup line matters: cleanup is the seam between tests. Skip it and step five never happens, so step six is false, because the next test does not start clean.

Reusing the mock boundary and the Next mocks

Section titled “Reusing the mock boundary and the Next mocks”

This is the “you already have this” section. When you built the integration lane in the previous chapters, you stood up an MSW singleton, Mock Service Worker, which intercepts outgoing HTTP at the network boundary so your tests exercise your real client code while a stubbed server answers the requests. You also wired mocks for next/headers, next/navigation, and next/cache in that lane’s setup. The jsdom lane reuses both, with no new boilerplate.

The mock boundary. The same server you built at src/test/msw/server.ts gets imported into the jsdom setup and given the same three-hook lifecycle the integration setup already uses:

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 in this stack? It is rarer than on the server, but it happens: a client component firing its own fetch in an effect, or a data-fetching hook polling for updates. When it does, the same boundary mocks answer it. And onUnhandledRequest: 'error' carries over unchanged: a request with no matching handler is a test failure, not a silent pass-through. That rule does not relax just because the caller is a component.

next/navigation. A client component that reads useRouter, usePathname, or useSearchParams crashes in jsdom, because there is no Next.js router mounted and those hooks have nothing to return. You register the mock in the setup file once, and override it per test when a specific test needs different values. This is the exact same shape as the auth.api.getSession mock you wired earlier: a default registered in the setup file, and a per-test override when a test needs to bend it.

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 by default: push and refresh are spies, usePathname returns a stable path, and useSearchParams returns an empty set. This is the same shape as the auth.api.getSession default you registered once in setupFiles. Most tests never touch it.

next/cache. The revalidatePath, revalidateTag, and updateTag mocks you set up earlier are already a setupFiles concern. A client component that invokes a Server Action which calls one of those inherits the mock with nothing further to wire. No rebuild needed.

Reset discipline carries over. The afterEach(() => vi.resetAllMocks()) you added to fix structural flake applies to this lane’s mocks too, because Vitest does not auto-reset mocks declared in setup files between tests. The same one line keeps the jsdom lane’s mocks from bleeding call history from one test into the next.

Notice the shape of this whole section: four concerns, zero new infrastructure. The mock boundary, the navigation mock, the cache mocks, and the reset hook are all the integration lane’s machinery, pointed at a new setup file. That is the payoff the chapter keeps collecting: the test tree is one coherent system, not three disconnected ones.

The render helper: a test-side mirror of your root layout

Section titled “The render helper: a test-side mirror of your root layout”

Now the centerpiece, the one piece of this lesson with the most architectural weight.

In production, your app/layout.tsx wraps the entire app in providers: a theme provider, the next-intl locale provider, the Toaster portal target, and whatever else the app needs in scope everywhere. A component under test needs those same providers, or it breaks. Render a localized component with no locale provider in scope and it throws on the first translation lookup; render a themed component with no theme and it renders wrong.

So every component test needs the provider stack. The naive approach is to re-type it in every test file. That works right up until the day you add a provider in production: now every single test file is out of date, and you are editing dozens of them to add one wrapper. That is the failure a single seam exists to prevent.

The fix is a render helper at src/test/render.tsx. It wraps RTL’s render, pre-applies the providers your root layout uses, and returns everything RTL returns plus one extra: a ready-to-drive simulated user. The rule that comes with it is short and absolute: your tests call this helper, never RTL’s render directly. Add a provider once, here, and every test inherits it. Think of this helper as the test-side mirror of your root layout; that framing is the whole point.

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 the component needs in scope, the test-side mirror of app/layout.tsx. Add a production provider once, here, and every test inherits it: that is the seam. Shown with just the locale provider for clarity; 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” payload. 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 immediately, with no per-test 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. The 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 }), which is what makes the next lesson’s locale-aware tests cheap.

1 / 1

Two details in that helper are worth pausing on.

First, the user convenience. The helper calls userEvent.setup() with no options and hands the instance back as user. No options is the correct default for this suite. You may have seen userEvent.setup({ delay: null }) in older examples; do not copy it. The user-event docs discourage it and warn it causes unexpected behavior, especially alongside fake timers. The one documented escape hatch, for the rare component test that drives a timer, is userEvent.setup({ advanceTimers: vi.advanceTimersByTime }), worth knowing about but not worth reaching for until a timer-driven test actually forces it. Bare userEvent.setup() is your default.

Second, the i18n thread. The helper accepts { locale, messages } and feeds them into the locale provider, so localized-text queries work without each test recreating provider boilerplate. Most tests take the default; the rare locale-specific test flips one option. That single seam is what will make the next lesson’s localized checkout-summary tests cheap to write.

Now the most valuable guardrail in this lesson: what the helper deliberately does not own. The most common way a render helper rots is by absorbing responsibilities that belong elsewhere, growing an ever-longer options list until it is a god object nobody understands. Hold this line:

  • Test data belongs to factories, not the helper. The helper never builds a row; a test that needs data builds it with buildInvoice(...) and passes it as a prop.
  • Auth state is not the helper’s job. Components that read auth() directly are usually Server Components, which are not an RTL surface at all. Client components receive their auth context as a prop, so the test passes the prop. The helper does not stub a session.
  • 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 you met when you built the auth test helper, a tight, named contract you can reason about rather than a junk drawer. A growing options list on this helper is the warning sign; resist it.

Driving the component: the simulated user and async queries

Section titled “Driving the component: the simulated user and async queries”

You have the rig. The last thing you need is just enough of the interaction API to use it. This section teaches the basic moves, not the query ladder; which query to prefer and why is the next lesson’s job. Here you only need the shape that proves the rig works.

Here is the core habit, and the second thing that catches people. You get user from render. Every interaction you drive with it must be awaited:

await user.click(button);
await user.type(input, 'Acme Inc');
await user.keyboard('{Enter}');

Why await? Because v14’s events are asynchronous: a user.click resolves only after its downstream effects settle, including the state updates, the queued microtasks, and any React transitions it kicked off. Drop the await and you get a dangling promise. Your next line, the assertion, runs before the UI has actually updated, so it checks a stale DOM and passes. The result is a green test that proves nothing.

You have met this exact bug before. It is the same rule as “never assert on a non-awaited promise” from the earlier testing chapter, the same hazard surfacing in a new place. await user.click(...) is that rule applied to interactions. The newest Vitest hardens its handling of unawaited assertions, which helps, but treat that as a safety net rather than the fix. The fix is the habit: await every interaction, every time.

One related decision is worth naming once. You will see two ways to fire a click in older code: fireEvent.click(button) and user.click(button). They are not equivalent. fireEvent.click dispatches a single synthetic click event. user.click dispatches the full real sequence (pointerdown, mousedown, focus, pointerup, mouseup, click) and waits for React to flush. That difference is not academic. Consider a component that fires field validation on focus rather than click. A user.click trips the focus listener exactly as a real user would; a fireEvent.click never fires focus at all, so the bug sails straight through your test. Default to the simulated user. fireEvent is the narrow carve-out for isolating one specific synthetic event that has no user-event equivalent, a scroll listener under test, say. Reach for it deliberately, not by habit.

Now the other half: reading the result after an interaction. When an interaction triggers async work, such as an effect-driven fetch that a mock handler resolves, or a transition, the result is not in the DOM on the very next synchronous line. Reaching for getByRole there finds nothing and throws. The async query is the answer:

expect(await screen.findByRole('status')).toBeInTheDocument();

findBy* returns a promise that retries until the element appears or a default timeout (about a second) elapses. It is the default reach for anything observable after an interaction. Its lower-level cousin waitFor is the escape hatch for non-DOM observations, such as asserting that a mock was eventually called: await waitFor(() => expect(push).toHaveBeenCalled()). Prefer findBy for anything in the DOM; reach for waitFor only when there is no element to find.

One last small choice: screen, not destructured queries. You will see both screen.getByRole(...) and pulling getByRole out of render’s return value. Prefer screen. It reflects the live global document, so a refactor that changes what render returns does not break your queries, and there is no destructuring to drift out of sync. Destructured, container-scoped queries earn their place only when a single test mounts two separate trees and you need to disambiguate them, which is genuinely rare. Default to screen.

Put the four moves together and here is the rig, end to end. Read it as plumbing verification: proof that render gives you a user, that await user.click drives it, that findBy waits, and that a jest-dom matcher asserts. The real assertions and the reasoning about which query to pick belong to the next lesson; this is not a real test yet.

the rig, end to end — 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();
});

Five lines, and every piece of the rig is in play: render handed back a user, await user.click drove the interaction the way a real user would, findByRole waited for the async result instead of guessing it was already there, and toBeInTheDocument made the assertion readable. That is the entire surface the next lesson builds real tests on.

Drill the four moves 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.ts
afterEach(___);
// subscribe-form.dom.test.tsx
it('confirms after subscribing', async () => {
const { user } = render(<SubscribeForm />);
___ user.click(screen.getByRole('button', { name: /subscribe/i }));
expect(await screen.___('status')).toBeInTheDocument();
});

That is the rig. From here, writing a component test is cheap: name the file *.dom.test.tsx, call render, drive user, query with screen. Run vitest --project component in watch mode and the slice re-runs on every save. A healthy end-of-chapter DOM suite is on the order of 15 to 30 tests and runs in three to five seconds.

But cheap is not a license. If this slice ever creeps past a hundred tests, that is not a tooling problem to optimize away; it is a signal. It means the team drifted past the trigger from the previous lesson and started writing component tests by reflex instead of by rule. The rig makes a component test cheap to write; the trigger is what keeps the count honest. The two have to work together. Easy setup with no discipline is exactly how a suite ends up with two hundred tests that catch nothing and double the watch loop.

One alternative deserves a sentence before we set it down. Storybook’s play function, paired with its testing utilities, is a parallel surface for interactive component tests, and it is a real, capable tool. The course does not pin it, for two reasons: it carries its own configuration and a separate runner, and the same Testing Library queries and user-event calls already run inside Vitest with no new runtime to maintain. One surface is enough, so we write against Vitest.

You have not asserted on a single real component yet, and that is exactly right. You built the lane, pinned the tools, registered the cleanup that the next lesson silently depends on, reused the boundary mocks you already had, and wrote the helper that mirrors your layout so every future test inherits its providers for free. The rig is done. The next lesson stands real tests on top of it, starting with which query to reach for and what “behavior” actually means at the DOM layer.