← Default view
Folded and faulted rock strata exposed on a mountainside
Plate I · Survey
32 samples · 10 min
July 2026
A section through the test suite

Which tier catches what

Five layers of React form testing, drawn as a cross-section. Read downward: the crust is thin and expensive, the bedrock is cheap and enormous. Every contact between two layers is a place where a whole class of bug stops being visible.

The core
push it
downward

Push validation logic down to schema unit tests — fast, exhaustive, no DOM. Push browser-truth up to a handful of Playwright specs. In between, run component tests in jsdom for wiring, and move only the forms that depend on real focus, real files and real validation into Vitest Browser Mode, which lost its experimental tag in Vitest 4 (Oct 2025) [1].

Server Actions are the exception to the pyramid — they do not lie conformably with the rest. Unit-test them as plain (prevState, formData) functions [22] and let E2E cover the RSC round-trip, because Vitest cannot render async Server Components at all [3].

Fig. 1 — vertical exaggeration

Cost per test, logarithmic

The deeper you go, the cheaper the rock. A single E2E spec buys you ten thousand schema cases.

E2E 2–20 s
Browser Mode 40–240 ms
Component jsdom 20–80 ms
Server logic ~5 ms
Schema ~1 ms
Above
Not rock · not drivable · not yours

Native autofill and the password manager

No tier reaches this. You cannot drive the browser's native autofill from Playwright; the feature request is open and parked at P3-collecting-feedback [31]. Anything you write that claims to "test autofill" is testing your own JavaScript.

Assert the inputs to autofill instead. autocomplete="given-name" | "email" | "cc-number", plus name, type and a real <label> association — a cheap tier-1 assertion over the rendered form. That is the actual contract with the browser.
Contact
0 m

The surface — where the user actually stands

Everything below this line you can drive. Everything above it belongs to the browser, the OS and the password manager. This is the only outcrop the user ever sees, which is exactly why it tempts you into testing everything here.

4
Tier
4
2–20 s

Topsoil — end‑to‑end

Playwright ⭐ 93k · real browser · real server · real network

A thin crust. Few tests, high value: test user-visible behaviour, prefer role-based locators, lean on auto-retrying web-first assertions, and mock third-party network rather than driving external sites [27].

Catches

  • One happy path per critical form
  • Multi-step wizard state survival: fill step 1 → next → page.reload() → assert step 1 restored
  • A real upload the server actually accepts — setInputFiles(), in-memory buffer, not a fixture [28]
  • Progressive enhancement: test.use({ javaScriptEnabled: false }), submit, assert the server-rendered result
  • The RSC round-trip itself

Blind to / wasted on

  • Field-level validation — that's a 1 ms case, not a 20 s one
  • Every error branch: each costs seconds and flakes
  • Re-driving validation through five wizard steps
  • Rejected-file-type and too-large — hand tier 2 a File directly
  • Native autofill [31]
Contact
Boundary

Below here, nothing is booted

The RSC round-trip lives only at the surface: serialization, the "use server" boundary, revalidation causing a re-render with fresh server data, and any async Server Component in the tree. Next.js says so outright — Vitest cannot render async Server Components, and points you at E2E [3]. There is no lower tier that can fake this.

3
Tier
3
~5 ms
Discordant · cuts the section

Intrusion — server logic

Vitest ⭐ 17k, node env · no React, no DOM, no framework

This rock does not lie conformably with the others: it is not a deeper slice of the same component, it is a different body of code cutting straight through the column. Split the flow at React's own seam — useActionState(action, initial) calls action(previousState, formData) and returns [state, dispatchAction, isPending] [21]. That signature is the contract, and it gives you two independently testable halves.

Catches

  • Half 1 — the action alone. Import the exported async function, build a FormData, assert the returned state; vi.mock redirect / revalidatePath / next/headers [22]
  • Four cases per action: happy path, auth denied, dependency down, absurd input
  • Half 2 — the component with a fake action. Pass the action in as a prop; assert isPending disables the button and returned errors land next to the right field

Blind to

  • The RSC transport itself [24]
  • Anything React renders — no DOM here at all
  • Whether the component that doesn't take its action as a prop is testable — it isn't. There is still no official Next.js recipe for RTL + Server Actions; the discussion has been open and unanswered since the useFormState era [23]
