This page is the hands-on companion to Property-based testing . The parent note taught you what properties are (the RIIOM patterns) and why shrinking matters. Here we walk every case class a property test can face — one input class per worked example — so you never meet a scenario you have not already seen solved.
Everything here is written from zero. If a word looks new, it will be built before it is used.
Definition RIIOM — the five property patterns (recap)
RIIOM is just a memory hook for the five ways to state a property, from the parent note. Each letter names one pattern:
R ound-trip: a function's inverse undoes it, g ( f ( x )) = x (e.g. decode after encode).
I dempotence: doing it twice equals doing it once, f ( f ( x )) = f ( x ) (e.g. sort, dedupe).
I nvariant: some quantity is conserved, e.g. len ( sort ( x s )) = len ( x s ) .
O racle: the fast version agrees with a trusted slow reference, f fast ( x ) = f slow ( x ) .
M etamorphic: two related inputs give related outputs, e.g. sort ( x s ) = sort ( shuffle ( x s )) .
Every example below is tagged with the RIIOM letter it exercises, so you always know which pattern you are practising.
Definition Multiset — a "bag" that counts duplicates
A set ignores repeats: { 2 , 2 , 1 } as a set is just { 1 , 2 } . A multiset is a bag that remembers how many times each value appears, but still ignores order . We write it with the same braces { … } , so the multiset of [2,2,1] is { 1 , 2 , 2 } — two 2's and one 1. Below, "multiset unchanged" means same values with the same counts , regardless of order. This is exactly what sorting and reversing must preserve.
Intuition What "case class" means
When you test a function, the interesting moments are not random — they cluster. There is the normal middle, the empty input, the single input, the boundary (biggest/smallest allowed), the degenerate shape (all-equal, already-sorted), the value with a sign trap (negative, negative zero), and the infinite / non-terminating trap. Cover one representative of each and you have covered the "matrix". Everything else is a mix of these.
Below is the full grid this topic can throw at you. Each row is a cell — a distinct kind of input or twist. Every cell is claimed by at least one worked example further down.
Cell
What it stresses
Example that covers it
C1 · Normal middle
Property holds for typical inputs
Ex 1 (reverse round-trip)
C2 · Empty input
[], "", size 0 — division / min / max traps
Ex 2 (average crash)
C3 · Singleton
Exactly one element — many properties trivially true
Ex 3 (sort idempotence)
C4 · Sign trap
Negatives, negative zero, mixed signs
Ex 4 (abs round-trip)
C5 · Degenerate shape
All-equal / already-sorted / duplicates
Ex 5 (sort invariant)
C6 · Boundary / limit
Largest, smallest, float edges (inf, NaN, subnormal)
Ex 6 (float round-trip)
C7 · Oracle disagreement
Fast vs slow reference diverge
Ex 7 (custom vs builtin sort)
C8 · Real-world word problem
A property hides inside a story
Ex 8 (shopping cart total)
C9 · Exam-style twist
"Why did shrinking stop here?" reasoning
Ex 9 (shrink trace)
C10 · Non-termination trap
Generator/assume filters too much
Ex 10 (assume overuse)
Mnemonic Read the matrix top-to-bottom as a
checklist : middle, empty, one, sign, shape, edge, oracle, story, shrink, filter. If your test suite touches all ten, you have "every scenario".
Worked example Reverse is its own inverse
Statement. reverse takes a list and returns it back-to-front. Property to test:
reverse ( reverse ( x s )) = x s
Does this hold for a typical list like [4, 9, 1, 6]?
Forecast: Before reading on — will double-reversing give back exactly [4, 9, 1, 6]? Guess yes/no.
Steps.
Reverse once: [4, 9, 1, 6] → [6, 1, 9, 4].
Why this step? We apply f (the function under test) to expose what one call does.
Reverse again: [6, 1, 9, 4] → [4, 9, 1, 6].
Why this step? Applying f a second time is the "round-trip" — a reverse undoes a reverse, so this is the inverse-function pattern from the RIIOM list.
Compare to the original [4, 9, 1, 6]. They match, so the property holds for this input.
Why this step? A property is only interesting if it can fail; we must actually check equality, not assume it.
Verify: Length is preserved (4 in, 4 out) and the multiset { 4 , 9 , 1 , 6 } is unchanged — two independent sanity checks that agree. ✅
This is the "middle" cell: nothing weird, property just holds. The value of PBT is that the next nine examples are the inputs you would never have typed by hand.
Worked example The average that crashes on
[]
Statement. average(xs) = sum(xs) / len(xs). Property: the mean sits between the min and max,
min ( x s ) ≤ average ( x s ) ≤ max ( x s ) .
What happens on the empty list []?
Forecast: Guess: does the property fail, or does something worse happen before we can even check it?
Steps.
sum([]) = 0.
Why this step? The sum of nothing is the additive identity 0 — no elements to add.
len([]) = 0.
Why this step? We need the denominator; there are zero elements.
average([]) = 0 / 0 → ZeroDivisionError .
Why this step? Division by zero is undefined; the code throws before the assertion even runs.
Also min([]) and max([]) are themselves undefined — the property statement cannot be evaluated .
Why this step? This is the deeper lesson: on the empty input the property isn't false, it's ill-defined . That is a contract gap (see Invariants and contracts ).
Verify: In Python, 0 / 0 raises ZeroDivisionError exactly as our numerator/denominator (both 0 ) predict — so the crash is real, not a typo. The minimal failing input is already [] — nothing to shrink. Fix: either raise a clear domain error, or generate lists(integers(), min_size=1) so the empty case is declared invalid. ✅
Common mistake Treating the crash as a "test bug"
Why it feels right: "the property is fine, the framework fed garbage."
Why it's wrong: the empty list is a valid Python list . PBT correctly found that your function has no defined behaviour there. Decide the contract; don't blame the generator.
Worked example Idempotence is "free" for one element
Statement. Idempotence property for sort:
sort ( sort ( x s )) = sort ( x s ) .
Test it on the singleton [7].
Forecast: One element — do you expect this to be a strong test or a weak one?
Steps.
sort([7]) = [7].
Why this step? A one-element list is already sorted — nothing can be out of order.
sort(sort([7])) = sort([7]) = [7].
Why this step? Both sides collapse to [7], so the equality is trivially true.
Conclude: singletons pass idempotence for any sort , even a broken one.
Why this step? This is the trap of the singleton cell — the property holds vacuously , so it proves almost nothing. A good generator must reach longer lists to actually exercise sorting.
Verify: Same idempotence check on [3, 1, 2]: sort → [1,2,3], sort again → [1,2,3]. Equal, and now non-trivially so. ✅
Cell lesson: always ask "is my property trivially true for tiny inputs?" If yes, your confidence must come from the larger generated cases.
Worked example Absolute value is NOT round-trippable
Statement. Someone claims abs has an inverse neg_if_needed so that
neg_if_needed ( abs ( x )) = x for all x .
Here abs(x) strips the sign: abs ( − 5 ) = 5 , abs ( 5 ) = 5 . Is the round-trip property valid?
Forecast: Guess: can any function recover x from abs ( x ) ?
Steps.
Take x = 5 : abs ( 5 ) = 5 .
Why this step? Start with a positive to see the "happy path".
Take x = − 5 : abs ( − 5 ) = 5 — the same output as x = 5 .
Why this step? Two different inputs map to one output, so abs is not injective (not one-to-one).
Any inverse would have to turn the single value 5 back into both 5 and − 5 — impossible.
Why this step? An inverse must be a function (one output per input); a many-to-one map cannot be inverted. So the round-trip property is false by construction .
The right property here is an invariant , not a round-trip:
abs ( x ) ≥ 0 and abs ( x ) = abs ( − x ) .
Why this step? When a function loses information (the sign), switch from RIIOM's Round-trip to Invariant/Metamorphic .
Verify: abs ( − 5 ) = 5 = abs ( 5 ) confirms non-injectivity; abs ( − 5 ) ≥ 0 and abs ( − 5 ) = abs ( 5 ) confirm the replacement invariants. ✅
Intuition Sign-of-zero corner
abs(0) = 0 and there is no separate "negative zero" for Python integers, so the boundary x = 0 is safe here — but for floats , -0.0 exists and equals 0.0. Example 6 handles float edges.
Figure s01 — C5, sort length invariant. Blueprint bar chart. The horizontal axis lists four degenerate input lists ([5,5,5], [1,2,3], [3,2,1], [2,2,1]); the vertical axis is list length (element count) , gridded 0–4. For each input a cyan bar shows the input length and an amber bar shows the output length of a buggy dedupe-then-sort . The first three inputs stay at length 3 (correct). The fourth, [2,2,1], drops from 3 to 2 — the amber arrow points at this failing cell and reads "invariant FAILS, len 3 → 2". A legend names the two bar colours; the small text over each bar shows the actual list produced.
Worked example Sort preserves the multiset — even with all-equal / already-sorted inputs
Statement. Invariant property:
sort ( x s ) is a permutation of x s , len ( sort ( x s )) = len ( x s ) .
Test the degenerate shapes: all-equal [5,5,5], already-sorted [1,2,3], reverse-sorted [3,2,1], and duplicate-heavy [2,2,1]. (Recall a multiset counts duplicates but ignores order, from the definition near the top.)
Forecast: Guess which of these four is most likely to expose a bug in a naive sort.
Steps.
[5,5,5] → [5,5,5]. Length 3→3; multiset { 5 , 5 , 5 } unchanged.
Why this step? All-equal is where a buggy sort that drops duplicates would shrink to [5] — the invariant catches that.
[1,2,3] → [1,2,3]. Already sorted; nothing moves.
Why this step? Tests the "no-op" branch — some sorts special-case sorted input and skip logic.
[3,2,1] → [1,2,3]. Worst-case reordering.
Why this step? Maximum movement stresses the comparison logic.
[2,2,1] → [1,2,2]. Duplicates survive and are ordered.
Why this step? The amber arrow in the figure shows a buggy "dedupe-then-sort" would return [1,2] — length 3→2, invariant fails . That is the bug this cell exists to catch.
Verify: For each input, sorted(xs) in Python keeps length and is a permutation. [2,2,1] → [1,2,2], length 3, multiset { 1 , 2 , 2 } matches. ✅
Definition Two symbols this example needs:
repr and ∞
repr(x) is a built-in Python function that returns the text form of a value that is meant to be read back exactly — think of it as the concrete show in our show/parse round-trip. For floats, float(repr(x)) is designed to return the identical value, so repr and float(...) are a matched pair.
∞ (read "infinity") is not a normal number; it is the symbol for a value larger than every real number . Floating-point hardware has a special bit pattern for it, printed as "inf". It arises as a limiting value : as a positive quantity grows without bound, we label the limit ∞ . We include it because a serializer must handle this edge, not just ordinary numbers.
Worked example Round-trip through text, and the float edges that break it
Statement. For a serializer, the round-trip property is parse ( show ( x )) = x , where show turns a value into a string and parse reads it back. Here we use repr as show and float/int as parse. For integers this is clean; test the integer boundaries, then the tricky float cases: a normal float, infinity (∞ ), and NaN.
Forecast: Guess: does float(repr(0.1)) equal 0.1? And what about a round-trip of NaN?
Steps.
Integer boundary x = 0 : show(0)="0", parse("0")=int("0")=0. Round-trip holds.
Why this step? Zero is the classic boundary; verify it round-trips exactly.
Large integer x = 1 0 9 : parse(show(10**9)) = int("1000000000") = 10**9. Holds.
Why this step? Python ints are arbitrary precision, so no overflow — a genuine boundary that passes .
Normal float x = 0.1 : because repr is built to round-trip, float(repr(0.1)) == 0.1 is True .
Why this step? This is the real round-trip check for floats — the repr/float pair is a matched show/parse designed to recover the exact bits. Note this is different from the arithmetic trap 0.1 + 0.2 != 0.3: round-tripping one stored value is exact; adding two rounded values compounds error. Keep the two issues separate.
Infinity edge x = ∞ : show(float("inf"))="inf", parse("inf")=float("inf"), and inf == inf is True — round-trip holds, but only because we handled the "inf" spelling.
Why this step? Infinities are a boundary a naive serializer forgets; test them explicitly.
NaN edge x = NaN : parse(show(nan)) = float("nan"), but nan == nan is False .
Why this step? NaN ("not a number") is defined to be unequal to everything, including itself . So an equality-based round-trip property fails on NaN even when the serializer is correct. This is the deep float trap.
Subnormal edge x = 5 × 1 0 − 324 (smallest positive float): float(repr(x)) == x still True .
Why this step? Subnormals are the tiniest representable floats; confirm the repr/float pair survives the extreme boundary.
Fixes. For floats, replace exact == with: (a) a tolerance ∣ parse ( show ( x )) − x ∣ ≤ ε for arithmetic, and (b) a NaN-aware compare (math.isnan(a) and math.isnan(b), or treat NaN as its own class) for the round-trip.
Verify: int("0")==0, int("1000000000")==10**9, float(repr(0.1))==0.1, float("inf")==float("inf"), float("nan")==float("nan") is False , and float(repr(5e-324))==5e-324. ✅
Worked example My clever sort vs the trusted builtin
Statement. Oracle property: mysort ( x s ) = sorted ( x s ) where sorted is the trusted reference. Suppose mysort sorts by last digit only. Find an input where they disagree.
Forecast: Guess a 2-element list where "sort by last digit" gives a different answer than a real sort.
Steps.
Take [13, 2]. Real sort: sorted([13,2]) = [2, 13].
Why this step? The oracle gives the ground truth.
mysort sorts by last digit: last digit of 13 is 3, of 2 is 2. So it orders [2, 13].
Why this step? On this input they agree — a reminder that a single passing case proves nothing (echoes Ex 3).
Now take [13, 5]. Real sort: [5, 13]. mysort by last digit: 3 < 5, so it returns [13, 5].
Why this step? Here the buggy key (3) beats the correct order (13 > 5). Disagreement found.
Report the counterexample [13, 5]: expected [5,13], got [13,5].
Why this step? The oracle pattern's whole power is that we never wrote the expected output by hand — the trusted sorted produced it.
Verify: sorted([13,5]) == [5,13] while last-digit order gives [13,5]; the two lists differ. ✅
Worked example Shopping cart: total never negative, discount never exceeds subtotal
Statement. A cart has item prices and a discount. total = max(0, subtotal - discount). Property: for any non-negative prices and any non-negative discount,
0 ≤ total ≤ subtotal .
Story input: prices [100, 50, 20], discount 200.
Forecast: Discount (200) is bigger than the subtotal — guess the total.
Steps.
subtotal = 100 + 50 + 20 = 170 .
Why this step? Establish the quantity the discount acts on.
Raw difference = 170 − 200 = − 30 (negative!).
Why this step? Reveals the trap: a big discount would make the total go below zero , which no shop allows.
Clamp: total = max ( 0 , − 30 ) = 0 .
Why this step? The max(0, …) enforces the lower bound of the invariant. Now check the upper bound: 0 ≤ 170 . ✅
Try a normal case: prices [100,50,20], discount 30 → 170 − 30 = 140 , and 0 ≤ 140 ≤ 170 .
Why this step? Confirm the invariant holds in the middle case too, not just at the clamp.
Verify: max ( 0 , 170 − 200 ) = 0 and 0 ≤ 0 ≤ 170 ; also max ( 0 , 170 − 30 ) = 140 and 0 ≤ 140 ≤ 170 . Both satisfy the property. ✅
Cell lesson: business rules ("total can't be negative") are properties. This is the everyday face of Invariants and contracts .
Figure s02 — C9, shrinking staircase. Blueprint step plot. The horizontal axis is the shrink step (greedy descent, 0–4); the vertical axis is input complexity measured as element count (0–6, gridded). The cyan staircase starts at the first random failure [847,-3,901,0,-12] (complexity 5) and drops as the framework removes elements while the failure persists, reaching [-3] then [-1] (complexity 1). The amber arrow marks the moment the framework tries [0], which passes ("not negative"), so that candidate is rejected and the descent stops at the minimum [-1].
Worked example Trace the greedy shrink to its minimum
Statement. Consider a buggy average whose property fails whenever the list contains any negative element (an off-by-one sign bug in the guard). The framework's first random failure is [847, -3, 901, 0, -12]. Trace how shrinking converges to the minimal counterexample.
Forecast: Guess the smallest list that still triggers "contains a negative".
Steps.
Start: x 0 = [ 847 , − 3 , 901 , 0 , − 12 ] — fails (it has negatives).
Why this step? Shrinking always begins from the first random failure it stumbled on.
Try dropping elements (fewer = simpler): remove 847 → [-3, 901, 0, -12], still fails.
Why this step? Fewer elements is "simpler"; keep any drop that still fails .
Keep dropping non-negatives: [-3] still fails — one negative is enough to trigger the bug.
Why this step? Greedy descent — we only retreat to simpler inputs that preserve the failure.
Now shrink the value toward zero: − 3 → − 1 , still negative, still fails. Try − 1 → 0 : 0 is not negative → the property passes → reject this candidate and stop .
Why this step? Shrinking halts when no simpler candidate fails . Both "drop an element" and "move the value to 0" now pass, so the minimum is [-1].
Verify: The reported minimal counterexample is [-1]: length 1 (can't drop further and stay failing) and value − 1 (any closer to 0 is 0 , which passes). The figure's staircase shows the monotone descent from 5 elements down to [-1]. ✅
Recall Why greedy descent gives a
minimal (not necessarily global minimal) case
Shrinking is local: it stops at the first point where every neighbouring simplification passes. This is a local minimum of "complexity subject to still failing" — usually tiny and clear, which is all a debugger needs.
What does shrinking minimise? ::: Input complexity, subject to the constraint that the input still fails the property.
assume() that rejects almost everything
Statement. You want to test only prime inputs and write assume(is_prime(n)) for n drawn from integers(0, 10**9). Why can this stall the whole test run?
Forecast: Guess roughly what fraction of numbers near 1 0 9 are prime.
Steps.
assume(cond) tells the framework: "if cond is false, throw this input away and try another."
Why this step? Define the tool before using it — assume is a filter , not a generator.
The density of primes near a value N is about 1/ ln N (the Prime Number Theorem). For N = 1 0 9 , ln ( 1 0 9 ) ≈ 20.72 , so only about 1/20.72 ≈ 4.8% of random draws are prime.
Why this step? This quantifies the waste: ~95% of generated numbers get discarded, so the framework spends most of its effort producing rejects and may give up with a "too many filtered inputs" error — the run effectively fails to terminate usefully.
Fix: generate valid inputs directly with a custom strategy (e.g. sample from a precomputed prime list, or integers().map(nth_prime)), instead of filtering.
Why this step? A generator that only ever produces valid inputs has 0% waste — the parent note's "generators must produce valid inputs" rule, made concrete.
Verify: ln ( 1 0 9 ) = 9 ln 10 ≈ 9 × 2.302585 = 20.723 , and 1/20.723 ≈ 0.04826 — under 5%, confirming the heavy rejection rate. ✅
Common mistake Over-filtering with
assume
Why it feels right: "just skip the inputs I don't want."
Fix: if you reject more than a small fraction, replace the filter with a constructive generator . See Hypothesis (Python) custom strategies. Related idea: unfocused generation is closer to Fuzzing ; PBT wants structured valid inputs.
Recall Self-test: which example covered which cell?
Empty input crash ::: Example 2 (C2)
Singleton makes idempotence trivially true ::: Example 3 (C3)
Sign loss means no round-trip, use an invariant ::: Example 4 (C4)
All-equal / duplicates stress the sort invariant ::: Example 5 (C5)
Float NaN / infinity break equality round-trip ::: Example 6 (C6)
Trusted oracle exposes a wrong sort key ::: Example 7 (C7)
Real-world business rule as an invariant ::: Example 8 (C8)
Shrinking stops at a local minimum ::: Example 9 (C9)
Over-filtering with assume stalls the run ::: Example 10 (C10)
Every cell of The scenario matrix now has a worked, verified example. When you write your own property tests, run this checklist: middle, empty, one, sign, shape, edge, oracle, story, shrink, filter.
Property-based testing — this is its worked-examples child page.
Unit testing — Examples 2 and 6 show where a hand-written unit test complements a property.
Invariants and contracts — Examples 2 and 8 are contract decisions in disguise.
Hypothesis (Python) — assume, min_size, and custom strategies used in Ex 2 and 10.
QuickCheck (Haskell) — the original tool that popularised shrinking (Ex 9).
Fuzzing — Example 10 contrasts unstructured generation with valid generators.
Pure functions — every property here assumes deterministic, side-effect-free code.