Skip to content

Property-Based Testing

A property-based test does not check one hard-coded example. You state a property — an invariant that must hold for all valid inputs — and the framework generates hundreds of random inputs, hunting for a counterexample.

When it finds one, it shrinks it to the smallest failing input, so you get [] or 0 instead of a 200-character string.

property + generator
│
▼
┌─────────────┐ pass ┌──────────┐
│ generate N │─────────►│ done │
│ inputs │ └──────────┘
└──────┬──────┘
│ fail
▼
┌─────────────┐
│ shrink to │ → minimal counterexample
│ smallest │
└─────────────┘

Example properties: reverse(reverse(xs)) == xs, sorting is idempotent, a round-trip encode/decode returns the original, length(concat(a, b)) == length(a) + length(b).

Use it for

  • Pure functions with a checkable invariant (parsers, codecs, sorters, math)
  • Round-trip properties: encode/decode, serialize/deserialize, parse/print
  • Finding edge cases you would never write by hand (NaN, empty, unicode, overflow)
  • Complementing example-based tests, not replacing them

Avoid it when

  • The expected output is just as complex as the input (no oracle to compare against)
  • The system is stateful, slow or non-deterministic (prefer model-based or a real harness)
  • You cannot describe a generator for valid inputs — garbage in, garbage out
  • A handful of examples already covers the risk
Tool Ecosystem Notes
fast-check JS/TS Rich arbitraries, integrated with Vitest/Jest
Hypothesis Python Best-in-class shrinking; @given, strategies
jqwik Java/JVM JUnit 5 platform engine; @Property, @ForAll
proptest Rust Generator + shrinker, proptest! macro
gopter Go Property testing on top of testing
QuickCheck Haskell The original that inspired them all
FsCheck / CsCheck .NET Idiomatic properties for C# and F#
QuickTheories Java Lightweight alternative to jqwik
Terminal window
npm install -D fast-check vitest
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
import { encode, decode } from './codec';
describe('codec', () => {
it('round-trips any string', () => {
fc.assert(
fc.property(fc.string(), (s) => {
expect(decode(encode(s))).toBe(s);
}),
);
});
});
  • Start from a real example. Write the example-based test first, then generalise it into a property: it is the “most valuable first test”.
  • Keep generators small but honest. A generator that only produces valid input is fine; one that produces nothing useful tests nothing.
  • Do not assert the exact same logic you are testing. A tautological property (f(x) == f(x)) always passes and proves nothing.
  • Seed and reproduce. Log the generated seed so a rare failure can be replayed deterministically in CI.
  • Watch runtime. Randomised tests are slower than examples — bound the number of runs and keep the property pure.
  • Treat every counterexample as a gift. Add it as an explicit regression example once fixed.

Self-assessment quiz

Pick one answer per question, then press Check answers.

  1. What does a property-based test assert?
  2. What is shrinking?
  3. Which of these is a classic property to test?
  4. When is a property-based test a poor fit?
  5. Why should you log and pin the random seed?