// node env, no React in sight
vi.mock('next/navigation', () => ({ redirect: vi.fn() }))
vi.mock('next/cache',      () => ({ revalidatePath: vi.fn() }))

it('returns field errors for invalid input', async () => {
  const fd = new FormData(); fd.set('email', 'nope')
  const state = await createUser({ ok: false }, fd)
  expect(state.errors?.email).toBeDefined()
  expect(db.user.create).not.toHaveBeenCalled()
})
Contact
HTTP

The HTTP boundary — where MSW stops

MSW ⭐ 18k intercepts at the network level, so the component runs its real fetch path [25] — in jsdom, Browser Mode and Playwright alike. It is not a universal mock. Anything that doesn't cross HTTP — clocks, flags, localStorage, layout — is a module seam (vi.mock, vi.stubGlobal), not an MSW handler [26]. And do not use it to fake Server-Action payloads: RSC does travel over plain HTTP, but its maintainer's verdict is that mocking that payload means mocking your framework's internals — "extremely dangerous and can break your app" [24]. Stub the third-party API your Server Action calls; stub the Server Action itself with vi.mock.

2
Tier
2
2–3× jsdom

Sandstone — the middle tier that finally exists

Vitest Browser Mode + vitest-browser-react ⭐ 282 · real Chromium/Firefox/WebKit, no app booted

The same Vitest files, run in a real browser via a Playwright provider [2]; Vitest 4 removed the experimental tag [1]. Roughly 2–3× jsdom per test plus browser startup [18] — an order of magnitude cheaper than E2E, and it doesn't need your app running.

Catches — correctness that lives in the browser

  • Focus order, focus-on-error, focus traps in modal forms
  • Native constraint validation, :user-invalid styling
  • <input type=file> incl. drag-drop, DataTransfer, image preview
  • Scroll-to-first-error, sticky error summary — real getBoundingClientRect, real IntersectionObserver
  • Combobox and pointer behaviour

Blind to / wasted on

  • Business flows, auth, DB state — nothing is booted
  • Pure wiring (resolver → message → aria-describedby): correct here, but 3× slower than tier 1
  • @testing-library/user-event — the official guide steers you to Vitest's own page.getByRole(...) locators and userEvent instead [16]. Locators auto-retry, so most waitFor noise disappears
// vitest.config.ts — keep all three seams as one workspace
test: {
  projects: [
    { test: { name: 'unit',    environment: 'node',  include: ['**/*.schema.test.ts'] } },
    { test: { name: 'jsdom',   environment: 'jsdom', include: ['**/*.test.tsx'] } },
    { test: { name: 'browser', include: ['**/*.browser.test.tsx'],
              browser: { enabled: true, provider: playwright(), headless: true,
                         instances: [{ browser: 'chromium' }] } } },
  ],
}
Uncon­formity
Major

The jsdom line — a genuine unconformity

Clicking a real submit button in jsdom prints Not implemented: HTMLFormElement.prototype.requestSubmit [14]. It is not a bug in your code. jsdom ⭐ 22k deliberately omits navigation, and native form submission is navigation [15].

With a JS-handled onSubmit the handler still fires and the console noise is cosmetic — but below this line, a plain <form action="/x" method="post"> has no verdict at all. Neither does focus, nor layout. Those answers exist only at tiers 2 and 4.

An angular unconformity in coastal rock: near-vertical beds truncated beneath a flat-lying layer
An angular unconformity: the record below is truncated, and the layers above sit on it as if nothing were missing. The jsdom line reads exactly like this.
1
Tier
1
20–80 ms

Shale — component tests in jsdom

Vitest + Testing Library ⭐ 20k + user-event ⭐ 2.3k

Vitest is the default for new React/TS projects in 2026; Vitest 4 shipped codemods that rewrite jest.*vi.* mechanically [1]. Keep Jest ⭐ 46k only if you ship React Native, are stuck on CommonJS, or run a 10k+ test monorepo leaning on its battle-tested --shard [32]. "We already have Jest and CI is fine" is a legitimate reason not to migrate.

