Form & Validation Testing

Forms are where users give an application something, and where applications most often make that difficult. A sign-up form that rejects a valid email, a checkout that loses the address after a server error, a validation message that appears for sighted users but is never announced to screen readers — each costs real conversions and real trust, and each is invisible to a test that fills fields by setting values directly and asserts only that a submit handler was called. This topic belongs to component and integration testing frameworks and covers testing forms as users experience them: typing into labelled fields, seeing validation at the right moment, reading errors that are properly associated with their fields, waiting through submission, and recovering from rejection — with React Hook Form, Zod schemas and plain controlled inputs alike.

The life of a form submission A user fills fields, client validation runs at blur or submit, errors are shown and announced, a valid form submits and enters a pending state, and the server either accepts it or rejects it with errors that must map back onto fields without losing input. fill fields by label validate blur or submit show errors linked, announced submitting disabled, busy accepted confirmation rejected errors on fields input kept
Most form tests cover the straight line to "accepted"; most form bugs live on the other branches.

Architectural Scope & Boundaries

This topic covers forms at the component and integration tiers: a form component rendered in a simulated DOM, its fields driven with user-event, its validation observed through the rendered output, and its submission sent to an intercepted API. That is where the overwhelming majority of form behaviour can be verified quickly and precisely.

It deliberately separates three concerns that forms tend to entangle. Validation rules — what makes an email valid, which fields are required together — are logic, and the best place to test them exhaustively is the schema itself, as a pure function. Form behaviour — when errors appear, how focus moves, what the submit button does while pending — is component behaviour, tested by rendering. And the submission contract — what the server receives and what it can reject — is an integration concern, tested with the network intercepted.

What this topic does not cover is visual design of form controls, which belongs to visual regression testing, or end-to-end tests of complete sign-up and checkout journeys, which are covered once per journey at the browser tier. The component tier can cover every validation case and every failure branch far more cheaply than a browser can, which leaves the end-to-end test to confirm only that the form is wired into the real page.

Accessibility is not a separate concern here but part of correctness. An error message that is visually adjacent to its field but not programmatically associated with it — no aria-describedby, no aria-invalid — is broken for screen-reader users, and the tests in this topic assert on those associations as a matter of course. Querying fields by their accessible label, rather than by name attribute or test id, is what makes such omissions fail loudly.

The last boundary is between the form library and your form. React Hook Form, Formik, TanStack Form and plain controlled state all produce the same observable behaviour when used well, and tests that interact only through the DOM do not need to know which one is in use. That is what allows a form library migration without rewriting the test suite, and it is a strong argument against tests that reach into a library’s internal state.

There is also a question of which forms deserve this attention. Every form benefits from the basics — labelled fields, associated errors — but the full treatment described here pays for itself on the forms where failure is expensive: sign-up and sign-in, checkout and payment, anything that creates a legal or financial record, and anything a user fills in once and cannot easily redo. A newsletter field needs one test; a mortgage application needs all of them.

Prerequisites

Step-by-Step Implementation

Step 1 — Test the schema on its own. Validation rules are a pure function from input to errors; every case belongs here, where each costs microseconds.

// src/forms/signup.schema.ts
import { z } from 'zod';

export const signupSchema = z.object({
  email: z.string().email('Enter a valid email address'),
  password: z.string().min(12, 'Use at least 12 characters'),
  confirm: z.string(),
}).refine((v) => v.password === v.confirm, { message: 'Passwords do not match', path: ['confirm'] });
// src/forms/signup.schema.test.ts
test.each([
  [{ email: 'a', password: 'longenough12', confirm: 'longenough12' }, 'email', 'Enter a valid email address'],
  [{ email: 'a@b.co', password: 'short', confirm: 'short' }, 'password', 'Use at least 12 characters'],
  [{ email: 'a@b.co', password: 'longenough12', confirm: 'different12!' }, 'confirm', 'Passwords do not match'],
])('reports %o as invalid on %s', (input, field, message) => {
  const result = signupSchema.safeParse(input);
  expect(result.success).toBe(false);
  expect(result.error!.flatten().fieldErrors[field as 'email']).toContain(message);
});

Keeping the schema in its own module is what makes this possible, and it pays twice. The same schema can validate the request on the server, so the rules tested here are the rules both sides enforce, and a change to one is a change to both. A form whose rules live inline in the component, by contrast, can only be tested through rendering, and its server-side counterpart drifts independently.

Step 2 — Fill forms by label, with user-event. Typing through user-event dispatches the key, input and change events a browser would, so validation modes that react to change or blur behave as they do in production.

