Unit Testing Validation Logic

Validation code is some of the most testable code in a front-end codebase and some of the least tested. A Luhn check, a postcode table, a date-of-birth rule, a Zod schema, a password policy — each is a pure function from input to verdict, with no network, no rendering and no timing, yet many teams test them only through slow end-to-end flows or not at all. The consequences are familiar: a regex change that silently rejects every Irish Eircode, an age rule that breaks on 29 February, a schema message that drifts from the server’s. This topic sets out a unit-testing strategy for validation logic with Vitest: separating pure rules from DOM wiring so most tests need no browser, table-driven fixtures drawn from real inputs, asserting on messages as well as verdicts, controlling time, testing code that uses the Constraint Validation API within jsdom’s limits, and property-based tests that explore inputs no fixture list would think of.

The failure this prevents is the silent regression: a validation rule that stops accepting valid input, which no error log records because, from the code’s point of view, it worked.

The validation testing pyramid A layered view of validation tests, from many fast pure-function unit tests at the base, through schema and DOM-wiring tests, to a small number of browser tests and accessibility audits at the top. Browser tests Playwright: focus, announcements, real typing Accessibility audits axe-core on error states DOM wiring tests jsdom: setCustomValidity, aria-invalid Schema tests Zod / Valibot with FormData fixtures Pure rule tests table-driven fixtures + property tests
Push as much validation logic as possible into pure functions, so the fast bottom layer carries most of the coverage.

Prerequisites for Unit Testing Validation

Requirement Minimum version Why it is needed
Vitest 2.x+ Fast runner with it.each, fake timers, jsdom/happy-dom environments
jsdom or happy-dom recent DOM APIs for wiring tests
TypeScript 5.0+ Typed fixtures
fast-check 3.x Property-based tests
@testing-library/dom 10.x Queries by label and role in wiring tests
Validation code split into pure modules So most tests need no DOM

Test Design Reference

Technique Tests Example
Table-driven fixtures Known good and bad inputs it.each(VALID_POSTCODES)
Message assertions The exact text users see toBe("Enter a postcode like SW1A 1AA.")
Boundary values Limits and off-by-one 11, 12, 13 characters for min 12
Injected clock Date rules ageOn(dob, new Date(2026, 8, 18))
FormData fixtures Schemas as they receive data strings, missing checkbox keys
Property-based tests Invariants over generated inputs “every single-digit change breaks Luhn”
Snapshot of error map Many fields at once Use sparingly; review diffs

Step-by-Step Implementation

1. Separate rules from wiring

// rules/postcode.ts — pure, no DOM
export function postcodeError(raw: string, country: string): string {
  const rule = RULES[country];
  if (!rule) return "";
  const v = raw.trim().toUpperCase();
  if (!v) return `Enter your ${rule.label.toLowerCase()}.`;
  return rule.pattern.test(v) ? "" : `Enter a ${rule.label.toLowerCase()} like ${rule.example}.`;
}

// wiring/postcode-field.ts — thin, DOM-only
export function wirePostcode(input: HTMLInputElement, country: HTMLSelectElement): void {
  const run = () => input.setCustomValidity(postcodeError(input.value, country.value));
  input.addEventListener("input", run);
  country.addEventListener("change", run);
}

Nearly all behaviour lives in postcodeError, which can be tested thousands of times per second without a DOM. The wiring is small enough to cover with a handful of jsdom tests. This split is the most important design decision for testable validation, and it is the same shape recommended in composing pure validator functions.

2. Test with real-world fixtures, including messages

import { describe, it, expect } from "vitest";
import { postcodeError } from "../rules/postcode";

const VALID: Array<[string, string]> = [
  ["GB", "SW1A 1AA"], ["GB", "m1 1ae"], ["GB", "EC1A1BB"],
  ["IE", "D02 X285"], ["US", "02134"], ["US", "02134-1234"], ["CA", "K1A 0B1"], ["NL", "1012 AB"],
];

const INVALID: Array<[string, string, string]> = [
  ["US", "2134", "Enter a zip code like 02134."],
  ["GB", "SW1A", "Enter a postcode like SW1A 1AA."],
  ["CA", "K1A 0B", "Enter a postal code like K1A 0B1."],
];

describe("postcodeError", () => {
  it.each(VALID)("%s accepts %s", (cc, code) => expect(postcodeError(code, cc)).toBe(""));
  it.each(INVALID)("%s rejects %s with a helpful message", (cc, code, msg) => expect(postcodeError(code, cc)).toBe(msg));
  it("accepts anything for countries without a rule", () => expect(postcodeError("70040-010", "BR")).toBe(""));
});

