Property-Based Testing
State a law the function must obey for all inputs, let the framework generate hundreds of random inputs to try to falsify it.
Why
Example-based tests check the cases you thought of; bugs live in the cases you did not think of. Property-based testing inverts the burden: you state a property that must always hold (sorting returns the same elements in a different order; parsing then serializing returns the original; the discount is never negative), and the framework generates hundreds of random inputs trying to find a counterexample. When it finds one, it shrinks the counterexample to the smallest failing case, which is often a boundary you never considered (empty string, negative zero, a 25-hour timezone offset). It finds bugs that no amount of example-writing would have reached.
When
Use for pure functions with clear laws: parsers, serializers, math, sorting, state reducers, data transformations. Not for side-effectful code or UI. Add a property test whenever a function has a round-trip, inverse, or invariant property.
How
1. Choose a property-testing library
- JS/TS: fast-check
- Python: Hypothesis
- Rust: proptest or quickcheck
- Go: gopter
2. Identify a property (a law, not an example)
- Round-trip: parse(serialize(x)) === x
- Invariant: sort(arr).length === arr.length
- Idempotent: normalize(normalize(x)) === normalize(x)
- Monotonic: a > b → price(a) >= price(b)
3. Write the property as a function that returns true for all valid inputs
- fc.assert(fc.property(fc.array(fc.integer()), (arr) => {
const sorted = sort(arr);
return sorted.length === arr.length; // invariant
}))
4. Run it — the framework generates 100+ random inputs by default
- All pass → the property holds (with high probability)
- One fails → the framework shrinks to the minimal counterexample
5. Read the shrunk counterexample and fix the bug or fix the property
- The shrink is often illuminating: "fails for []" or "fails for [0, 0]"
- If the property was wrong (too strict), relax it; if the code was wrong, fix itExample
import { describe, it, expect } from "vitest";
import fc from "fast-check";
import { parseDuration, formatDuration } from "./duration";
describe("duration round-trip", () => {
// Property: for any valid duration, parse(format(d)) === d
it("parse ∘ format = identity", () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 86400 }), (seconds) => {
const formatted = formatDuration(seconds);
const reparsed = parseDuration(formatted);
expect(reparsed).toBe(seconds);
}),
);
});
// Property: the formatted string never contains negative numbers
it("format never produces negative components", () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 86400 }), (seconds) => {
const formatted = formatDuration(seconds);
expect(formatted).not.toMatch(/-/);
}),
);
});
});
// fast-check finds: "fails for 3600 → '1h0m0s' → parse gives 3600 ✓"
// But if formatDuration had a bug with 0: "fails for 0 → '0h0m-0s'"
// The shrunk counterexample pinpoints the exact edge case.Common Problems
- ⚠Stating properties that are trivially true ("returns a string") gives false confidence — the property must be a meaningful law that would fail if the function were wrong, not a tautology.
- ⚠Generating inputs without constraints (fc.integer() with no bounds) produces values the function will never see in production (a 10-billion-element array), and the test fails for reasons unrelated to real usage.
- ⚠Properties that are too strict ("output is always sorted ascending" for a function that does not sort) fail constantly, and developers disable them instead of fixing the property statement.
- ⚠Ignoring the shrunk counterexample and just re-running until it passes randomly wastes the most valuable output of property testing — the shrink is the bug, stated minimally.
Best Practices
- ✓Start with round-trip properties (parse ∘ serialize = identity) — they are the easiest to state and catch the most serialization bugs, and they require no oracle for the correct output.
- ✓Constrain the input generators to the function's real domain (fc.integer({ min: 0, max: 1000000 }) for a duration in seconds) so failures reflect real-world inputs, not impossible ones.
- ✓When a property fails, read the shrunk counterexample carefully — it is the minimal failing case and usually reveals an edge case (empty, zero, negative, max-boundary) you had not considered.
- ✓Combine property tests with a few example tests: the examples document specific expected behavior for readers, the properties verify the law holds for all inputs.