AnyLearn
All lessons
Programmingintermediate

Property-Based Testing: Assert the Law, Not the Example

An example-based test checks the cases you thought of, which is exactly the set that excludes your bugs. Property-based testing inverts it: state a law the code must obey for all inputs, let the machine hunt for a counterexample, and let it shrink that counterexample to something you can read. This lesson covers the property patterns, generators, shrinking, and where the technique stops fitting.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 7

The blind spot in every example you write

An example-based test says: for this input, expect that output. It is precise, readable, and limited in a specific way. The examples come from your head, and your head is the same instrument that wrote the bug.

The cases people habitually write are the ones they already reasoned about. The cases that break software are the ones nobody reasoned about: the empty collection, the string with a combining character, the value exactly at the boundary, the negative zero, the input arriving twice, the list already sorted, the timestamp during a leap second.

Key idea: example-based testing samples the input space at points you chose. Since you chose them by thinking, and the bug survived your thinking, the sample is biased away from your bugs by construction.

Property-based testing changes the shape of the claim. Instead of asserting an output for an input, you assert a law that must hold for every input, then hand the search for a violation to a machine that has no preconceptions about which inputs are interesting.

Full lesson text

All 7 steps on one page, for reading, reference, and search.

Show

1. The blind spot in every example you write

An example-based test says: for this input, expect that output. It is precise, readable, and limited in a specific way. The examples come from your head, and your head is the same instrument that wrote the bug.

The cases people habitually write are the ones they already reasoned about. The cases that break software are the ones nobody reasoned about: the empty collection, the string with a combining character, the value exactly at the boundary, the negative zero, the input arriving twice, the list already sorted, the timestamp during a leap second.

Key idea: example-based testing samples the input space at points you chose. Since you chose them by thinking, and the bug survived your thinking, the sample is biased away from your bugs by construction.

Property-based testing changes the shape of the claim. Instead of asserting an output for an input, you assert a law that must hold for every input, then hand the search for a violation to a machine that has no preconceptions about which inputs are interesting.

2. What a property looks like in code

The mechanics are ordinary. You declare the shape of valid inputs, and write an assertion that must hold for all of them.

from hypothesis import given, strategies as st

# Example-based: one case you thought of
def test_roundtrip_example():
    assert decode(encode({"name": "Ada"})) == {"name": "Ada"}

# Property-based: the law, for every dict of this shape
@given(st.dictionaries(st.text(), st.integers()))
def test_roundtrip(payload):
    assert decode(encode(payload)) == payload

The framework, here Hypothesis for Python, generates hundreds of dictionaries: empty ones, ones with empty-string keys, ones with astral-plane characters, ones with keys that differ only by Unicode normalisation, ones with integers beyond 64 bits. Each is fed through the same assertion.

The example test passes forever with a serialiser that mangles empty keys. The property test finds it on the first run, because "a dictionary with an empty-string key" is an unremarkable member of the space it was told to explore, and an exotic thought for a person writing examples.

The idea and the first implementation come from QuickCheck, by Koen Claessen and John Hughes, published at ICFP 2000 and later given that conference's most influential paper award.

3. The property patterns worth memorising

The hard part is never the tooling. It is answering "what is true about this function for every input?" Five patterns cover most real cases.

PatternThe lawFits
Roundtripdecode(encode(x)) == xSerialisers, parsers, compression, migrations
InvariantSome property always holds of the outputSorting: output is ordered and a permutation of the input
Idempotencef(f(x)) == f(x)Normalisation, deduplication, retries, migrations
Oraclefast(x) == obvious_but_slow(x)Optimised code with a naive reference version
MetamorphicRelated inputs give related outputsSearch: adding a filter never grows the result set

The oracle pattern deserves emphasis because it is the one people forget they can use. When you optimise a function, the previous implementation becomes a free oracle: keep it, and assert the new one agrees with it on everything the generator can produce. The same applies when replacing a library, migrating a schema, or rewriting a service in another language.

