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.
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.
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.
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.
Related Guides
- Unit Testing Validation Logic — the overall strategy.
- Testing Zod Schemas with Vitest — schema-focused tests.
- Mocking ValidityState in jsdom — DOM-side tests.
- Validating Common Input Types — the rules worth testing.