Traditional tests = "given this input, expect this output". Yet edge cases unseen across 100 examples still bite in production. Property-based testing auto-generates thousands of inputs through a generator and verifies invariants of the input → output relation. This guide covers generators / shrinkers / the QuickCheck family — and the kinds of bugs only property tests find.
Example-based vs Property-based
Example-based (traditional):
test("reverse twice = original", () => {
expect(reverse(reverse([1,2,3]))).toEqual([1,2,3]);
expect(reverse(reverse([]))).toEqual([]);
expect(reverse(reverse([7]))).toEqual([7]);
// ... +5 examples
});
→ Only 5 examples verified. What about [1, NaN, undefined, [], "x"]?
Property-based:
test("reverse twice = original", () => {
fc.assert(
fc.property(fc.array(fc.anything()), (arr) => {
expect(reverse(reverse(arr))).toEqual(arr);
})
);
});
→ fc.array(fc.anything()) generates 1000+ arrays automatically.
strings, numbers, null, undefined, NaN, nested arrays, ...
Fails on the first case violating the property.Property — What's Being Verified
A universal "input → output" relation.
Common property types:
1. Roundtrip:
parse(serialize(x)) === x
decode(encode(x)) === x
2. Idempotence:
normalize(normalize(x)) === normalize(x)
sort(sort(x)) === sort(x)
3. Invariant:
sort(arr).length === arr.length
max(arr) >= min(arr)
4. Equivalence (vs another implementation):
slowButCorrect(x) === fastNewImpl(x)
nativeRegex(x) === customRegex(x)
5. Algebraic laws:
(a + b) + c === a + (b + c) // associativity
merge(a, b) === merge(b, a) // commutativityGenerators — Auto-Generated Inputs
fast-check (TypeScript) examples:
fc.integer() → -2147483648..2147483647
fc.integer({min: 0, max: 100}) → 0..100
fc.string() → arbitrary strings (incl. empty)
fc.string({minLength: 5}) → 5+ chars
fc.array(fc.integer()) → arbitrary-length int array
fc.record({ → object
id: fc.integer(),
name: fc.string(),
active: fc.boolean()
})
fc.oneof(fc.integer(), fc.string()) → int or string
→ Express "any input is possible".
Edge cases (empty array, max int, "", NaN, ...) included by default.Shrinker — Smallest Failing Input
On failure, the generator searches smaller inputs that still fail.
First fail: arr = [42, 17, 88, -3, 1024] → reverse(reverse) broken
shrink 1: arr = [42, 17, 88, -3] → still fails?
shrink 2: arr = [42, 17, 88] → still fails?
shrink 3: arr = [42, 17] → fails!
shrink 4: arr = [42] → passes (length 1 is fine)
shrink 5: arr = [17] → passes
shrink 6: arr = [0, 0] → fails!
shrink 7: arr = [] → passes
→ Minimal failing input: [0, 0]
Suggests "duplicate elements" is the cause
→ Huge debugging value. [0, 0] is far faster to reason about than
the random [42, 17, 88, -3, 1024].Bug Types Only This Catches
1. Edge Cases (Empty, 0, Max, NaN)
function discount(items) {
return items.reduce((sum, i) => sum + i.price, 0) * 0.9;
}
example-based: [{price:100}, {price:200}] → 270 ✓
property-based finds:
items = [] → 0 * 0.9 = 0 (intended?)
items = [{price: NaN}] → NaN ← bug!
items = [{price: Infinity}] → Infinity ← edge2. Roundtrip Failure
function escapeQuotes(s) { return s.replace(/'/g, "\\'"); }
function unescapeQuotes(s) { return s.replace(/\\'/g, "'"); }
property: unescape(escape(s)) === s
Found: s = "\\'" → escape: "\\\\'" → unescape: "\\'" ✗
(existing backslash not handled)3. Concurrent / State-Machine Bugs
fast-check / Hypothesis state-machine testing:
generate arbitrary method-call sequences → verify invariants.
E.g. cache.set(k, v) + cache.get(k) + cache.evict(k) in random order
property: get(k) === last set v (or undefined after evict)
→ Sequences you'd never try manually (set k1, evict k2, get k1) are auto-tried.Libraries
| Language | Library | Notes |
|---|---|---|
| Haskell (origin) | QuickCheck (1999) | The starting point of property-based |
| Python | Hypothesis | Most mature, strong stateful |
| JavaScript / TypeScript | fast-check | Most active, Jest/Vitest integration |
| Rust | proptest, quickcheck | proptest is the modern standard |
| Java / Kotlin | jqwik, Kotest property | |
| Scala | ScalaCheck | Port of Haskell QuickCheck |
| Go | testing/quick (stdlib), gopter | stdlib's quick is bare-bones |
Common Pitfalls
- Property-test everything — explicit examples read better in many cases (regression tests). Complementary.
- Property too weak — trivial like `result !== null`. Roundtrip / invariant / equivalence are the valuable ones.
- Over-constrained generator — misses intended edge cases. Try fc.anything() or wide ranges.
- Test time explodes — default 100 runs × 50 tests = slow CI. Property only on core paths, examples for the rest.
- No seed — others can't reproduce a failure. Print the seed on fail + add a regression test with that seed.
Wrap-up
Property-based testing — instead of writing many examples by hand, declare a universal property of the inputs. Generator + shrinker auto-discover edge cases + produce minimal repros.
Practical: not every test should be property-based. Add property tests to core functions (parsers / serializers / algorithms); keep examples for the rest. The stateful-machine support in QuickCheck / Hypothesis / fast-check is also valuable.