// src/forms/SignupForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('submits valid details', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();
  render(<SignupForm onSubmit={onSubmit} />);

  await user.type(screen.getByLabelText('Email'), 'ada@example.test');
  await user.type(screen.getByLabelText('Password'), 'correct horse battery');
  await user.type(screen.getByLabelText('Confirm password'), 'correct horse battery');
  await user.click(screen.getByRole('button', { name: 'Create account' }));

  expect(onSubmit).toHaveBeenCalledWith({ email: 'ada@example.test', password: 'correct horse battery', confirm: 'correct horse battery' });
});

Querying every field by its label is doing more than locating it. A field that cannot be found by label has no accessible name, which is a defect for every user who relies on a screen reader or voice control, and the test’s failure is the earliest and cheapest place for that defect to surface. The payoff compounds as forms grow: a thirty-field form tested by label is also a thirty-field form whose labels have all been verified.

Step 3 — Assert that errors are associated, not merely visible. The accessible description of the field should contain the error; aria-invalid should be set; the error should be announced.

test('links the email error to its field', async () => {
  const user = userEvent.setup();
  render(<SignupForm onSubmit={vi.fn()} />);

  await user.type(screen.getByLabelText('Email'), 'not-an-email');
  await user.tab();

  const email = screen.getByLabelText('Email');
  expect(email).toBeInvalid();
  expect(email).toHaveAccessibleDescription('Enter a valid email address');
});

Step 4 — Test validation timing deliberately. Whether errors appear on blur, on change or only on submit is a product decision; pin it, so a library upgrade that changes the default is noticed.

test('does not nag while the user is still typing', async () => {
  const user = userEvent.setup();
  render(<SignupForm onSubmit={vi.fn()} />);
  await user.type(screen.getByLabelText('Email'), 'ada@');
  expect(screen.queryByText('Enter a valid email address')).not.toBeInTheDocument();
  await user.tab();
  expect(screen.getByText('Enter a valid email address')).toBeInTheDocument();
});
Three concerns, three tiers Validation rules are tested exhaustively on the schema as a pure function, form behaviour such as timing, focus and error association is tested by rendering the component, and the submission contract is tested with the network intercepted. rules the schema, directly every case, every boundary microseconds each behaviour render and type timing, focus, association milliseconds each submission MSW-intercepted API payload, pending, rejection a handful per form
Separating the three stops component tests from re-verifying every rule through the DOM.

Validation timing is one of the few form behaviours that a library upgrade can change silently. A new major version with a different default mode turns a form that validated politely on blur into one that shows errors after the first keystroke, and no other test notices because every error-content assertion still passes. One test that types a partial value and checks for the absence of an error is all it takes to pin the choice.

Step 5 — Test the pending state and double submission. While a request is in flight, the submit button should be disabled or marked busy, and a second click should not send a second request.

test('prevents a double submission while saving', async () => {
  let calls = 0;
  server.use(http.post('/api/signup', async () => { calls++; await delay(200); return HttpResponse.json({ ok: true }); }));
  const user = userEvent.setup();
  render(<SignupPage />);
  await fillValidSignup(user);

  const submit = screen.getByRole('button', { name: 'Create account' });
  await user.click(submit);
  await user.click(submit);

  expect(submit).toBeDisabled();
  await screen.findByRole('heading', { name: 'Check your inbox' });
  expect(calls).toBe(1);
});

The disabled state is only half the check. The count of requests the handler received is what proves the protection works, because a button can be visually disabled after the second click has already been processed. Asserting on both the interface and the network catches the case where the disabling happens a moment too late — a race users on slow connections find immediately.

Step 6 — Test server rejection mapped back onto fields. A 422 with field errors should appear next to the right fields, with the user’s input intact.

test('shows the server’s field error and keeps the input', async () => {
  server.use(http.post('/api/signup', () =>
    HttpResponse.json({ errors: { email: 'An account with this email already exists' } }, { status: 422 })));
  const user = userEvent.setup();
  render(<SignupPage />);
  await fillValidSignup(user, { email: 'taken@example.test' });
  await user.click(screen.getByRole('button', { name: 'Create account' }));

  const email = await screen.findByLabelText('Email');
  expect(email).toHaveAccessibleDescription('An account with this email already exists');
  expect(email).toHaveValue('taken@example.test');
});

Server rejection is the branch forms most often handle badly, and it deserves more than one test on important forms: a field-level error like the one above, a form-level error with no field (“we could not process your request”), and a network failure with no response at all. Each needs a different presentation, and each is a single MSW override away.

Configuration Reference Table

Setting Where Effect on tests
validation mode form library Decides whether errors appear on change, blur or submit; pin it in a test.
reValidateMode React Hook Form Controls revalidation after the first submit; affects error-clearing tests.
shouldFocusError React Hook Form Moves focus to the first invalid field on submit; assert on document.activeElement.
schema resolver form library Connects Zod or Yup; the schema itself is tested separately.
userEvent.setup({ delay }) tests null for speed; realistic delays rarely add value in component tests.
aria-describedby markup Links error text to the field; required for toHaveAccessibleDescription.
aria-invalid markup Marks the field invalid; required for toBeInvalid.
noValidate form element Disables browser validation so the application’s own messages are tested.

