Skip to content
← All articles

Property-Based Testing: Naming the Thing That Is Always True

The skill is identifying the property, not generating random input. The six-property taxonomy — round-trip, idempotence, invariance, metamorphic, oracle, never-crashes — with the trap that round-trip only ever compares a system with itself.

The pitch for Hypothesis is usually “it generates random inputs for you”, and that is the least interesting thing about it. Random input is a solved problem; you could write a generator in an afternoon. What you cannot write in an afternoon is the assertion.

An example-based test needs you to know the answer:

assert slugify("Hello World") == "hello-world"

You supplied the input and the output. The test can only ever check the cases you already thought about, which are — definitionally — the cases where you already understood the behaviour.

A property-based test needs you to know something true of every input:

@given(st.text())
def test_slug_is_url_safe(raw: str) -> None:
    assert set(slugify(raw)) <= set(string.ascii_lowercase + string.digits + "-")

Note what is missing: the expected output. You do not know what slugify returns for "Ünïcode™ – 2026". You know it must not contain a space. That is a weaker claim about one input and an infinitely stronger claim about the input space, and stating it is the entire skill.

The taxonomy

There are, in practice, six shapes. Almost every useful property is one of them, and going through the list against a function you are stuck on works surprisingly often.

Round-trip. decode(encode(x)) == x. The archetype for anything with an inverse — serialisers, parsers, compressors, encryption, schema migrations up and down. It is the first property anyone finds and it has a specific blind spot, discussed below.

Idempotence. f(f(x)) == f(x). True of normalisation, sorting, deduplication, path canonicalisation, strip, and any migration you might have to re-run after a failed deploy. The last one is the reason it matters operationally: an idempotent migration can be retried, a non-idempotent one needs a human.

Invariance. Something is preserved by the transformation. Sorting preserves multiset contents and length. A rebalancing operation preserves the total. A run-length encoding preserves the sum of the run counts. Invariants are usually the cheapest property to state and the most likely to catch an off-by-one, because off-by-one errors are almost always conservation violations.

Metamorphic relations. Two inputs whose outputs must relate, even though you cannot compute either. search(query) and search(query + " " + extra) must return a subset. price(n + 1) >= price(n). distance(a, b) == distance(b, a). This is the one people never think of, and it is the one that works for functions whose output you genuinely cannot predict — recommenders, rankers, optimisers, anything statistical.

Oracle comparison. The fast implementation agrees with a slow, obviously-correct reference. This is the property to reach for whenever you optimise something that already worked: keep the naive version, mark it private, and assert agreement. It converts “I hope the fast one is right” into a checkable claim.

Never crashes. The weakest, and still worth having on any parser or deserialiser that touches input you did not create. @given(st.binary()) against your protocol decoder, asserting only that it raises ProtocolError and never IndexError, KeyError or RecursionError, is often the first property to find a bug.

💡A codec passes a round-trip property on a hundred thousand generated inputs. What class of bug is still guaranteed to be invisible to it, and which property catches it? click to reveal

Anything where the encoder and decoder are wrong in matching ways.

Round-trip compares your system against itself. Suppose the encoder writes the run marker upper-cased and the decoder lower-cases it on the way back. decode(encode(x)) == x for every input in the universe. Length is preserved. Nothing crashes. The codec is internally perfect and produces bytes that no other implementation on earth will accept — which you discover when the partner integration rejects the file, not when the suite runs.

The general shape: round-trip verifies that a transformation is invertible by you. It says nothing about whether the intermediate form is correct, and the intermediate form is the part that leaves your process.

The property that catches it is the oracle: compare the encoder’s output, byte for byte, against a slow reference implementation of the actual specification. If there is no reference, a fixed corpus of externally-produced encodings serves the same purpose — that is what a “golden file” test is for, and it is one of the few good uses of one.

This generalises past codecs. Any time your test’s expected value is computed by the same code as the actual value, you have a tautology with extra steps. That is also why assert render(x) == f"value={x}" passes on every version of Python and detects nothing.

Shrinking is the feature

Random testing without shrinking is nearly useless in practice, and this is the part people underestimate.

When a property fails, Hypothesis does not hand you the 4,000-character string that broke it. It searches for the simplest input with the same failure — shortest, then smallest, then earliest in the alphabet — and reports that. A failure that arrives as

Falsifying example: test_slug_is_url_safe(raw='\x85')

is a bug report. The same failure reported as the original random string is a puzzle, and most people would close the ticket.

It also caches failures. Once Hypothesis has found a falsifying example, it replays it first on subsequent runs, so the test is deterministic after the first failure. That matters for CI: the shrunk example becomes the regression test, and adding it explicitly with @example(...) pins it forever.

Where properties go wrong

The property restates the implementation. If your property is assert slugify(x) == x.lower().replace(" ", "-") you have written the function twice and tested that Python is deterministic. A good property is a weaker claim than the implementation — that is what makes it independent of it.

The generator is too narrow. st.text(alphabet=string.ascii_lowercase) will never produce the character that breaks your normaliser. The default st.text() includes surrogates, combining characters, and things your terminal cannot render, and that is the point.

The generator is too wide. If half your generated inputs get filtered out by assume(...), Hypothesis is spending its budget generating garbage. Build the constraint into the strategy — st.integers(min_value=1) rather than assume(n > 0).

The property is vacuous. A property that holds because the precondition is never satisfied passes forever. Hypothesis warns about heavy filtering for exactly this reason.

💡Your team has one property-based test for a JSON serialiser and it has never failed in eighteen months. Is that a well-tested serialiser, or a useless test? click to reveal

Not enough information — and the specific thing you cannot tell from “it never failed” is whether the generator ever produced anything interesting.

Two ways this goes wrong invisibly. If the strategy is st.dictionaries(st.text(), st.integers()), the serialiser has been tested on flat string-to-int maps for eighteen months. It has never seen a nested structure, a float, a None, a key that needs escaping, or a value at the edge of the integer range. The property is fine; the input distribution is a tiny neighbourhood of the trivial case.

If the property is round-trip only, see above: it cannot see any bug the deserialiser shares.

There is a diagnostic for the first problem and you should use it. hypothesis.event() and --hypothesis-show-statistics report what the generator actually produced — how many examples, how many were filtered, and any events you tagged. Instrument the property with event("nested" if is_nested(value) else "flat") and read the histogram. If 98% of examples are flat, you know exactly what to fix, and it is not the assertion.

The honest answer to the original question is usually: it is a real test of a narrow slice, and the value it delivered was mostly at the moment it was written, when someone had to articulate what was always true. That articulation is worth the test on its own. But a property that has never failed and has never been re-examined is a claim nobody has checked in eighteen months, which is the same category of thing as an unreviewed comment.

Where to start

Do not convert your suite. Pick one function with a clear contract — a parser, a normaliser, a serialiser, a pricing rule — and write two properties for it: one from the list above that is obviously true, and one oracle against the dumbest possible implementation.

In most codebases the second one finds something within an hour, and it will be an input nobody would have thought to write down. That is the value: not more tests, but tests of the region of the input space where your intuition ran out.