Metamorphic properties are the escape hatch when you cannot compute the right answer at all. You may not know what a search should return for a random query, but you know that narrowing a filter must not add results, and that is a checkable law.

4. Shrinking: the feature that makes it usable

Random testing without shrinking is a bad experience. The counterexample it hands you is a random input, which means a 400-element list of arbitrary integers with the failure hidden somewhere inside it.

Shrinking is the search for a smaller input that still fails. When a property is violated, the framework repeatedly simplifies the counterexample, dropping list elements, moving integers toward zero, emptying strings, and re-runs the property on each candidate, keeping any that still fail. It stops when no further simplification reproduces the failure.

Predict first

A property over lists of integers fails on [847, -3391, 0, 12, 847, 22, ...] with 312 more elements. After shrinking, what does the framework typically report?

Implementations differ in how they do it: QuickCheck takes shrinkers defined per type, while Hypothesis shrinks the underlying random choices that produced the value, an approach called internal shrinking that also inherits shrinking automatically for composed generators. Hedgehog and RapidCheck use integrated shrinking, where generators carry their own shrink trees.

5. The loop the framework runs

Reading the cycle end to end makes the failure and success modes legible.

The generator draws an input from the declared space. The property runs on it. If it holds, the loop draws again, typically for a fixed number of examples, defaulting to the low hundreds in most frameworks. If the property fails, the framework enters the shrink loop, simplifying while the failure persists, and finally reports the minimal case.

One detail matters operationally: the failing example is usually saved to a local database and replayed first on subsequent runs. That turns a random find into a deterministic regression test automatically, which resolves the obvious objection that random tests are not reproducible. Frameworks also print the seed, so any run can be reproduced exactly.

The cost model is worth internalising too. A property test runs its body hundreds of times, so a property over a function taking 10 ms costs seconds, not milliseconds. That is affordable for pure logic and rarely affordable for anything touching a network, which is the main constraint on where the technique fits.

flowchart TD
A["Generator draws an input"] --> B["Run the property"]
B --> C["Holds: draw again, up to N examples"]
C --> A
B --> D["Fails: enter shrink loop"]
D --> E["Simplify while it still fails"]
E --> D
E --> F["Report minimal counterexample"]
F --> G["Save to database, replay first next run"]

6. Generators are where the real work is

A property is only as good as the inputs it sees, and default generators produce values that are valid for the type but often meaningless for the domain. A random string is not a valid email address; a random integer pair is not a valid date range.

Frameworks provide composition for this. You build a generator for your domain from primitives, adding constraints and derived fields:

@st.composite
def bookings(draw):
    start = draw(st.datetimes(min_value=datetime(2020, 1, 1)))
    nights = draw(st.integers(min_value=1, max_value=30))
    return Booking(start=start, end=start + timedelta(days=nights))

@given(bookings())
def test_nightly_rate_never_exceeds_total(b):
    assert nightly_rate(b) * b.nights <= total_price(b) + 0.01

Two failure modes to watch for, both of which produce a test that passes while proving nothing:

  • Over-constrained generators. Filtering aggressively (.filter(lambda x: x > 0)) discards most draws, and frameworks eventually give up and report too few examples. Construct valid values directly instead of rejecting invalid ones.
  • The generator encoding the same assumption as the code. If your generator only produces well-formed input because your parser only handles well-formed input, the property is a tautology dressed as a test.

In practice: when a property finds nothing after several runs, suspect the generator before concluding the code is correct. Print a sample of the drawn values; the answer is usually visible immediately.

7. Where it fits, and where it does not

Property-based testing is a sharp tool with a specific shape, and pretending it is general purpose is how teams abandon it.

It fits best where a law exists and inputs are cheap to generate: parsers and serialisers, data structures, numeric and date logic, state machines, anything with a slow reference implementation, and any function whose bugs would live in inputs nobody imagines.

