Hand-written validator tests check the inputs someone thought of — "", "abc", "[email protected]" — and miss the ones users actually produce: a trailing no-break space, a number with two decimal separators, an emoji in a name, a date on 29 February, a card number whose formatting round-trip drops a digit.

Property-based testing flips the approach: instead of listing inputs, you state properties that must hold for all inputs, and a library such as fast-check generates hundreds of cases, including awkward ones, and shrinks any failure to a minimal example. Validators, parsers and formatters — pure functions with clear invariants — are ideal subjects. This page is part of testing form validation.


Context and prerequisites

Properties that suit form code:

  • Round trip. parse(format(x)) === x for every valid raw value — the formatter and parser agree.
  • Idempotence. normalise(normalise(s)) === normalise(s) — trimming or formatting twice changes nothing more.
  • Validity is preserved. If validate(x) passes, then validate(normalise(x)) passes — normalisation never makes a valid value invalid.
  • Never throws. validate(anyString) returns an error or null, never an exception — validators handle garbage.
  • Agreement. Two implementations (client and server, old and new schema) return the same verdict — the migration check from migrating from Yup to Zod, automated.
  • Monotonicity. Adding characters to a too-long value never makes it valid.

When a property fails, fast-check shrinks the input to a minimal counterexample (for example, from a 40-character random string to " "), which usually points straight at the bug.

Properties worth checking for common form functions For a locale number parser and formatter, the round trip property that parsing a formatted number returns the same number finds separator and precision bugs. For a card number formatter, the property that stripping spaces from the formatted value returns the original digits finds grouping bugs that drop or duplicate digits. For an email validator, the property that it never throws on any string finds crashes on unusual input. For a name normaliser, idempotence finds trimming and whitespace bugs. For two schema versions, the property that both return the same verdict finds migration regressions. Function Property Finds locale number parser parse(format(n)) === n separator, precision bugs card formatter digits(format(d)) === d dropped or duplicated digits email validator never throws on any string crashes on odd input name normaliser normalise is idempotent whitespace handling schema v1 vs v2 same verdict for all inputs migration regressions

The core pattern: properties with fast-check

import fc from "fast-check";
import { describe, it, expect } from "vitest";
import { localeNumber } from "../src/locale-number";          // parse/format by locale
import { groupDigits } from "../src/card";                     // "4111111111111111" → "4111 1111 1111 1111"
import { checkEmail } from "../src/email";
import { normaliseName } from "../src/names";

