What Is Property-Based Testing? β Definition and How It Works
Learn how property-based testing generates inputs to falsify invariants, and which properties suit parsers and pipelines.
Property-based testing states a property that should hold for all inputs, then generates many inputs trying to falsify it. Where example-based tests check cases you thought of, this searches for the ones you did not.
1. The Shift in What You Write
Example-based: given this input, expect that output.
Property-based: for any input of this shape, this relationship holds.
from hypothesis import given, strategies as st
@given(st.text())
def test_normalise_is_idempotent(s):
once = normalise(s)
assert normalise(once) == once
You never name an input. The framework generates hundreds, including the ones nobody would think to write: empty strings, lone combining characters, zero-width spaces, strings of only whitespace.
2. Shrinking Is What Makes It Practical
When a property fails, the framework shrinks the failing input to the smallest example that still fails.
That is the feature that turns a random failure into a usable bug report. Instead of a 400-character string of mixed scripts, you get "Μ" β a lone combining accent β and the cause is immediately obvious.
3. Properties Worth Testing in Collection Code
| Property | Statement |
|---|---|
| Idempotence | f(f(x)) == f(x) β normalisation, cleaning, URL canonicalisation |
| Round-trip | parse(serialise(x)) == x β serialisation, encoding |
| Invariance | Output length, count, or ordering constraints always hold |
| Never raises | Parsing arbitrary bytes returns a result or a defined error, never a crash |
| Monotonicity | More input never produces fewer records |
| Equivalence | Two implementations agree β a fast path and a reference path |
4. The Highest-Value Property for Parsers
For anything parsing untrusted input, this one:
@given(st.binary())
def test_parser_never_crashes(data):
try:
parse_page(data)
except ParseError:
pass # a defined failure is fine
A parser fed arbitrary bytes must not raise an undefined exception. Real collection meets truncated responses, wrong encodings, binary served as HTML, and challenge pages β and a crash on any of them takes down a pipeline.
Property-based testing finds those inputs far faster than production does.
5. Equivalence Testing Is Underused
Where you replace a parser or optimise a normaliser, assert the new implementation agrees with the old:
@given(st.text())
def test_new_matches_old(s):
assert normalise_fast(s) == normalise_reference(s)
That converts "I think this refactor is safe" into evidence, and it is one of the cheapest ways to gain confidence in a rewrite.
6. Custom Strategies for Realistic Inputs
Generic text finds encoding bugs. Domain-shaped input finds domain bugs:
prices = st.one_of(
st.just(""), st.just("β"), st.just("Free"),
st.from_regex(r"\$\d{1,4}\.\d{2}", fullmatch=True),
st.from_regex(r"\d{1,3}(,\d{3})*,\d{2} β¬", fullmatch=True),
st.from_regex(r"(?:USD|EUR|GBP) ?\d+(\.\d+)?", fullmatch=True),
)
@given(prices)
def test_price_parse_never_lies(s):
out = parse_price(s)
assert out is None or (isinstance(out, int) and out >= 0)
out is None or valid is the useful shape: the function may decline to parse, but it must never return a wrong-typed or negative value.
7. Limitations
- It tests properties, not correctness. A property can hold while the output is wrong.
- Properties are hard to find for some code; not everything has a clean invariant.
- Slower than example tests, so it belongs in CI rather than in a tight edit loop.
- Flaky-looking if the code is nondeterministic β fix that first.
- It does not replace fixtures. Real captured pages still test real structure.
8. Summary
Property-based testing states invariants and generates inputs to falsify them, shrinking any failure to a minimal example that makes the cause obvious.
For collection code the highest-value property is that a parser fed arbitrary bytes never raises an undefined error, since truncated, misencoded, and binary responses are routine β and equivalence properties turn a risky refactor into a verified one, alongside rather than instead of fixture tests.