Skip to content
yutils

How Property-Based Testing Actually Works

Example-based vs property-based, generators + shrinkers (why the failing input becomes [] not [42, 17, 88]), QuickCheck / Hypothesis / fast-check, and the kinds of bugs only property tests find.

~9 min read

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)    // commutativity

Generators — 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     ← edge

2. 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

LanguageLibraryNotes
Haskell (origin)QuickCheck (1999)The starting point of property-based
PythonHypothesisMost mature, strong stateful
JavaScript / TypeScriptfast-checkMost active, Jest/Vitest integration
Rustproptest, quickcheckproptest is the modern standard
Java / Kotlinjqwik, Kotest property
ScalaScalaCheckPort of Haskell QuickCheck
Gotesting/quick (stdlib), gopterstdlib'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.

Back to guides