describe("form functions: properties", () => {
  it("number formatting round-trips in every supported locale", () => {
    const locales = ["en-US", "en-GB", "de-DE", "fr-FR", "de-CH", "en-IN"];
    fc.assert(
      fc.property(
        fc.constantFrom(...locales),
        // Money-like values: up to 10^9 with 2 decimals, as integers of cents.
        fc.integer({ min: -100_000_000_000, max: 100_000_000_000 }),
        (locale, cents) => {
          const n = cents / 100;
          const ln = localeNumber(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
          expect(ln.parse(ln.format(n))).toBeCloseTo(n, 2);
        },
      ),
      { numRuns: 500 },
    );
  });

  it("card grouping never loses or adds digits", () => {
    fc.assert(fc.property(
      fc.stringMatching(/^\d{12,19}$/),
      (digits) => { expect(groupDigits(digits).replace(/ /g, "")).toBe(digits); },
    ));
  });

  it("email check never throws, whatever the input", () => {
    fc.assert(fc.property(
      fc.string({ unit: "grapheme", maxLength: 200 }),   // includes emoji and combining marks
      (s) => { expect(() => checkEmail(s)).not.toThrow(); },
    ));
  });

  it("name normalisation is idempotent", () => {
    fc.assert(fc.property(fc.string({ unit: "grapheme" }), (s) => {
      const once = normaliseName(s);
      expect(normaliseName(once)).toBe(once);
    }));
  });

  // Regression seeds: inputs that once failed are always re-run.
  it("known tricky inputs", () => {
    for (const s of [" [email protected]", "[email protected].", "1.234,56", "ÉLODIE"]) {
      expect(() => checkEmail(s)).not.toThrow();
    }
  });
});

Step-by-step walkthrough

  1. Pick pure functions with clear invariants. Parsers, formatters, normalisers, validators and schema adapters. Components and effects are poor subjects; test them with example-based tests.
  2. State properties, not examples. Round trip, idempotence, never-throws, agreement between implementations. Each property replaces dozens of hand-picked cases.
  3. Choose generators that match reality. fc.string({ unit: "grapheme" }) includes emoji and combining characters; fc.stringMatching(regex) produces structured strings; fc.constantFrom(...) enumerates locales or countries.
  4. Keep domains honest. Generate only values the function is meant to accept for round-trip properties (valid raw values), and anything at all for never-throws properties.
  5. Let shrinking do the diagnosis. A failing case is reduced to something like " " or "0,"; add it to a regression list so it is always re-checked.
  6. Pin the seed in CI when debugging. fast-check prints the seed and path of a failure; re-running with them reproduces it exactly.

Why generated inputs find different bugs

Hand-written tests are biased toward the cases the author already had in mind while writing the code — which are the cases the code already handles. Generated inputs have no such bias. They routinely produce strings that humans rarely think to type but real users and systems do produce: no-break spaces from copy-paste, full-width digits from Asian input methods, combining accents, very long values, values at exact boundaries. For form code, which sits exactly at the boundary between unpredictable human input and strict data, that difference is the whole point. A few properties per parser or validator often find more real bugs in an afternoon than a year of example tests.

A failure, found and shrunk fast-check generates hundreds of cent values and locales. On the fr-FR locale with a value above one thousand, the round trip fails because the formatter inserts a narrow no-break space as the grouping separator and the parser only strips ordinary spaces. fast-check shrinks the case to fr-FR with 1000.00, the smallest value that includes a grouping separator. The developer adds U+202F to the parser's grouping characters, and the property passes. fast-check parse(format(n)) Developer fr-FR, 73 918 204,55 … fails shrink … fr-FR, 1000.00 fails format uses U+202F; parse ignores it accept U+202F as grouping 500 runs pass

Where to start in an existing codebase

Adding property tests to a mature form codebase works best in a particular order. Start with never-throws properties on every exported validator and parser: they need no thought about expected outputs, they run quickly, and crashes on odd input are the most damaging bugs because they often take the whole form down. Next add round-trip properties for each formatter and parser pair — number, currency, date, phone, card — since those pairs are where locale and edge-case bugs concentrate. Then add agreement properties wherever two implementations must match: the client and server copies of a rule, or an old and a new schema during a migration. Only then consider more specific invariants. Each step tends to surface a few real bugs; fixing them, and adding the shrunk inputs to the example suite, leaves the codebase measurably more robust within a sprint.


Failure modes and edge cases

1. Properties that restate the implementation

expect(validate(x)).toBe(x.includes("@")) just duplicates the code. Good properties relate functions to each other (round trip, agreement) or state universal truths (never throws, idempotent).

2. Generators that are too narrow

fc.string() without a unit option generates characters from a limited range; use unit: "grapheme" or "binary" to include the characters that cause real bugs.

3. Floating-point round trips

parse(format(n)) === n fails for values the format rounds (more decimals than the format shows). Generate values at the format’s precision — integers of minor units, as above — or compare with a tolerance.

4. Slow properties

Properties that render components or call networks are slow and flaky. Keep them on pure functions; hundreds of runs of a pure parser take milliseconds.

5. Locale data differences

Intl output can differ between Node versions and browsers (for example, which space character a locale uses). Run property tests in the same runtime as production code paths, and treat locale-data differences found this way as real bugs to handle, as the French example shows.

Example tests and property tests together Example-based tests document intended behaviour with readable cases, pin specific messages and cover known regressions. Property-based tests explore the input space, find unanticipated inputs, and check invariants such as round trips and never throwing. Use both: examples for behaviour you want to show, properties for guarantees you want to hold for all inputs. Example tests Readable, documented cases. Exact messages. Known regressions. Property tests Explore the input space. Round trips, never-throws, agreement. Shrink to minimal failures.

Verification checklist


Frequently Asked Questions

How many runs are enough?

fast-check defaults to 100 runs per property. For pure, fast functions, 500–1,000 in CI is cheap and finds more. Increase runs temporarily when investigating a suspected bug.

Can I generate valid form objects for schema tests?

Yes. Build arbitraries with fc.record({ email: …, age: … }), or generate from schemas using community bridges between Zod and fast-check. Generate both valid and invalid objects and assert the schema’s verdict matches a simpler reference rule.

Does this replace fuzzing the API?

It complements it. Property tests exercise your client functions; server-side fuzzing exercises the endpoint. An agreement property between client and server validators, run with the same generator, connects the two.


Related

← Testing Form Validation