Skip to content
Back to Quality Assurance

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 it

Example

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.