Asserting on the exact message catches a class of regressions verdict-only tests miss: a refactor that changes “Enter a postcode like SW1A 1AA.” into “Invalid postcode” still returns an error, so not.toBe("") passes while users lose the example that told them how to fix it.

3. Control time for date rules

import { vi, it, expect, afterEach } from "vitest";
afterEach(() => vi.useRealTimers());

it("a card expiring this month is still valid", () => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date(2026, 8, 30, 23, 59));      // last minute of September
  expect(expiryError("09/26")).toBe("");
  vi.setSystemTime(new Date(2026, 9, 1, 0, 0));          // first minute of October
  expect(expiryError("09/26")).toMatch(/expired/);
});

Prefer an injected now parameter where you can — expiryError(raw, now) — and use fake timers for code that calls new Date() internally. Either way, never let a validation test depend on the day it runs. The date rules themselves are covered in date and time validation.

4. Test schemas with the wire format

import { signupSchema } from "../schemas/signup";

const fd = (entries: Record<string, string>) => {
  const f = new FormData();
  Object.entries(entries).forEach(([k, v]) => f.append(k, v));
  return Object.fromEntries(f);
};

it("rejects an unchecked terms box (missing key)", () => {
  const r = signupSchema.safeParse(fd({ email: "ada@example.com", password: "correct horse battery" }));
  expect(!r.success && r.error.flatten().fieldErrors.terms).toEqual(["You must accept the terms to create an account."]);
});

Schemas receive strings and missing keys, not the tidy objects tests like to construct. Building inputs from FormData keeps tests honest about what the browser and server actually send. The dedicated recipe is testing Zod schemas with Vitest.

From rule to trustworthy test suite Rules are extracted into pure functions, covered with real fixtures and message assertions, date logic gets a fixed clock, schemas get FormData inputs, and property tests add generated inputs. Pure rules no DOM, no clock Fixtures + messages real inputs, exact text Fixed clock injected now / fake timers FormData inputs wire format Property tests generated inputs
Each step removes a source of flakiness or blind spots, so a green run actually means the rules still accept real users' input.

State Management and Edge Cases in Tests

Validation tests go wrong in predictable ways:

  • Shared mutable state. A validator with a module-level cache (availability, compiled regexes with the g flag) leaks between tests. Reset caches in beforeEach, and never use g or y on validation regexes.
  • Locale-dependent output. Messages that format dates or numbers with Intl vary by the test machine’s locale. Pin the locale in the test setup, or pass it explicitly.
  • Time zones. Date tests that pass in one zone fail in another. Run CI with TZ=UTC and add explicit tests for a far-east and a far-west zone for any rule that uses local dates.
  • jsdom gaps. jsdom implements validity and setCustomValidity but not layout, focus visibility or :user-invalid. Test those in a browser.
What jsdom can and cannot test Two columns listing validation behaviours that jsdom unit tests cover reliably and those that need a real browser. jsdom is fine for • validity flags from required, pattern, min, max • setCustomValidity and customError • aria-invalid and message text in the DOM • event wiring (input, change, blur) Needs a real browser • :user-invalid and computed styles • focus after reportValidity • screen reader announcements • tooShort from real typing, autofill
Use jsdom for the logic around native validity; use a real browser for anything visual, focus-related or announced.

Accessibility Assertions in Unit Tests

Unit tests can and should assert the accessibility wiring that validation produces, because it breaks silently: aria-invalid toggled in step with the message, aria-describedby pointing at an element that exists and contains the message, and the message text being non-empty and specific. Testing Library’s toHaveAccessibleDescription matcher (from @testing-library/jest-dom) checks the computed description directly, which catches broken id references. What unit tests cannot check is whether assistive technology actually announces the message; that needs the manual and automated approaches in screen reader testing for forms and axe-core audits.

import { screen } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";

it("links the error to the field", async () => {
  document.body.innerHTML = renderSignupForm();
  wireSignup(document.querySelector("form")!);
  const email = screen.getByLabelText("Email address");
  await userEvent.type(email, "not-an-email");
  await userEvent.tab();
  expect(email).toHaveAttribute("aria-invalid", "true");
  expect(email).toHaveAccessibleDescription("Enter an email address like name@example.com.");
});

Common Gotchas and Debugging