It fits badly where the expected output is a matter of judgement rather than law. There is no property that captures "the recommendation is good" or "the copy reads well". It also fits badly where each run is expensive, since hundreds of executions per test rules out live network calls, and where the only property you can state is a restatement of the implementation, which proves nothing beyond that the code equals itself.

Gotcha: property tests complement example tests, they do not replace them. Keep a handful of concrete examples for documentation value: a reader learns more from assert slugify("Hello World") == "hello-world" than from any law, and the law is what protects you from the input you never pictured. Most mature suites carry both, deliberately.

One connection worth drawing: this course's neighbour on formal verification proves properties hold for all inputs by mathematical argument. Property-based testing tests the same kind of claim by sampling. It is enormously cheaper, gives no guarantee, and finds real bugs on a Tuesday afternoon, which is why it is the version most software actually gets.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Why is example-based testing structurally biased away from your own bugs?
    • Examples run too few iterations to be statistically valid
    • Examples are usually written after the code, so they inherit its structure
    • The examples come from the same reasoning that produced the code, so cases you failed to consider are absent from the sample
    • Frameworks optimise example tests for speed rather than coverage
  2. Which property pattern applies when you have optimised a function and kept the old implementation?
    • Oracle: assert the fast version agrees with the obvious slow one on every generated input
    • Idempotence: assert applying it twice equals applying it once
    • Roundtrip: assert decoding an encoding returns the original
    • Metamorphic: assert related inputs produce related outputs
  3. What does shrinking do, and why does it decide adoption?
    • It reduces the number of generated examples to keep tests fast
    • It compresses the test database of past failures
    • It narrows the generator's range after each successful run
    • It simplifies a failing input while the failure persists, turning a random 300-element counterexample into a minimal one that often names the bug
  4. A property test passes consistently and finds nothing. What should you suspect first?
    • That the framework's example count is too high
    • The generator: over-constrained filters or a generator encoding the same assumptions as the code make the property a tautology
    • That shrinking is disabled
    • That the property needs to be split into several tests
  5. Where does property-based testing fit badly?
    • Parsers and serialisers, where roundtrip laws exist
    • Sorting and data structures, which have clear invariants
    • Date and numeric logic with awkward boundaries
    • Cases where correctness is a judgement call, or each execution is expensive, since the body runs hundreds of times per test

Related lessons

Programming
intermediate

Benchmarks That Hold Up, and Knowing When to Stop

A benchmark is an experiment, and most are badly designed enough to produce confident wrong answers. This lesson covers what a measurement must control to mean anything, the ways microbenchmarks lie including code the compiler deletes, how to catch regressions in continuous integration despite noisy machines, and how to recognise the point where optimising stops paying.

7 steps·~11 min
Programming
intermediate

Flakiness, Coverage, and Suites That Survive

A test suite is a product with a maintenance cost, and two forces decide whether it stays useful: flakiness, which destroys the signal, and the metrics teams use to judge it, which mostly measure the wrong thing. This lesson covers where flakiness comes from, the arithmetic that makes it fatal at scale, what coverage research actually found, and what to measure instead.

8 steps·~12 min
Programming
intermediate

Test Doubles: Isolation and What It Costs

Replacing a real dependency with a stand-in is what makes a unit test fast, deterministic, and able to reach states you cannot otherwise produce. It is also how a suite ends up green while the system is broken. This lesson covers the five kinds of double, when each is right, and the two failure modes that follow every team who reaches for mocks by reflex.

8 steps·~12 min
Programming
intermediate

What Is Worth Testing, and the Shape That Follows

Every test costs time to write, time to run, and time to maintain when the code moves. This lesson works out what that budget should buy: what each level of test can and cannot catch, why the pyramid has the shape it does, why some teams invert it, and the arithmetic that decides both questions before anyone argues about it.

8 steps·~12 min