Property-Based Testing Validators with fast-check

Fixture tests check the inputs you thought of. Validators fail on the inputs you did not: a name containing a combining accent, a phone number with a non-breaking space pasted from a PDF, a card number whose formatter inserts a space the validator then rejects. Property-based testing turns the question around. Instead of listing examples, you state a property that must hold for every input — “anything the formatter produces, the validator accepts” — and a library generates hundreds of inputs trying to break it. When it finds a failure, it shrinks the input to the smallest counterexample, which is usually a one-character string that explains the bug on sight. This guide uses fast-check with Vitest to add properties to form validators, as part of the approach in unit testing validation logic.

The failure this guide prevents: a validator and a formatter that disagree about some rare but real input, discovered by a customer rather than by CI.

How a property test runs fast-check generates random inputs, runs the property against each, and on the first failure shrinks the input step by step to a minimal counterexample that is reported with a seed for replay. Generate random inputs from arbitraries Check property 100 runs by default Failure found e.g. a 14-character string Shrink smaller inputs that still fail Report minimal counterexample + seed
Generation finds a failure; shrinking makes it readable; the seed makes it reproducible.

Prerequisites

Requirement Minimum version Notes
fast-check 3.x fc.assert, fc.property, arbitraries
Vitest 2.x+ Or @fast-check/vitest for test.prop
TypeScript 5.0+ Typed arbitraries
Validators as pure functions Properties need no DOM

Which Properties Suit Validators

Property Statement Catches
Round trip validate(format(x)) is valid for every valid x Formatter inserts characters the validator rejects
Idempotence normalise(normalise(s)) === normalise(s) Normalisers that keep changing input
Error detection Every single-digit change to a valid number is rejected Checksums implemented wrongly
Robustness validate(s) never throws for any string Crashes on odd Unicode
Agreement Client and server validators agree on every input Drift between two implementations
Monotonicity Adding a character never makes a too-long value valid Off-by-one limits

Step 1: Robustness — Never Throw

The cheapest and most surprisingly productive property: the validator returns a verdict for any string without throwing.

import { it } from "vitest";
import fc from "fast-check";
import { phoneError } from "../rules/phone";

it("phoneError never throws", () => {
  fc.assert(
    fc.property(fc.string({ unit: "grapheme" }), (s) => {
      const r = phoneError(s, "GB");
      return typeof r === "string";
    }),
  );
});

unit: "grapheme" (fast-check 3.22+) generates whole user-perceived characters, including emoji and combining sequences, which is where hand-written validators most often crash — for example a regex built from user input, or str[0].toUpperCase() on an empty string. On older versions, fc.fullUnicodeString() serves the same purpose.

Step 2: Round Trip — The Formatter and Validator Agree

import { formatCardNumber, cardNumberError, luhnCheckDigit } from "../rules/card";

const validCard = fc
  .array(fc.integer({ min: 0, max: 9 }), { minLength: 12, maxLength: 18 })
  .map((digits) => digits.join(""))
  .map((payload) => payload + luhnCheckDigit(payload));

it("every formatted valid card number is accepted", () => {
  fc.assert(
    fc.property(validCard, (n) => cardNumberError(formatCardNumber(n)) === ""),
  );
});

The arbitrary builds only valid numbers by construction: random digits plus a correct check digit. The property then says formatting must never make a valid number invalid. If the formatter groups digits with a non-breaking space and the validator strips only regular spaces, this fails within a few runs and shrinks to a 13-digit number. The formatter itself is covered in Luhn algorithm credit card validation.

Step 3: Error Detection — Checksums Catch What They Promise

The Luhn algorithm is guaranteed to detect any single-digit error. A property states that guarantee directly and tests your implementation against it:

it("any single-digit change breaks the Luhn check", () => {
  fc.assert(
    fc.property(
      validCard,
      fc.nat(),
      fc.integer({ min: 1, max: 9 }),
      (n, posSeed, delta) => {
        const i = posSeed % n.length;
        const changed = n.slice(0, i) + ((Number(n[i]) + delta) % 10) + n.slice(i + 1);
        return cardNumberError(changed) !== "";
      },
    ),
  );
});

A buggy implementation — doubling the wrong positions, forgetting to subtract 9 — passes most hand-picked fixtures yet fails this property immediately. The same pattern applies to IBAN mod-97 checks, ISBN check digits and any other checksum a form validates.

Shrinking a failing input fast-check finds a long failing string, then repeatedly tries smaller variants, keeping each one that still fails, until it reports a minimal counterexample. fast-check Property Report 30-char string with U+00A0 fails 15-char half still fails a lone U+00A0 still fails counterexample and seed
Shrinking turns a random 30-character failure into the single character that actually triggers the bug.

Step 4: Idempotence — Normalisers Settle

Normalisers — trimming, collapsing whitespace, upper-casing postcodes, NFC-normalising names — should reach a fixed point after one application. If normalising twice gives a different result, the stored value and the displayed value will drift.

import { normaliseName } from "../rules/name";