Testing only happy paths. A suite of valid inputs passes when a rule accepts everything. Always pair valid fixtures with invalid ones that must fail.

Asserting toBeTruthy() on errors. It passes for any message, including a wrong one. Assert the exact text or a precise pattern.

// Before
expect(emailError("x")).toBeTruthy();
// After
expect(emailError("x")).toBe("Enter an email address like name@example.com.");

tooShort in jsdom. Setting input.value from a test does not set tooShort, just as in browsers. Test length rules through your custom rule or through real typing in a browser test.

Snapshotting large error maps. Snapshots of whole-form errors get updated without review. Prefer explicit assertions for important fields.

Property-Based Tests for Validators

Fixtures test the inputs you thought of; property-based tests generate the ones you did not. For validation logic, the most useful properties are invariants: every string the formatter produces is accepted by the validator; normalising twice is the same as normalising once; every single-digit change to a Luhn-valid number is rejected; any string containing no letters is rejected by the name rule. fast-check generates hundreds of inputs per property and shrinks failures to a minimal counterexample, which often reveals Unicode, whitespace and boundary bugs within seconds. The approach is covered in property-based testing validators with fast-check.

import fc from "fast-check";

it("normalising a postcode is idempotent", () => {
  fc.assert(fc.property(fc.string(), (s) => normalisePostcode(normalisePostcode(s, "GB"), "GB") === normalisePostcode(s, "GB")));
});

Keeping Fixtures Honest Over Time

A fixture list is most valuable when it records real inputs that once broke the form. Every support ticket about a rejected valid value should add that value (anonymised) to the fixtures before the rule is fixed, so the regression can never return. Group fixtures by rule in one file per validator, comment the unusual ones (“Jersey postcode, matches GB shape”), and review additions like code. Over time the list becomes documentation of the real world the form serves — the practice recommended throughout validating common input types.

Running Validation Tests in CI

Validation unit tests are fast, so run them on every commit and fail the build on any failure. Pin the time zone and locale in CI (TZ=UTC, LANG=en_US.UTF-8) and add one job that runs date-sensitive suites under an extreme zone to catch local-date bugs. Measure coverage per rule module rather than globally — a rule file at 100% branch coverage is a meaningful claim; a project at 80% is not. And run the shared schema tests once for both client and server, since they are the same code, as described in shared client–server schemas.

Testing Boundary Values Systematically

Most validation bugs sit on a boundary: the twelfth character of a password with min 12, the maximum quantity, the eighteenth birthday, the last day of a card’s expiry month. Boundary testing means writing three cases for every limit — just below, exactly on, and just above — and naming them so a failure says which side broke. It is tedious by hand, so generate the cases from the rule’s own configuration. That way, when someone changes the limit from 12 to 14, the tests move with it and still check the edges instead of silently testing the middle of the range.

const LIMITS = { password: { min: 12, max: 128 }, quantity: { min: 1, max: 99 } } as const;

describe.each(Object.entries(LIMITS))("%s limits", (field, { min, max }) => {
  const make = (n: number) => (field === "password" ? "a".repeat(n) : String(n));
  it.each([
    [min - 1, false], [min, true], [min + 1, true],
    [max - 1, true], [max, true], [max + 1, false],
  ])("value of size %i valid=%s", (n, ok) => {
    expect(RULES[field](make(n)) === "").toBe(ok);
  });
});

Length boundaries deserve a Unicode case too. A password rule that counts UTF-16 code units treats an emoji as two characters, so "a".repeat(10) + "😀" passes a 12-character minimum while the user typed eleven visible characters. Decide whether your limits count code points, grapheme clusters or bytes, write that decision down as a test, and keep it consistent with the server, as covered in minlength and maxlength character limits.

Testing Async Validators Without the Network

Async validators — username availability, address lookup, VAT number checks — are the rules most often left untested, because they seem to need a server. They do not. Inject the fetcher, pass a fake that resolves or rejects on demand, and test the behaviour that matters: the right message for each response, stale responses being ignored when a newer request has started, aborted requests not producing errors, and network failure producing a neutral “could not check” state instead of a false rejection.

it("ignores a stale response that resolves after a newer one", async () => {
  const pending = new Map<string, (taken: boolean) => void>();
  const fetcher = (name: string) => new Promise<boolean>((r) => pending.set(name, r));
  const check = createAvailabilityCheck(fetcher);

  const first = check("ada");
  const second = check("adal");
  pending.get("adal")!(false);                 // newer request answers first
  pending.get("ada")!(true);                   // older request answers late
  expect(await second).toBe("");
  expect(await first).toBe(null);              // null = superseded, do not render
});

