Decision. Push validation logic down to schema unit tests (fast, exhaustive, no DOM) and 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/file/validation behaviour into Vitest Browser Mode, which graduated to stable in Vitest 4 (Oct 2025) [1]. Server Actions are the exception to the pyramid: unit-test them as plain
(prevState, formData)functions [22] ⭐ 141k and let E2E cover the RSC round-trip, because Vitest cannot render async Server Components at all [3].
The pyramid
| Tier | Runner | Cost/test | Test here | Do not test here |
|---|---|---|---|---|
| 0. Schema | Vitest ⭐ 17k, node env | ~1 ms | Every validation rule, refine/transform, error paths & messages, cross-field rules [5] |
Anything requiring render |
| 1. Component (jsdom) | Vitest + RTL + user-event |
~20–80 ms | Wiring: field ↔ error message, submit disabled while pending, reset, optimistic UI, onSubmit payload shape |
Rule-by-rule validation (tier 0), native form submit ⚠ [14] ⭐ 2.3k |
| 2. Component (browser) | Vitest Browser Mode + vitest-browser-react [16] |
~2–3× jsdom [18] | Focus order & focus-on-error, native constraint validation, <input type=file>, combobox/pointer, :user-invalid, scroll-to-error |
Business flows, auth, DB state |
| 3. Server logic | Vitest, node env | ~5 ms | Server Actions / route handlers as plain functions: happy path, auth denied, invalid input, dependency down | The RSC transport itself [24] ⭐ 18k |
| 4. E2E | Playwright ⭐ 93k | ~2–20 s | 1 happy-path per critical form, multi-step wizard state across navigation/reload, real upload, progressive enhancement (JS off) | Field-level validation, every error branch |
Rule of thumb: a test belongs one tier lower than you think, unless it depends on something that tier can’t fake.
Tier 0 — test the schema, not the DOM rendering of the schema
The single highest-leverage move. A Zod/Valibot schema is a pure function; 40 validation cases cost ~40 ms in a node-env Vitest file and produce error messages you can assert exactly, including path [5].
// 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)
})
What it buys: the combinatorics live where they’re cheap. Your component tier then needs one invalid case per form — enough to prove the resolver is wired and the message reaches an aria-describedby — instead of one per rule. Cross-field .refine() and .transform() (trim, coerce, Date parsing) are exactly the code that breaks silently and never surfaces in a DOM test, so test them directly [5].
For schemas with wide input surfaces (money, dates, i18n strings), property-based testing is worth the setup: zod-fast-check ⭐ 128 derives fast-check ⭐ 5.1k arbitraries from the schema, so you can assert round-trip invariants (parse(serialize(x)) === x) rather than hand-picked cases [6] [7].
Tier 1 — component tests in jsdom
Runner: Vitest. It is the default for new React/TS projects in 2026; Vitest 4 shipped codemods that rewrite jest.* → vi.* mechanically [1] [30] ⭐ 17k. 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 [32].
The three pain points
1. act() warnings with form libraries. Every form library validates asynchronously, so the state update lands after your await userEvent.type(...) resolves — hence “not wrapped in act(…)” [12] ⭐ 2.3k. react-hook-form’s ⭐ 45k own answer: the 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.* calls is the wrong fix — user-event already wraps them, and double-wrapping produces the contradictory “environment is not configured to support act” warning [13] ⭐ 20k.
// ✗ 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()
Corollary: an empty assertion-free interaction is a warning generator. If a mode: 'onChange' form validates on mount or on first keystroke and the test ends there, the update escapes the act scope [12]. Assert on something.
2. Debounced fields. Real timers + 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] ⭐ 2.3k:
vi.useFakeTimers()
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await user.type(screen.getByRole('searchbox'), 'wou')
await vi.advanceTimersByTimeAsync(300) // async variant: flushes microtasks between callbacks
expect(await screen.findByText('1 result')).toBeInTheDocument()
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 control the component: inject the debounce interval as a prop and pass 0 in tests — no fake timers, no deadlock.
3. Async validation (server-checked fields). Mock at the network boundary with MSW ⭐ 18k so the component runs its real fetch path [25]; then assert the three states (pending, taken, available) by awaiting each. Do not reach for MSW for anything that doesn’t cross HTTP — clocks, flags, localStorage, layout — that’s a vi.mock/vi.stubGlobal seam [26].
The jsdom lie you will hit first
Clicking a real submit button in jsdom prints Not implemented: HTMLFormElement.prototype.requestSubmit [14] ⭐ 2.3k. It isn’t 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 it means jsdom tells you nothing about whether a plain <form action="/x" method="post"> (progressive enhancement) actually works. That verdict only exists at tiers 2 and 4.
Tier 2 — Vitest Browser Mode, the middle tier that finally exists
Browser Mode runs the same Vitest files in a real Chromium/Firefox/WebKit via a Playwright provider [2]; Vitest 4 removed the experimental tag [1] [19]. Cost: ~2–3× jsdom per test plus browser startup [18] [20] — an order of magnitude cheaper than E2E, and it doesn’t need your app booted.
// vitest.config.ts — keep both, as a workspace/projects split
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' }] } } },
],
}
API note: in Browser Mode you use vitest-browser-react ⭐ 282 for render and Vitest’s own page.getByRole(...) locators + userEvent — not @testing-library/user-event, which the official guide explicitly steers away from [16] [17]. Locators auto-retry, which removes most waitFor noise.
What moves here — forms whose correctness lives in the browser:
| Form behaviour | jsdom verdict | Browser Mode verdict |
|---|---|---|
Native constraint validation (required, type=email, :user-invalid styling) |
✗ no real validity UI/navigation [15] | ✓ real |
| Focus management (focus first invalid field, focus trap in 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, 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’re already on Vitest, Browser Mode is the lower-friction middle tier [18].
Tier 3 — Server Actions and useActionState
Split the flow at React’s own seam. useActionState(action, initial) calls action(previousState, formData) and returns [state, dispatchAction, isPending]; wiring dispatchAction to <form action> wraps submission in a Transition [21]. That signature is the contract, and it gives you two independently testable halves.
Half 1 — the action, in isolation (node env, no React). A Server Action is an exported async function. Import it, build a FormData, assert the returned state; vi.mock the framework internals it touches (redirect, revalidatePath, next/headers) [22].
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()
})
Cover four cases per action: happy path, auth/permission denied, dependency down, absurd input. This is where the ROI is — it’s a pure function test.
Half 2 — the client component, with a fake action. Make the component take the action as a prop (or via context) and pass vi.fn() returning a canned state in tests. Then you assert UI contract — isPending disables the button, returned errors render next to the right field, a returned success message appears — without any RSC machinery. There is still no official Next.js recipe for RTL + Server Actions; the discussion has been open and unanswered since the useFormState era [23] ⭐ 141k. Designing the component to accept its action is what makes it testable at all.
What you cannot test below E2E: the actual RSC round-trip (serialization, "use server" boundary, revalidation causing a re-render with fresh server data), and any async Server Component in the tree — Next.js says so explicitly and points you at E2E [3].
MSW’s role in 2026. Unchanged and still valuable: network-boundary mocking for forms that talk to REST/GraphQL, in jsdom, Browser Mode and Playwright [25]. But do not use it to fake Server-Action payloads. MSW’s maintainer’s position: RSC does travel over plain HTTP so you can intercept it, yet mocking that payload means “mocking the internals of your framework… extremely dangerous and can break your app” — the supported path for server-side interception is setupRemoteServer (mock what your server calls, not what the client-server RPC returns) [24]. Translation: use MSW to stub the third-party API your Server Action calls; use vi.mock to stub the Server Action itself.
Tier 4 — Playwright: few tests, high value
Keep E2E for what no lower tier can see. Playwright [29] ⭐ 93k is the default runner; its own guidance — test user-visible behaviour, use role-based locators, rely on auto-retrying web-first assertions (await expect(x).toBeVisible()), and mock third-party network rather than driving external sites [27].
Multi-step wizards. The E2E-only question is state survival: does step 3 still hold step 1’s answers after a reload, a back-button, or a session resume? Assert the persistence mechanism, not the fields. One spec: fill step 1 → next → page.reload() → assert step 1 values restored → complete → assert the final POST payload (page.waitForRequest) and the success page. Field-level validation in each step belongs at tier 0/1; re-driving it through five wizard steps is the classic waste.
File upload. setInputFiles() is the whole API; prefer an in-memory buffer to a fixture file so CI stays deterministic [28]:
await page.getByLabel('Avatar').setInputFiles({
name: 'a.png', mimeType: 'image/png', buffer: png,
})
// custom picker button (no addressable input):
const chooser = page.waitForEvent('filechooser')
await page.getByRole('button', { name: 'Upload' }).click()
await (await chooser).setFiles({ name: 'a.png', mimeType: 'image/png', buffer: png })
await expect(page.getByRole('img', { name: 'Avatar preview' })).toBeVisible()
Cover one real upload end-to-end (including the server accepting it); test rejected-type/too-large purely at tier 2, where you can hand the component a File directly.
Autofill — the trap. You cannot drive the browser’s native autofill or a password manager from Playwright; the feature request is open and parked at P3-collecting-feedback [31] ⭐ 93k. Anything you write that “tests autofill” is really testing your own JS. So: assert the inputs to autofill instead — autocomplete="given-name" | "email" | "cc-number", name, type, and a <label> association — as a cheap tier-1 accessibility-style assertion over the rendered form. That is the actual contract with the browser.
Progressive enhancement. If your form must work without JS (a real design goal for <form action> + Server Actions), it can only be verified here: test.use({ javaScriptEnabled: false }), submit, assert the server-rendered result. jsdom will never tell you [15].
Anti-patterns
- Re-testing the schema through the DOM. 30
user.type+findByTextcases where 30safeParsecases would do — 100× slower, and asserts the message string twice. - Wrapping
user.*inact()to silence warnings. It’s already wrapped; you get a different warning and hide a real missing-await [13] [4]. fireEvent.changeto dodge act warnings. It works because it skips the async path the real user triggers [12] — you’ve tested a code path users never take.- E2E for error branches. Each costs seconds and flakes; the branch is a tier-0 case.
- MSW as a universal mock. HTTP boundary only; everything else is a module seam [26].
- Trusting jsdom on focus, layout, or native submit. Those verdicts require tier 2 [19] [20].
Reference stack, July 2026
vitest ⭐ 17k (node + jsdom + browser projects) · @testing-library/react ⭐ 20k + @testing-library/user-event ⭐ 2.3k (jsdom tier) · vitest-browser-react ⭐ 282 + @vitest/browser-playwright (browser tier) · msw ⭐ 18k (HTTP boundary, all tiers) · @playwright/test ⭐ 93k (E2E) · zod ⭐ 43k / valibot ⭐ 8.8k + fast-check ⭐ 5.1k (schema tier).