Property-based, mutation & fuzzing
Beyond example-based tests: state invariants instead of cases (property-based testing), test relationships when you have no oracle (metamorphic testing), measure whether your assertions actually bite (mutation testing), and let a loop hunt for crashes (fuzzing) — each built from scratch on the stdlib, with the library tools labeled.
Learning objectives
- Write property-based tests — state an invariant and check it on many generated inputs, not a few examples.
- Recognize the classic property families: round-trip/inverse, idempotence, invariants, and oracles.
- Apply metamorphic testing when there is no exact oracle (relations between related inputs).
- Explain mutation testing and mutation score, and why a suite that passes on buggy code is weak.
- Understand coverage-guided fuzzing and run a loop that finds a crash.
- Map each technique to its library tool (Hypothesis, mutmut/cosmic-ray, Atheris) and know when the stdlib version suffices.
pip install.1 · Property-based testing
Example-based tests check specific input→output pairs you thought of. Property-based testing flips this: you state a property — an invariant that must hold for every input — and a generator produces hundreds of random inputs to try to falsify it. When it finds a counterexample, a good tool shrinks it to the minimal failing case. The lab is a from-scratch for_all so it runs on the stdlib.
property_based.pyimport random
# Property-based testing: instead of hand-picking examples, state a *property*
# (an invariant that must hold for ALL inputs) and check it on many random ones.
# A from-scratch mini property-tester, so this runs on the stdlib alone.
def for_all(gen, prop, trials=1000, seed=0):
rng = random.Random(seed)
for i in range(trials):
x = gen(rng)
if not prop(x):
return ("FAIL", x) # a counterexample
return ("OK", trials)
# System under test: an encode/decode round-trip.
def encode(s): return s.replace("%", "%25").replace(",", "%2C")
def decode(s): return s.replace("%2C", ",").replace("%25", "%")
# Property 1 (round-trip / inverse): decode(encode(s)) == s for ALL strings.
rand_str = lambda rng: "".join(rng.choice("a,%z ") for _ in range(rng.randint(0, 8)))
print("round-trip :", for_all(rand_str, lambda s: decode(encode(s)) == s, seed=1))
# Property 2 (idempotence of sorting): sorting twice == sorting once.
rand_list = lambda rng: [rng.randint(-5, 5) for _ in range(rng.randint(0, 6))]
print("sort idempot:", for_all(rand_list, lambda xs: sorted(sorted(xs)) == sorted(xs), seed=2))
# Property 3 catches a bug: a broken decode that forgets to unescape "%25".
def decode_buggy(s): return s.replace("%2C", ",")
print("buggy decode:", for_all(rand_str, lambda s: decode_buggy(encode(s)) == s, seed=1))
round-trip : ('OK', 1000)
sort idempot: ('OK', 1000)
buggy decode: ('FAIL', '%, a%aa')
The round-trip and idempotence properties hold across 1000 random inputs; the buggy decoder is falsified with a counterexample containing a bare % (here '%, a%aa') — exactly the escaping case a hand-written example set tends to miss. Our mini-tester reports the first failing input; a real tool like Hypothesis would shrink it down to the minimal '%'. Common property families to memorize:
| Property family | Invariant | Example |
|---|---|---|
| Round-trip / inverse | decode(encode(x)) == x | serialize/parse, compress/decompress |
| Idempotence | f(f(x)) == f(x) | sort, normalize, dedupe |
| Invariant | some relation always true | sorted output is ordered; length preserved |
| Oracle / model | f(x) == reference(x) | fast impl vs slow reference |
| Commutativity | f(a,b) == f(b,a) | set union, addition |
pip install hypothesis): @given(st.text()) generates inputs, and it auto-shrinks failures to a minimal example and remembers past counterexamples. The stdlib version above shows the mechanism; Hypothesis adds generation strategies and shrinking.2 · Metamorphic testing — when you have no oracle
Sometimes you cannot compute the exact expected output — a search ranker, an ML model, a physics simulation. The oracle problem. Metamorphic testing sidesteps it: instead of checking one output against a known answer, you check a relation between the outputs of related inputs. If sin(x) is hard to verify, you can still assert sin(x) == sin(π − x).
metamorphic.pyimport random
# Metamorphic testing: when you have NO oracle (you can't compute the exact
# expected output), test a RELATION between outputs of related inputs instead.
# Example: a search-relevance score you can't hand-compute, but you know adding
# a matching word must not DECREASE the score (a metamorphic relation).
def score(query, doc):
q = set(query.split()); d = doc.split()
return sum(d.count(w) for w in q) # count of query-word hits
rng = random.Random(0)
words = ["alpha", "beta", "gamma", "delta"]
violations = 0
for _ in range(1000):
query = " ".join(rng.sample(words, 2))
doc = " ".join(rng.choice(words) for _ in range(rng.randint(1, 6)))
base = score(query, doc)
# Metamorphic transform: append a word that IS in the query.
q_word = query.split()[0]
followup = score(query, doc + " " + q_word)
if followup < base: # relation: must not decrease
violations += 1
print("metamorphic relation held:", violations == 0, "| violations:", violations)
metamorphic relation held: True | violations: 0
We cannot say what the relevance score should be, but we know a valid metamorphic relation: adding a query word to a document must not decrease its score. Checking that over 1000 random query/doc pairs finds no violation — and would immediately flag a ranker that regressed. This is the workhorse technique for testing the non-deterministic and LLM systems in TQ8 and ch05-evaluation.
3 · Mutation testing — do your assertions actually bite?
Coverage (TQ6) tells you a line ran; it cannot tell you the assertion checked the right thing. Mutation testing answers that: it deliberately introduces small bugs (mutants) into your code — flip + to -, < to <=, and to or — then runs your suite. A mutant your suite fails on is killed; one it passes is a survivor — proof of a weak or missing assertion.
The mutation score is killed / total mutants. The lab builds a tiny mutation tester and contrasts a strong suite (asserts the value) with a weak one (only runs the code):
mutation.pyimport copy
# Mutation testing: judge a TEST SUITE by seeding bugs (mutants) into the code
# and checking the suite catches them. A suite that passes on buggy code is weak.
# From-scratch mutation tester: swap an operator in the source, re-exec, run tests.
SOURCE = "def add(a, b):\n return a + b\n"
def make_tests(strong):
# strong=True asserts a value (kills the mutant); weak just checks it runs.
def tests(ns):
add = ns["add"]
if strong:
assert add(2, 3) == 5 # pins the value -> kills '+'->'-'
else:
add(2, 3) # no assertion -> mutant survives
return tests
MUTATIONS = [("+", "-"), ("+", "*")] # operator-swap mutants
def mutation_score(source, tests):
killed = 0
for old, new in MUTATIONS:
mutant = source.replace(old, new, 1)
ns = {}
exec(mutant, ns)
try:
tests(ns) # suite runs against the mutant
except AssertionError:
killed += 1 # caught the bug -> mutant killed
return killed, len(MUTATIONS)
k1, n = mutation_score(SOURCE, make_tests(strong=True))
print(f"strong suite: killed {k1}/{n} score={100*k1//n}%")
k2, _ = mutation_score(SOURCE, make_tests(strong=False))
print(f"weak suite: killed {k2}/{n} score={100*k2//n}% <- passes on buggy code!")
strong suite: killed 2/2 score=100%
weak suite: killed 0/2 score=0% <- passes on buggy code!
The strong suite kills 100% of mutants because assert add(2,3) == 5 fails when + becomes - or *. The weak suite calls add but asserts nothing, so every mutant survives — a 0% mutation score. That is the whole lesson: a green, high-coverage suite can still be worthless if it never asserts. Real tools do this at scale:
| Tool | Ecosystem | Note |
|---|---|---|
| mutmut / cosmic-ray | Python | needs the lib; mutates real modules and reruns pytest |
| Stryker | JS/TS, C#, Scala | industry-standard mutation framework |
| PIT (pitest) | Java/JVM | the reference JVM mutation tool |
x*1 → x+0) and can never be killed — the equivalent-mutant problem. This is why 100% mutation score is often unattainable and detecting equivalence is undecidable in general; teams target a high score on core modules, not perfection.4 · Fuzzing — hunt for crashes
Fuzzing throws large volumes of generated (often malformed) input at a target and watches for a crash — an unhandled exception, hang, or memory fault — rather than a wrong value. Modern fuzzers are coverage-guided: they instrument the target and keep inputs that reach new code paths, evolving toward deep bugs (the AFL/libFuzzer idea). The lab is a minimal crash-finder that shows the core loop.
fuzz.pyimport random
# Fuzzing: feed many generated inputs and watch for a CRASH (an unhandled
# exception), not a wrong value. Real fuzzers are coverage-guided: they keep
# inputs that reach NEW code paths. This mini loop shows the crash-finding core.
def parse_fraction(s):
# Buggy parser: crashes on a zero denominator (unhandled ZeroDivisionError).
num, den = s.split("/")
return int(num) / int(den)
def fuzz(target, gen, trials=5000, seed=0):
rng = random.Random(seed)
seen_paths = set()
for i in range(trials):
x = gen(rng)
try:
target(x)
seen_paths.add("ok")
except ZeroDivisionError:
return ("CRASH", x, i) # found the crashing input
except (ValueError, IndexError):
seen_paths.add("handled") # expected bad-input rejections
return ("no crash", trials)
# Generator biased toward interesting inputs (small ints, includes 0 denominator).
def gen(rng):
n = rng.randint(-3, 3); d = rng.randint(-2, 2)
return f"{n}/{d}"
print("fuzz result:", fuzz(parse_fraction, gen, seed=7))
fuzz result: ('CRASH', '-3/0', 3)
The generator is biased toward interesting values (small ints, including a zero denominator), so within a few iterations it produces an input like '-3/0' and the parser's unhandled ZeroDivisionError surfaces. A coverage-guided fuzzer would additionally retain inputs that unlock new branches, finding far deeper bugs than this uniform sampler.
pip install atheris) is a coverage-guided fuzzer built on libFuzzer; for native code, AFL++ and libFuzzer dominate. Hypothesis also offers a fuzzing mode. The stdlib loop above is the conceptual skeleton; production fuzzers add coverage feedback, corpus management, and input mutation.5 · Which technique, and when
| Technique | Answers | Needs | Reach for it when |
|---|---|---|---|
| Property-based | does an invariant hold for all inputs? | a stated property + generator | pure logic, parsers, data transforms |
| Metamorphic | does a relation hold when you have no oracle? | a metamorphic relation | ML, ranking, simulation, LLM output |
| Mutation | do my assertions actually catch bugs? | the code + an existing suite | auditing a suite's strength |
| Fuzzing | does any input crash the code? | a target that shouldn't crash | parsers, decoders, untrusted input |
✓ Checkpoint — you can move on when you can…
- State a property (round-trip, idempotence, invariant, oracle) and check it over many generated inputs.
- Explain the oracle problem and give a valid metamorphic relation for a system with no exact answer.
- Define mutation score and explain why a passing suite on a mutant means a weak assertion.
- Describe coverage-guided fuzzing and what distinguishes a crash from a wrong value.
- Name the library tool for each technique and when the stdlib version is enough.
Knowledge check
The mutation lab reports the weak suite killing 0/2 mutants (0% mutation score) even though it executes add on every mutant. Coverage would report that line as covered. What does mutation testing measure that coverage cannot?
Show answer
add(2,3) — so the line is 100% covered — but asserts nothing, so when + is mutated to - the output silently changes and no test fails: the mutant survives. Coverage sees “line ran = good”; mutation score sees “bug introduced, suite didn't notice = weak.” It is the direct measure of assertion strength.You must test a document-ranking function whose exact scores you cannot compute by hand (no oracle). Which technique fits, and give one concrete relation you could assert?
Show answer
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Round-trip (inverse) is the most reliable property to reach for: parse∘format, encode∘decode, compress∘decompress.
Your task: Write a property test that int(str(n)) == n for many random integers, using a from-scratch generator loop (no library).
Requirements:
- Generate random ints across a wide range with
random.Random(seed) - Assert the round-trip property on each
- Report OK with the trial count, or the first counterexample
- Deterministic via a fixed seed; runs on the stdlib
💡 Hint: A round-trip property needs two functions that are inverses — str and int here.
Show solution
Round-trip is the highest-value property to start with:
import random
def for_all(gen, prop, trials=1000, seed=0):
rng = random.Random(seed)
for _ in range(trials):
x = gen(rng)
if not prop(x): return ("FAIL", x)
return ("OK", trials)
print(for_all(lambda r: r.randint(-10**9, 10**9),
lambda n: int(str(n)) == n, seed=1))
# ('OK', 1000)
One property replaces dozens of hand-written examples. In production, @given(st.integers()) in Hypothesis does the generation and shrinking for you.
Context: Idempotence (f(f(x))==f(x)) and structural invariants catch a huge class of transform bugs.
Your task: Property-test sorted: assert (a) idempotence and (b) the invariant that the result is ordered and a permutation of the input, over random lists.
Requirements:
- Reuse a
for_allharness over random lists - Check idempotence:
sorted(sorted(xs)) == sorted(xs) - Check the invariant: output is non-decreasing and
sorted(out)==sorted(xs)(same multiset) - Deterministic seed; stdlib only
💡 Hint: A permutation check is just comparing the multisets — sorted(a)==sorted(b) or Counter.
Show solution
Two independent properties over the same generator:
import random
from collections import Counter
def for_all(gen, prop, trials=1000, seed=0):
rng = random.Random(seed)
for _ in range(trials):
x = gen(rng)
if not prop(x): return ("FAIL", x)
return ("OK", trials)
rand_list = lambda r: [r.randint(-9, 9) for _ in range(r.randint(0, 8))]
is_ordered = lambda xs: all(a <= b for a, b in zip(sorted(xs), sorted(xs)[1:]))
print("idempotent:", for_all(rand_list, lambda xs: sorted(sorted(xs)) == sorted(xs), seed=3))
print("invariant :", for_all(rand_list,
lambda xs: is_ordered(xs) and Counter(sorted(xs)) == Counter(xs), seed=4))
# idempotent: ('OK', 1000)
# invariant : ('OK', 1000)
The multiset check (Counter) guards against a sort that drops or invents elements — a bug pure ordering checks would miss.
Context: With no oracle, a metamorphic relation is your test.
Your task: For a bag-of-words score(query, doc), encode the monotonicity relation: adding a query word to the doc must not lower the score. Find any violation over random inputs.
Requirements:
- Implement a simple query-hit
score - Generate random query/doc pairs with a seeded RNG
- Assert the follow-up score (doc + a query word) is ≥ the base score
- Report the violation count (should be 0); stdlib only
💡 Hint: Pick the added word from the query itself so the relation is guaranteed to be a valid metamorphic transform.
Show solution
Test the relation, not an exact value:
import random
def score(query, doc):
q = set(query.split()); d = doc.split()
return sum(d.count(w) for w in q)
rng = random.Random(0); words = ["a", "b", "c", "d"]; bad = 0
for _ in range(2000):
query = " ".join(rng.sample(words, 2))
doc = " ".join(rng.choice(words) for _ in range(rng.randint(1, 5)))
if score(query, doc + " " + query.split()[0]) < score(query, doc):
bad += 1
print("violations:", bad) # 0
Zero violations across 2000 pairs gives evidence the ranker is monotone in matching terms — the kind of guarantee you can state without ever computing an exact score.
Context: Mutation score is the direct measure of whether a suite's assertions bite.
Your task: Build a mutation tester that flips comparison operators in a clamp(x, lo, hi) function and reports the mutation score for a given suite; show a strong suite scoring higher than a weak one.
Requirements:
- Mutate at least two operators (e.g.
<→<=,>→>=) via source replace - Run the suite against each mutant; count kills
- Report
killed/totalfor a strong suite and a weak (assertion-light) suite - Runs on the stdlib via
exec
💡 Hint: Boundary-flipping mutants (<↔<=) are killed only by tests that probe the boundary — tie this back to BVA in TQ6.
Show solution
Operator-flip mutants, killed only by boundary-probing assertions:
SRC = ("def clamp(x, lo, hi):\n"
" if x < lo: return lo\n"
" if x > hi: return hi\n"
" return x\n")
MUT = [("< lo", "<= lo"), ("> hi", ">= hi")]
def mscore(src, tests):
killed = 0
for old, new in MUT:
ns = {}; exec(src.replace(old, new, 1), ns)
try: tests(ns);
except AssertionError: killed += 1
return killed, len(MUT)
def strong(ns):
c = ns["clamp"]
assert c(0, 0, 10) == 0 and c(10, 0, 10) == 10 # probes both boundaries
def weak(ns):
assert ns["clamp"](5, 0, 10) == 5 # middle only
print("strong:", mscore(SRC, strong)) # (2, 2)
print("weak :", mscore(SRC, weak)) # (0, 2)
The strong suite probes both boundaries, so flipping < to <= changes a boundary result and the assertion fails — mutant killed. The middle-only weak suite never notices: 0% mutation score despite passing.
Context: Real fuzzers keep inputs that reach new paths; even a crude coverage signal beats uniform sampling.
Your task: Extend the fuzz loop to track which branches an input reaches and prefer mutating inputs that discovered new coverage, then find a crash in a small parser faster than uniform random.
Requirements:
- Instrument the target to report a set of reached branch labels
- Keep a corpus of inputs that expanded total coverage
- Mutate corpus entries (not just fresh random) to reach deeper paths
- Stop and report on the first crashing input; stdlib only
💡 Hint: Coverage guidance is just: if an input reveals a branch label you haven't seen, save it and mutate from it.
Show solution
A crude coverage signal steers the fuzzer toward new paths:
import random
def target(s): # returns reached branches; may crash
reached = {"enter"}
parts = s.split("/")
if len(parts) == 2:
reached.add("two")
return int(parts[0]) / int(parts[1]), reached # ZeroDivision on /0
reached.add("one"); return None, reached
def fuzz(seed=0, trials=3000):
rng = random.Random(seed); corpus = ["1/1"]; seen = set()
for _ in range(trials):
base = rng.choice(corpus)
s = "".join(c for c in base if rng.random() > 0.3) + rng.choice("0/12")
try:
_, reached = target(s)
if reached - seen: seen |= reached; corpus.append(s) # new coverage
except ZeroDivisionError:
return ("CRASH", s)
except (ValueError, IndexError):
pass
return ("no crash", len(corpus))
print(fuzz(seed=1)) # ('CRASH', ...) with a zero denominator
Saving inputs that reach the "two" branch and mutating from them reaches the division path — and thus the /0 crash — far sooner than uniform sampling. Atheris/libFuzzer do this with real edge coverage.
Context: A staff engineer gates high-risk modules on property tests AND a mutation-score floor — coverage alone is a vanity metric.
Your task: Design a CI policy function that, per module, requires branch coverage above a floor AND a mutation score above a floor, and returns the modules that fail plus which gate they missed; justify why both gates are needed.
Requirements:
gate(module)checks branch-coverage floor and mutation-score floor per risk tier- Return failing modules with the specific gate(s) missed and the shortfall
- Explain why coverage-without-mutation lets assertion-free tests pass, and mutation-without-coverage is slow/blind
- Demonstrate on a realistic module list; stdlib only
- Note the equivalent-mutant caveat so the floor is <100%
💡 Hint: Coverage is cheap and catches unrun code; mutation is expensive and catches weak assertions. Gate high-risk modules on both, low-risk on coverage only.
Show solution
Two orthogonal gates, applied by risk tier:
POLICY = {
"critical": {"cov": 90, "mut": 80},
"core": {"cov": 80, "mut": 60},
"peripheral":{"cov": 60, "mut": 0}, # coverage only
}
def gate(modules):
fails = []
for m in modules:
pol = POLICY[m["tier"]]
if m["cov"] < pol["cov"]:
fails.append((m["name"], "coverage", pol["cov"] - m["cov"]))
if m["mut"] < pol["mut"]:
fails.append((m["name"], "mutation", pol["mut"] - m["mut"]))
return fails
mods = [
{"name": "payments", "tier": "critical", "cov": 92, "mut": 71},
{"name": "search", "tier": "core", "cov": 83, "mut": 64},
{"name": "banner", "tier": "peripheral","cov": 55, "mut": 0},
]
for f in gate(mods): print(f)
# ('payments', 'mutation', 9)
# ('banner', 'coverage', 5)
payments has ample coverage (92%) but its assertions are weak (71% mutation, below the 80 floor) — exactly the failure coverage cannot see. banner is gated on coverage only. The mutation floor sits below 100% to allow for equivalent mutants that can never be killed.