Catches — wiring, not rules

  • Field ↔ error message, reaching an aria-describedby
  • Submit disabled while pending; reset; optimistic UI
  • The onSubmit payload shape
  • Async server-checked fields — mock at the network boundary with MSW so the component runs its real fetch, then assert all three states: pending, taken, available [25]
  • One invalid case per form — enough to prove the resolver is wired

Blind to

  • Rule-by-rule validation — that belongs one layer down, at 1 ms a case
  • Native form submit [14]
  • Real focus, real layout, real files — approximated at best [19]
  • Anything that isn't HTTP but got mocked with MSW anyway
Contact
Render

The render line — below this, nothing renders

Pure functions only. Which is precisely why the rock is so cheap: 40 validation cases cost about 40 ms, produce exact error messages you can assert including path, and never once mount a component [5]. Every case you push across this line gets 100× cheaper.

0
Tier
0
~1 ms

Bedrock — the schema itself

Vitest, node env · no jsdom, no render, no mocks

The single highest-leverage move on this page. A Zod/Valibot schema is a pure function. Put the combinatorics where they are cheap, and the tier above needs one invalid case per form instead of one per rule.

Catches

  • Every validation rule, exhaustively
  • Cross-field .refine() and .transform() (trim, coerce, Date parsing) — exactly the code that breaks silently and never surfaces in a DOM test [5]
  • Error path and exact message strings
  • Wide input surfaces (money, dates, i18n): zod-fast-check ⭐ 128 derives fast-check ⭐ 5.1k arbitraries from the schema, so you assert round-trip invariants rather than hand-picked cases [6]

Blind to

  • Anything requiring a render — by construction
  • Whether the resolver is actually wired to the form (tier 1's one job)
// signup.schema.test.ts — node env, no jsdom, no render
const base = { email: 'a@b.co', password: 'hunter22', confirm: 'hunter22' }

it.each([
  ['rejects mismatched confirm', { confirm: 'nope' }, ['confirm'], 'Passwords must match'],
  ['rejects short password',     { password: 'x', confirm: 'x' }, ['password'], undefined],
])('%s', (_n, patch, path, msg) => {
  const r = signupSchema.safeParse({ ...base, ...patch })
  expect(r.success).toBe(false)
  const issue = r.error!.issues.find(i => i.path.join('.') === path.join('.'))
  expect(issue).toBeDefined()
  if (msg) expect(issue!.message).toBe(msg)
})
Rule of thumb

A test belongs one tier lower than you think — unless it depends on something that tier can't fake.

Fig. 2 — fractures in the shale

Three faults you will hit in the jsdom layer

None of these are bugs in your form. They are the layer behaving exactly as it was built to, at a place where its behaviour stops matching yours.

A road-cut exposing beds offset across a fault plane
Displacement across a fault plane: the beds are the same, but they no longer line up. So it is with an async validator and a synchronous assertion.
F1
act()
warnings

The state update lands after your await resolves

Every form library validates asynchronously, so the update arrives after await userEvent.type(...) returns — hence "not wrapped in act(...)" [12]. react-hook-form's ⭐ 45k own answer: validation is always async, so await a UI consequence with find*/waitFor, and do not wrap render() in act() [4]. Adding your own act() around user.* is the wrong fix — user-event already wraps them, and double-wrapping produces the contradictory "environment is not configured to support act" warning [13].

Corollary: an assertion-free interaction is a warning generator. If a mode: 'onChange' form validates on first keystroke and the test ends there, the update escapes the act scope. Assert on something.

// ✗ warns: nothing awaits the resolver's async validation
await user.type(screen.getByLabelText('Email'), 'bad')
expect(screen.getByText('Invalid email')).toBeInTheDocument()

// ✓ awaits the consequence; no act() anywhere
await user.type(screen.getByLabelText('Email'), 'bad')
expect(await screen.findByText('Invalid email')).toBeInTheDocument()
Slickensides: a polished, striated rock face produced by two blocks grinding past each other Slickensides — the polish left where two blocks ground past one another. Evidence of the slip, long after it happened.
F2
debounced
fields

Fake timers deadlock unless you hand them to user-event

Real timers plus waitFor works, but pays the debounce in wall-clock on every run. Fake timers work — but must be handed to user-event, or clicks and types hang forever [9] [10].

The Async suffix is the whole trick: advanceTimersByTime fires the debounce callback but never lets the promise it returns settle, so a debounced async validator (checkUsernameAvailable) deadlocks [11] [8]. Cheaper alternative when you own the component: inject the debounce interval as a prop and pass 0 in tests — no fake timers, no deadlock.

vi.useFakeTimers()
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })

