Atlas survey

Testing React forms in 2026: which tier catches what

The 2026 form-testing stack is Vitest + schema unit tests + jsdom component tests, with Browser Mode as the real middle tier and Playwright reserved for the three or four flows that actually touch the network.

32 sources ~10 min read #4 react · testing · forms · vitest · playwright · testing-library · msw · zod

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 + userEventnot @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 contractisPending 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 + findByText cases where 30 safeParse cases would do — 100× slower, and asserts the message string twice.
  • Wrapping user.* in act() to silence warnings. It’s already wrapped; you get a different warning and hide a real missing-await [13] [4].
  • fireEvent.change to 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).

Citations · 32 sources

Click the Citations tab to load…