Verification & Assertions

The most valuable single assertion in form testing is toHaveAccessibleDescription on an invalid field. It proves three things at once: the error is rendered, it is associated with the field programmatically, and the text is what a screen-reader user will hear. A test that only checks the message is somewhere on the page passes for a form that is broken for a significant share of users.

Assert on focus after submission as well. A form that fails validation should move focus to the first invalid field, or to an error summary, so keyboard and screen-reader users are taken to the problem rather than left at the submit button wondering why nothing happened.

await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(screen.getByLabelText('Email')).toHaveFocus();

Where a form offers a way to reveal a password, clear a field or add another row, test those controls by role as well. They are small, frequently overlooked, and they are exactly the controls that ship without accessible names — an icon button with no label is invisible to assistive technology and, not coincidentally, impossible to find with getByRole.

Finally, assert on the submitted payload rather than on the form’s internal values. The payload is the contract with the server; transformations between what the user typed and what is sent — trimming, number parsing, date formatting — are where subtle bugs hide, and only an assertion on the intercepted request sees them, as discussed in asserting request payloads without brittle snapshots.

A form-level assertion worth adding on long forms is an error summary: a list at the top of the form, linked to each invalid field, that appears after a failed submission. It is the pattern accessibility guidelines recommend for anything beyond a few fields, and testing it — the summary receives focus, each item links to its field, following a link focuses the field — covers keyboard users’ entire recovery path in one test.

Edge Cases & Failure Modes

Browser validation masking application validation. A type="email" input with the required attribute triggers the browser’s own validation before the application’s. jsdom implements some of this, so tests may see the browser’s behaviour rather than yours. Add noValidate to forms that manage their own messages, so tests exercise the messages users actually see.

Setting values instead of typing. fireEvent.change(input, { target: { value } }) sets the value in one step, skipping the keystrokes that trigger per-character formatting, masking and on-change validation. Tests pass for input masks that break under real typing. Use user.type, which types character by character.

Required fields marked only visually. An asterisk tells sighted users a field is required; aria-required or the required attribute tells everyone else. Assert on the attribute for each required field, since a missing one passes every other test here while leaving screen-reader users guessing.

Errors that never clear. A field that shows an error, is corrected, and still shows the error is a common bug in hand-written validation. Every validation test should include the correction step, asserting the error disappears and aria-invalid is removed.

Lost input after a failed submission. Some forms reset on any response, erasing everything the user typed when the server rejects one field. The rejection test in Step 6 asserts the value is still present; without that assertion, this regression goes unnoticed until users complain.

What a visible error check misses Checking that an error message is visible passes even when it is not linked to its field, while checking the field's accessible description and invalid state proves the error is associated and announced. getByText(error) the text is somewhere passes if unlinked passes if not announced sighted users only toHaveAccessibleDescription linked by aria-describedby plus toBeInvalid exact text a reader hears every user
The stronger assertion costs no more to write and fails for the bugs the weaker one lets through.

Autofill and paste bypassing formatting. Browsers fill forms from saved data without keystrokes, and users paste card numbers and addresses. A field that formats on keydown — inserting spaces into a card number, say — may be left unformatted by autofill or paste. Test paste explicitly with user.paste for any field with formatting logic.

Performance & CI Impact

Form component tests are fast, but typing is the dominant cost within them: user-event types character by character, and a test that fills eight fields with realistic values types hundreds of characters. With delay: null that is still only tens of milliseconds, but it adds up across a large suite. Where a test is about submission rather than typing, user.paste fills a field in one step while still dispatching realistic events.

The larger performance win is structural. Moving every validation case to schema tests removes most renders from the form suite; a form with twenty rules needs perhaps five component tests — valid submission, error association, timing, pending state, server rejection — rather than twenty-five. Those schema tests run in microseconds and can be exhaustive without slowing anything.

Flakiness in form tests almost always comes from asserting before an asynchronous validator or a submission has settled. findBy queries for anything that appears after an await, and MSW with an explicit delay for pending states, remove nearly all of it. Real timers and arbitrary waits have no place here, as replacing arbitrary waits with deterministic conditions explains.

Finally, consider where form tests run in the pipeline. Because they are fast and cover high-value paths, form component tests belong in the blocking pull-request suite without exception; a checkout form regression should never reach the default branch. The single end-to-end test per journey can run alongside them, while any broader cross-browser form testing belongs in a scheduled job.

In-Depth Guides