await user.type(screen.getByRole('searchbox'), 'wou')
await vi.advanceTimersByTimeAsync(300)   // async: flushes microtasks between callbacks
expect(await screen.findByText('1 result')).toBeInTheDocument()
F3
requestSubmit
not implemented

The false alarm that is really a missing layer

Not implemented: HTMLFormElement.prototype.requestSubmit is the most common form-testing false alarm [14]. Nothing is broken. jsdom omits navigation on purpose, and native form submission is navigation [15]. Treat it not as noise to silence but as the layer telling you it has no verdict — and go get the verdict at tier 2 or tier 4.

Fig. 3 — the same outcrop, two exposures

What moves from shale into sandstone

Forms whose correctness lives in the browser. Everything else stays in jsdom, where it is three times faster.

Form behaviour Tier 1 · jsdom Tier 2 · Browser Mode
Native constraint validation (required, type=email, :user-invalid) ✗  no real validity UI or navigation [15] ✓  real
Focus management — focus first invalid field, focus trap in a modal form, tab order ⚠  approximated [19] ✓  real
<input type=file> incl. drag-drop, DataTransfer, image preview ⚠  synthetic File only ✓  real
Scroll-to-first-error, sticky error summary (getBoundingClientRect, IntersectionObserver) ✗  stubbed / faked [19] ✓  real
Pure wiring: resolver → message → aria-describedby ✓  and 3× faster ✓  wasteful

CI shape: npx playwright install --with-deps chromium, run headless, and start maxWorkers low (~2) on constrained runners [19]. Playwright's own component testing is still shipped as @playwright/experimental-ct-react in 2026 — if you are already on Vitest, Browser Mode is the lower-friction middle tier [18].

Fig. 4 — talus at the foot of the section

Six ways to misread the column

Each of these is a test placed at the wrong depth.

  • Re-testing the schema through the DOM

    30 user.type + findByText cases where 30 safeParse cases would do — 100× slower, and it asserts the message string twice.

  • Wrapping user.* in act()

    It's already wrapped. You get a different warning, and you hide a real missing await [13].

  • fireEvent.change to dodge act warnings

    It works because it skips the async path a real user triggers [12]. You have tested a code path nobody takes.

  • E2E for error branches

    Each costs seconds and flakes. The branch is a tier-0 case that costs a millisecond.

  • MSW as a universal mock

    HTTP boundary only. Everything else — clocks, flags, storage, layout — is a module seam [26].

  • Trusting jsdom on focus, layout or native submit

    Those verdicts do not exist at that depth. They require tier 2 [20].

The Lulworth Crumple: violently contorted limestone beds in a coastal cliff, with a fan of fallen rock at the foot
The Lulworth Crumple — beds folded back on themselves, and the talus they shed. A layer put under enough stress will eventually be found somewhere it does not belong.
Key to the section

Reference stack, July 2026

vitest⭐ 17k
node + jsdom + browser projects, one config
jsdom tier, with user-event ⭐ 2.3k
browser tier, with @vitest/browser-playwright
msw⭐ 18k
the HTTP boundary, at every tier
E2E — three or four specs, no more
zod ⭐ 43k / valibot ⭐ 8.8k
the bedrock, plus fast-check ⭐ 5.1k
jest⭐ 46k
only for React Native, CommonJS, or a 10k-test monorepo on --shard
the seam that makes tier 3 testable at all
Adjacent sections · Building forms in React in 2026

Other boreholes on the same site

Expedition

The React form-library landscape in 2026

RHF still wins, TanStack Form is the real challenger, Formik is a zombie.

Survey

The validation & schema layer

Standard Schema v1 turned the schema library into a swappable dependency — and this is the layer your bedrock tests actually sit on.

Survey

React's native form primitives