Controlling resolution order by hand is what makes this test deterministic. The same technique covers debounce: combine it with fake timers, advance by the debounce delay, and assert how many times the fetcher was called. The runtime pattern is described in asynchronous server checks.

Organising a Validation Test Suite

A predictable layout makes it obvious where a new test goes and what is missing. Mirror the source tree — one test file per rule module, one per schema, one per wiring module — and keep fixtures next to the tests that use them rather than in a global folder. Name tests after behaviour a product owner would recognise (“rejects a US zip code with four digits”) rather than implementation (“regex returns false”). And keep each test to one input and one expectation where you can; table-driven it.each gives you many small, independently reported tests for the price of one function, which beats a single test with twenty expect calls that stops at the first failure.

Where each validation test belongs A grid mapping kinds of validation code to the test file and environment that should cover them. rules/*.ts rules/*.test.ts in Node schemas/*.ts schemas/*.test.ts with FormData wiring/*.ts wiring/*.test.ts in jsdom async checks fake fetcher + fake timers error summary jsdom + Testing Library focus and styling Playwright in a real browser
Mirror the source layout so every rule, schema and wiring module has an obvious test home.

Testing Error Messages as Content

Error messages are user-facing copy, and a test suite is a good place to enforce the rules that make them useful. Beyond asserting individual messages, add a small meta-test that walks every message a rule module can produce and checks house style: each one ends with a full stop, none contains the words “invalid” or “error” on its own, none exceeds a length that wraps badly on a phone, and each one tells the user what to do rather than only what went wrong. These checks are cheap and they stop the gradual drift where one developer writes “Invalid input!” next to another’s carefully worded instruction.

import { ALL_MESSAGES } from "../rules/messages";

it.each(Object.entries(ALL_MESSAGES))("%s follows message style", (_key, msg) => {
  expect(msg).toMatch(/\.$/);
  expect(msg).not.toMatch(/^invalid\b/i);
  expect(msg.length).toBeLessThanOrEqual(90);
});

If messages are translated, run the same checks per locale and add one that every key exists in every locale file, so a missing translation fails the build instead of showing a raw key to users.

What Not to Unit Test

Not everything benefits from a unit test. Do not re-test the browser: asserting that type="email" rejects abc in jsdom tests jsdom, not your code. Do not unit test third-party schema libraries’ built-ins — z.string().email() is Zod’s responsibility — but do test your composition of them, your messages and your refinements. And do not chase coverage in the wiring layer with brittle tests of event listener counts; a few behavioural tests of “type, blur, see message” cover it better. Spend the saved effort on fixtures and properties for the rules, where bugs actually live.

Browser Compatibility Matrix

Test environments for validation code A table comparing Node, jsdom, happy-dom and real browsers for the validation features they support in tests. Pure rules validity API Focus and styles Node (no DOM) ✓ Yes ✗ No ✗ No jsdom ✓ Yes ✓ Yes partial happy-dom ✓ Yes partial partial Playwright browser ✓ Yes ✓ Yes ✓ Yes
Pure rules run anywhere; native validity works in jsdom; visual and focus behaviour needs a real browser.
Environment Intl.Segmenter validity / setCustomValidity :user-invalid Focus
Node 20+ Yes
jsdom Yes (from Node) Yes No Partial
happy-dom Yes Partial No Partial
Chromium / Firefox / WebKit via Playwright Yes Yes Yes Yes

Frequently Asked Questions

How should I unit test form validation?

Put rules in pure functions and test them with table-driven fixtures of real valid and invalid inputs, asserting on exact messages. Keep DOM wiring thin and cover it with a few jsdom tests, and leave focus and announcements to browser tests.

Why assert on error messages and not just on valid or invalid?

Because the message is what users rely on to fix the problem. A refactor can keep the verdict but lose the example or the instruction, and only a message assertion catches it.

How do I test date validation reliably?

Inject the current date into the rule or use fake timers to set the system time, and run CI with a fixed time zone plus an extra run in a far-east and far-west zone.

Can jsdom test the Constraint Validation API?

Partly. jsdom supports validity flags and setCustomValidity, so it can test wiring. It does not implement layout, :user-invalid or real focus behaviour, which need a real browser.

← Back to Testing & Accessibility

Explore This Section