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.
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.
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
gflag) leaks between tests. Reset caches inbeforeEach, and never usegoryon validation regexes. - Locale-dependent output. Messages that format dates or numbers with
Intlvary 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=UTCand add explicit tests for a far-east and a far-west zone for any rule that uses local dates. - jsdom gaps. jsdom implements
validityandsetCustomValiditybut not layout, focus visibility or:user-invalid. Test those in a browser.
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.
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
| 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.
Related Guides
- Testing Zod Schemas with Vitest — schema-focused tests.
- Mocking ValidityState in jsdom — testing native-validity wiring.
- Property-Based Testing Validators with fast-check — generated inputs.
- Playwright Form Validation Testing — the browser layer.
← Back to Testing & Accessibility