A submission layer, not a form layer: they own pending/error/optimistic state across the network boundary — the thing tier 3 splits in half.

Survey

The hard parts: arrays, wizards, conditionals

Where the three mainstream libraries actually break — and where your tier-4 wizard spec earns its seconds.

Survey

Uploads, autosave and unsaved-changes guards

Bytes bypass your form: the browser presigns and uploads direct-to-storage while the form only carries a key.

Recon

Wiring form engines into React UI kits

The kits stopped picking an engine for you; the cost is the per-component bridge you still hand-write — and hand-test.

Sample register

32 samples logged

[1]logrocket.com — Vitest 4 (Oct 2025) removed the experimental tag from Browser Mode and shipped Jest-to-Vitest codemods.
[2]vitest.dev — Browser Mode is configured with a provider factory and an instances array; no experimental banner.
[3]nextjs.org — Vitest cannot render async Server Components; Next.js recommends E2E for them.
[4]react-hook-form — act warnings come from always-async validation; fix by awaiting find*/waitFor, not wrapping render in act().
[5]stevekinney.com — assert safeParse success/failure plus exact error path and message; focus on custom refine/transform.
[6]zod-fast-check — derives fast-check arbitraries from a Zod schema for property-based parse/transform testing.
[7]fast-check — the mainstream TypeScript property-based testing library.
[8]vitest.devvi.advanceTimersByTime and the async variant for driving debounce timers deterministically.
[9]testing-library.com — fake timers + user-event time out unless advanceTimers is passed to userEvent.setup().
[10]user-event #1115userEvent.click() hangs under vi.useFakeTimers() without advanceTimers. Open.
[11]hy2k.dev — debounced async work needs advanceTimersByTimeAsync, which flushes microtasks between callbacks.
[12]user-event #457type() with rhf mode:'onChange' produces act() warnings.
[13]RTL #1418 — user-event already wraps in act(); double-wrapping yields the contradictory warning.
[14]user-event #1297 — "Not implemented: HTMLFormElement.prototype.requestSubmit" is the most common form-testing false alarm.
[15]jsdom #1937 — jsdom deliberately does not implement navigation, which includes native form submission.
[16]vitest.dev — the component-testing guide uses vitest-browser-react's render and Vitest's own locators, not @testing-library/user-event.
[17]vitest-browser-react — the official React renderer for Browser Mode.
[18]pkgpulse.com — Browser Mode runs ~2–3× slower than jsdom; Playwright CT remains experimental in 2026.
[19]qaskills.sh — Browser Mode fixes jsdom's missing IntersectionObserver, fake getBoundingClientRect and unreliable focus/pointer; needs headless + low maxWorkers on CI.
[20]vitest.dev — simulated environments diverge from real browsers; spinning one up costs initialization time.
[21]react.dev(previousState, formData) => newState; returns [state, dispatchAction, isPending] and wraps submission in a Transition.
[22]next.js #69036 — the community pattern: import the action, hand-build FormData, vi.mock the Next internals.
[23]next.js #56304 — still no official guidance for testing useActionState/useFormStatus with RTL; unanswered by maintainers.
[24]msw #2256 — the maintainer rejects mocking RSC/Server-Action payloads as framework-internal mocking; use setupRemoteServer.
[25]mswjs.io — MSW intercepts at the network level, so components exercise their real fetch/axios paths.
[26]qaskills.sh — MSW is the wrong seam for anything that does not cross HTTP: flags, storage, clocks, layout.
[27]playwright.dev — test user-visible behaviour, prefer role-based locators, use web-first assertions, mock third-party network.
[28]playwright.devsetInputFiles(), in-memory buffers, and the filechooser event for custom pickers.
[29]microsoft/playwright — the default E2E runner in 2026. ⭐ 93k
[30]vitest-dev/vitest — the Vite-native runner and default recommendation for new React/TS projects. ⭐ 17k
[31]playwright #26831 — still cannot drive native autofill; open, labelled P3-collecting-feedback.
[32]pkgpulse.com — Jest 30 still wins for CommonJS, 10k+ suites needing --shard, and React Native.
Plate I · Testing React forms in 2026 Survey · 32 citations · 10 min Building forms in React in 2026 Atlas Photographs: Wikimedia Commons