it("normaliseName is idempotent", () => {
  fc.assert(
    fc.property(fc.string({ unit: "grapheme" }), (s) => normaliseName(normaliseName(s)) === normaliseName(s)),
  );
});

it("normalised names are accepted if the raw name was", () => {
  fc.assert(
    fc.property(fc.string({ unit: "grapheme", minLength: 1 }), (s) => {
      fc.pre(nameError(s) === "");
      return nameError(normaliseName(s)) === "";
    }),
  );
});

fc.pre discards inputs that do not meet a precondition. Use it sparingly: if most generated inputs are discarded, fast-check gives up, which is a sign you need a better arbitrary rather than a filter. The rules these properties protect are discussed in identity and text field validation.

Step 5: Agreement — Two Implementations, One Answer

When a rule exists twice — a hand-written client check and a server regex, or a legacy validator and its replacement — a property can compare them over generated input:

import { emailErrorClient } from "../client/email";
import { emailErrorServer } from "../server/email";

it("client and server agree on email validity", () => {
  fc.assert(
    fc.property(fc.oneof(fc.emailAddress(), fc.string()), (s) => (emailErrorClient(s) === "") === (emailErrorServer(s) === "")),
  );
});

Mixing fc.emailAddress() with arbitrary strings ensures both valid and invalid inputs are generated. A disagreement means users can pass the client check and be rejected by the server, the situation described in shared client–server schemas.

Step 6: Monotonicity — Limits Behave at the Edges

Length and range limits have a simple shape that a property can state exactly: once a value is too long, adding characters never makes it valid again, and once it is within the limit, removing characters never makes it too long. The property is useful because length bugs usually come from counting the wrong unit — UTF-16 code units instead of code points or graphemes — and generated emoji find them at once.

import { bioError, MAX_BIO } from "../rules/bio";

const graphemes = (s: string) => [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)].length;

it("bio length is counted in user-perceived characters", () => {
  fc.assert(
    fc.property(fc.string({ unit: "grapheme", maxLength: MAX_BIO + 20 }), (s) => {
      const tooLong = bioError(s) === `Keep your bio to ${MAX_BIO} characters or fewer.`;
      return tooLong === graphemes(s) > MAX_BIO;
    }),
  );
});

The property compares the validator against an independent count, so a validator that uses s.length fails as soon as fast-check generates a string with an emoji near the limit. Whether you count graphemes or code points is a product decision; the property pins whichever one you chose, and the server must count the same way.

Reproducing and Pinning Failures

When a property fails, fast-check prints the counterexample, a seed and a path. Replay the exact failure while debugging:

fc.assert(fc.property(validCard, prop), { seed: 1843567230, path: "12:3:0", endOnFailure: true });

Once fixed, copy the minimal counterexample into your ordinary fixture list so the regression is checked on every run regardless of what the generator produces. Properties find bugs; fixtures keep them fixed.

Fixtures versus properties Two columns comparing example-based fixture tests with property-based tests for validators. Fixtures ✓ exact inputs and messages ✓ document real-world cases • only what you thought of Properties ✓ hundreds of generated inputs ✓ shrink to minimal failures • need a statable invariant ✗ seeds make failures reproducible
Use both: properties explore, fixtures pin down the exact inputs that once failed.

Common Mistakes

Restating the implementation as the property. validate(s) === /regex/.test(s) using the same regex proves nothing. Properties should come from an independent fact: a round trip, a guarantee, a second implementation.

Filtering instead of generating. fc.string().filter(isValidPostcode) discards almost everything. Build valid values by construction, as the card arbitrary does.

Too few runs for rare cases. The default of 100 runs is fine locally. For checksums and Unicode, raise numRuns in CI, or run a nightly job with thousands of runs.

Asserting too much in one property. A property that checks format, message text and normalisation at once fails with a counterexample that does not say which part broke. Keep one invariant per property, and let the shrunk input speak for itself.

Ignoring time. Properties over date rules must fix the clock, exactly like fixture tests, or they become flaky in a way that is hard to reproduce even with a seed.

Performance and CI

Property tests are slower than fixtures because they run many cases, but validators are fast, so a few hundred runs usually take milliseconds. Keep the default run count on every commit and add a scheduled job with a high numRuns and a random seed, reporting the seed on failure. Keep arbitraries in a shared module so every test file generates valid cards, postcodes and names the same way; over time that module becomes a precise, executable description of what “valid” means for your forms.

Frequently Asked Questions

What is property-based testing for form validation?

Instead of listing example inputs, you state a rule that must hold for every input, such as "every formatted valid card number is accepted", and a library like fast-check generates many inputs trying to break it.

Which properties are most useful for validators?

Round trips between formatter and validator, idempotent normalisers, checksum error detection, never throwing on any string, and agreement between client and server implementations.

How do I reproduce a failing fast-check test?

Pass the reported seed and path back to fc.assert. Then add the minimal counterexample to your fixture list so the case is always tested.

Do property tests replace fixture tests?

No. Properties explore inputs you did not think of; fixtures document exact real-world cases and messages. Use both.

← Back to Testing & Accessibility