Exercises — Property-based testing
Before we start, one word we will lean on constantly:
Level 1 — Recognition
Exercise 1.1
For each snippet, say whether it is an example-based test or a property-based test, and why.
# (a)
assert sort([3,1,2]) == [1,2,3]
# (b)
@given(lists(integers()))
def test(xs):
assert sort(sort(xs)) == sort(xs)Recall Solution
- (a) Example-based. It fixes ONE input
[3,1,2]and asserts ONE hardcoded output. It says nothing about any other list. - (b) Property-based. The
@given(lists(integers()))decorator asks the framework to generate many random lists, and the assertionsort(sort(xs)) == sort(xs)is a rule that must hold for all of them. This is the idempotence pattern: sorting an already-sorted list changes nothing.
Exercise 1.2
Match each pattern name to its formula.
| Name | Formula |
|---|---|
| A. Round-trip | 1. |
| B. Idempotence | 2. |
| C. Invariant | 3. |
Recall Solution
- A → 3. Round-trip: undoes , so encoding then decoding returns the original.
- B → 1. Idempotence: applying twice equals applying once.
- C → 2. Invariant: sorting reorders but preserves the count of elements. Where are Oracle and Metamorphic? The mnemonic RIIOM = Round-trip, Idempotence, Invariant, Oracle, Metamorphic has five patterns, but only three fit into a single self-contained formula. The last two need extra machinery and so appear later on this page:
- Oracle compares against a second, trusted function — — so it needs a reference implementation (see Exercise 2.3).
- Metamorphic relates two different inputs whose outputs must be related — so it needs a pair of inputs, not one (see Exercise 4.2). They are omitted from this three-row table on purpose, not by mistake.
Level 2 — Application
Exercise 2.1
You have to_json and from_json. Write the property that captures their relationship, name the pattern, and say precisely what set of inputs x ranges over.
Recall Solution
Pattern: Round-trip (inverse functions), because from_json is defined to undo to_json.
Here the domain of is stated explicitly: it is the set of values to_json is contracted to accept — numbers, strings, booleans, None, and lists/dicts built from those. The symbol means "for all", and it ranges only over that set, not over every Python object.
@given(json_values()) # generate whatever `to_json` accepts
def test_json_roundtrip(x):
assert from_json(to_json(x)) == xGenerator note: it must produce valid inputs for to_json — exactly the JSON-serialisable values above. See Hypothesis (Python) strategies. If you feed it a Python set (not JSON-representable), you'd get a false failure that is your generator's fault, not the code's.
Exercise 2.2
A function dedupe(xs) removes duplicate elements. Write two distinct properties for it (name each pattern).
Recall Solution
Throughout, ranges over all finite lists of integers — the full input contract of dedupe.
Property 1 — Idempotence: de-duplicating an already-deduped list changes nothing.
Property 2 — Invariant (membership preserved): every element of the input still appears in the output, and no new element is invented.
@given(lists(integers()))
def test_dedupe_idempotent(xs):
assert dedupe(dedupe(xs)) == dedupe(xs)
@given(lists(integers()))
def test_dedupe_preserves_membership(xs):
assert set(dedupe(xs)) == set(xs)Exercise 2.3
You wrote a fast fast_median(xs). A slow, obviously-correct reference exists. Write the oracle property, handle both odd- and even-length lists, and name the pattern.
Recall Solution
First, a notation note we will reuse: means floor — round down to the nearest whole number (so ). In Python the operator // is integer / floor division: 5 // 2 == 2. So len(xs)//2 and are the same thing written in two notations. We use the words interchangeably from here on because they are literally equal for non-negative lengths.
Pattern: Oracle — compare the fast version against a trusted slow reference.
Odd length ( is odd): there is a single middle element after sorting. Example: for , sorted is , and , so the reference picks index , which is .
Even length ( is even): there is no single middle — two elements straddle the centre, and the median is their average. This is the natural edge case, and skipping it would leave half of all inputs untested. Example: for , sorted is , , so the median is .
def slow_median(xs):
s = sorted(xs)
n = len(s)
if n % 2 == 1:
return float(s[n // 2])
return (s[n // 2 - 1] + s[n // 2]) / 2
@given(lists(integers(), min_size=1))
def test_median_oracle(xs):
assert fast_median(xs) == slow_median(xs)Why min_size=1: the median is undefined for the empty list, so we exclude it from the domain. We now cover every non-empty list — both parities.
Level 3 — Analysis
Exercise 3.1
This test fails. Explain why, and whether the bug is in the code or the test.
def encode(s): return s.strip() # remove leading/trailing spaces
def decode(s): return s # identity
@given(text())
def test_roundtrip(s):
assert decode(encode(s)) == sRecall Solution
The bug is in the property (the test), not necessarily the code. encode strips whitespace, so it is lossy — it throws information away. For an input like s = " a ", encode(s) == "a", and decode("a") == "a" != " a ".
A round-trip property is only valid when is injective / lossless. Stripping is not lossless, so this pattern simply does not apply.
Correct property (idempotence of the lossy step instead):
Stripping twice equals stripping once — that is true here.
Exercise 3.2
Given the failing input [3, 1, 4, 1, 5, 9, 2, 6] for a sort bug, list a plausible sequence of shrink steps a framework might try, justify each shrink decision, and state the stopping condition. Assume the bug only triggers when a duplicate value exists.
Reading the figure below. The figure is a top-to-bottom ladder of candidate inputs. Each box is one candidate list; the vertical axis has no numbers — it just means "lower = simpler" (fewer elements, values closer to 0). Magenta boxes still fail the test; the violet box passes. The solid orange arrows are accepted shrink steps (the framework keeps moving down because the smaller candidate still fails); the dashed violet arrow is a rejected step (the candidate [1] passed, so it is discarded and we back up). The bottom magenta box [0,0] is the final minimal counterexample the framework reports.

Recall Solution
Shrinking is a greedy descent toward the simplest still-failing input (see figure). The framework tries the cheapest simplifications first — dropping elements shrinks structure fastest — and only switches to shrinking the values once the list is already as short as it can be. A plausible path:
[3,1,4,1,5,9,2,6]— fails. Why drop elements first? Fewer elements is the biggest simplification, so the greedy search tries that before touching values.[3,1,4,1,5]— dropped the tail; the duplicate1survives, still fails → keep (a smaller failing input is strictly better).[1,4,1]— dropped more, keeping both copies of the duplicate because we suspect duplicates matter; still fails → keep.[1,1]— dropped the non-duplicate4; still fails → keep. We now cannot drop further without destroying the duplicate.[1]— dropping one copy leaves no duplicate, so the bug's trigger is gone → passes → discard, restore[1,1]. Why this teaches us something: the pass tells the framework the duplicate is essential, so it stops dropping and switches strategy.- Now shrink values toward 0:
[0,0]— still a duplicate, still fails → keep. Values can't get simpler than0. Stopping condition: no simpler candidate (fewer elements, or values closer to 0) still fails. Report[0,0]. That minimal counterexample points straight at "breaks when a duplicate exists." See Fuzzing for the raw-generation cousin without this shrink step.
Level 4 — Synthesis
Exercise 4.1
Design a complete property test suite for a function parse(s) that parses a string into a number and render(n) that turns a number back into its canonical string. Cover round-trip in both directions and explain why one direction is trickier.
Recall Solution
Direction 1 — parse ∘ render (starting from numbers): this is the clean round-trip.
@given(integers())
def test_render_then_parse(n):
assert parse(render(n)) == nDirection 2 — render ∘ parse (starting from strings): trickier because many strings map to the same number ("007", "+7", "7" all parse to 7). So render(parse(s)) == s is false in general — render produces only the canonical form.
The correct property is that render ∘ parse is idempotent. Here is the "why", step by step:
- Let be "canonicalise the string ".
- Applying once turns any valid form (
"007") into its canonical form ("7"). - Applying again to an already-canonical string must return it unchanged — canonicalising the canonical form does nothing new.
- That "second application changes nothing" is exactly the idempotence law . Unwrapping gives the nested form:
The outer
render(parse(...))is applied to the innerrender(parse(s)), which is — so the whole line reads . It is not a magic string of functions; it is idempotence with spelled out.
@given(text())
def test_render_parse_idempotent(s):
assume(is_parseable(s)) # only valid numeric strings
canon = render(parse(s))
assert render(parse(canon)) == canonWhy assume: we filter out non-numeric strings so we test only inputs the contract covers. This mirrors Test-driven development: properties describe the intended contract first.
Exercise 4.2
Write a metamorphic property for a search(query, documents) function without knowing the exact ranking algorithm.
Recall Solution
Metamorphic = relate two related inputs whose outputs must be related, even when the exact output is unknown. Property: adding a document that does not contain the query must not change the results already returned.
@given(query=text(min_size=1), docs=lists(text()), irrelevant=text())
def test_irrelevant_doc_doesnt_change_results(query, docs, irrelevant):
assume(query not in irrelevant)
base = search(query, docs)
extended = search(query, docs + [irrelevant])
assert [r for r in extended if r != irrelevant] == baseWe never assert the actual ranking — only that an irrelevant addition is invisible to the query. That is the power of metamorphic testing (the M in RIIOM, and the reason it could not fit the single-formula table of Exercise 1.2).
Level 5 — Mastery
Exercise 5.1
A property test passes 10,000 random cases. A colleague says "the function is now proven correct." Give a precise argument for why this is false, and describe the smallest change to the generator that could reveal a hidden bug in a function f(x) = 1 / (x - 42) tested with assert isinstance(f(x), float) over integers().
Recall Solution
Why not a proof: PBT is sampling from an input space. For integers() that space is countably infinite — infinitely many values, but you still visit only finitely many (10,000) of them. Passing 10,000 cases says nothing about the infinitely many untested integers. It raises confidence, never certainty. (Contrast with a mathematical proof, or an exhaustive check over a genuinely finite domain.)
The hidden bug: f(42) divides by zero → ZeroDivisionError. If the framework's random draws from integers() never happened to hit exactly 42, all 10,000 cases pass while the bug lurks.
Smallest generator change to expose it: bias the generator toward the boundary value, e.g. integers(min_value=40, max_value=44) or add 42 explicitly via sampled_from([42]) mixed in. Frameworks like Hypothesis (Python) already nudge generators toward such "magic" boundary values, which is why it usually would find 42 — but a naive uniform generator over a huge range might not. This is the everyday argument for combining PBT with hand-written boundary example tests.
Exercise 5.2
For a function tested with a property, explain why hidden global randomness inside f breaks shrinking, and give the fix. Then classify: is f a pure function?
Recall Solution
Why shrinking breaks: shrinking assumes that if input fails, re-running still fails, so it can safely test simpler inputs and trust the pass/fail signal. If f reads a hidden global RNG, then re-running the same input may pass one moment and fail the next. The framework tries a simpler candidate, it passes by luck, and the shrinker wrongly discards it — the reported "minimal" case may not even reproduce.
Fix: make behaviour deterministic per input — seed the RNG from the input, inject the RNG as a parameter, or make the property tolerant (assert a range, not an exact value). Frameworks also replay the failing seed so a genuine failure can be reproduced.
Classification: a function with hidden global randomness is not a pure function — it has a hidden dependency (the RNG state) and is not referentially transparent. Pure functions are the ideal target for PBT precisely because same-input-same-output makes shrinking reliable.
Exercise 5.3
Numeric drill. A shrinker halves a failing integer toward 0 while it still fails, using floor division (Python x // 2). The bug triggers for all integers with absolute value . Starting from :
(a) list the sequence of accepted (still-failing) shrink values;
(b) state the final reported minimum under pure halving;
(c) explain what a smarter shrinker would report and why plain halving misses it.
Recall Solution
(a) The accepted chain. Halve while the result still fails (); reject the first halving that lands below the threshold:
Checking each: all have → fail → accepted. The next candidate is , but → it passes → discarded.
(b) Reported minimum. Pure halving cannot step below the last still-failing value it holds, so it reports .
(c) What a smarter shrinker does. After halving stalls at 13, a finer strategy would linearly probe the still-failing region downward: all fail, but passes — so the true minimal failing value is . Plain halving misses it because its step size (halving) is too coarse near the boundary; it lands on 6 (below threshold) and stops, getting stuck at a local, not global, minimum. This is the everyday lesson: shrinking gives a much simpler, usually near-minimal, counterexample — not a provably minimal one.
Connections
- Unit testing — pair boundary example tests with PBT (Exercise 5.1).
- Test-driven development — properties encode the contract before implementation (Exercise 4.1).
- Invariants and contracts — every property here is a runtime-checkable invariant.
- Fuzzing — generation without structured assertions or shrinking (Exercise 3.2).
- Pure functions — the reliable target for shrinking (Exercise 5.2).
- Hypothesis (Python) · QuickCheck (Haskell) — frameworks implementing all of the above.