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
Start with the bug you've shipped. You wrote a function, added three tests — the empty case, a normal case, a big number — watched them go green, and moved on. Weeks later it falls over on an input you never imagined: a string with a lone %, a list that was already sorted, a number at exactly the integer boundary. The tests didn't lie; they only ever checked the handful of cases you could think of. And that's the ceiling of example-based testing: it can only find bugs in the examples you were already smart (or paranoid) enough to write down.
Here's the reframe that breaks that ceiling. Instead of asking "what should f(3) return?" — a question you have to answer once per example — ask "what should be true of the answer no matter what I feed in?" That second question is a property: a statement that must hold for every valid input, not one input. "Decoding an encoded string gives the original back" is a property. "Sorting a list twice is the same as sorting it once" is a property. You state the rule once, and a generator then hammers it with hundreds of random inputs trying to prove you wrong.
Think of it as the difference between spot-checking a bridge by driving your own car over it a few times, versus rolling every conceivable weight across it and asserting "it never collapses." You've stopped enumerating cases and started asserting a law. When the generator does find an input that breaks the law, a good tool does one more crucial thing: it shrinks the counterexample — it whittles the messy random failing input down to the smallest one that still breaks, so instead of debugging 'x9#%, qz%a' you debug '%'. That minimal case is usually a one-line diagnosis.
The common mistake is writing a property that's really just the implementation restated — asserting sorted(xs) == my_own_sort(xs) tests nothing if both share the same bug. Good properties come from a few reliable families (round-trip, idempotence, invariants, a trusted oracle) that hold for reasons independent of how you coded the function — that independence is what makes them catch real bugs. The lab below is a from-scratch for_all loop so the whole mechanism runs on the stdlib; the callout after it names the real tool, Hypothesis.
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.
Follow the three boxes left-to-right along the arrows — it is the loop a property-based tester runs.
- The blue box on the left (generate inputs) is the starting point: instead of hand-picking examples, the tester manufactures many random inputs.
- The arrow carries each input into the green middle box (check property) — the stated invariant that must hold for all of them, like
decode(encode(s)) == s. - The amber box on the right (shrink on fail) is the payoff: when a check fails, the tool doesn't just report the messy random input — it whittles it down to the smallest input that still breaks the property.
In short: Generate many inputs, check one invariant on all of them, and shrink any failure to a minimal counterexample.
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
Property-based testing has a hidden assumption: that you can check the answer. To assert decode(encode(s)) == s, you need to know what the right answer is. But a huge slice of the software you'll actually test in AI work has no knowable right answer. What exact relevance score should a search ranker give document #47 for the query "budget travel"? What's the correct summary of a support ticket? What should a physics simulation output on step 900,000? You cannot compute these by hand, so you have nothing to compare against. This is the famous oracle problem — an "oracle" being any source of the known-correct answer, and for these systems there simply isn't one.
The clever escape is to stop asking "is this output correct?" and start asking "are these two related outputs consistent with each other?" You don't need to know the answer to trig to be certain that sin(x) and sin(π − x) must come out equal — that relationship holds by definition, whatever the actual values are. Testing a relationship between the outputs of deliberately-related inputs, rather than one output against a fixed answer, is metamorphic testing. The relationship you assert is called a metamorphic relation.
These relations are usually easier to state than to notice, because they're the "obvious" things a correct system can never violate. Make a document longer by adding a word the query is searching for — its relevance must not drop. Feed a classifier the same image rotated slightly — the label shouldn't flip. Multiply every price in a shopping cart by two — the total must double. In each case you never say what the number is, only how it must move when you nudge the input in a known direction. Any run that breaks the relation is a genuine bug, caught with zero oracle.
The trap to avoid is asserting a relation that isn't actually guaranteed — "adding any word raises the score" is false (add an irrelevant word and a good ranker leaves the score flat), so a test built on it would fire false alarms and get deleted. The art is picking transforms whose effect is genuinely certain (add a matching word → score can't decrease). As you'll see in TQ8 and ch05-evaluation, this is the backbone technique for testing LLMs and ML models, precisely because their outputs are non-deterministic and have no ground-truth 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).
Read it top-to-bottom on each side, then left-to-right across the dashed line.
- The two blue input boxes are deliberately related: the right one (
x′) is the left one (x) after a known transform — here, appending a word that is in the query. - Each input flows down to its output, but the red note top-right says the plain outputs are unknowable — there is no oracle to check a single score against.
- The green box is the payoff: even with both scores unknown, the transform guarantees a relation (
f(x′) ≥ f(x)) you can assert. Any run that breaks it is a real bug.
In short: When you can't check one answer, relate two answers — the transform makes the relation certain even when the values aren't.
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?
Here is a green build that means nothing. A teammate proudly reports 95% test coverage. Every function runs under test, the dashboard is a wall of green, everyone feels safe. Then a one-character bug — < where they meant <= — ships to production and corrupts data for a week. How? Because coverage answers a much weaker question than everyone assumes. Coverage tells you a line was executed during the tests. It says nothing about whether any test would have complained if that line were wrong. A test can call a function, exercise every branch, and assert absolutely nothing — 100% covered, 0% checked.
So how do you test whether your tests are any good? You can't just read them — a weak assertion looks identical to a strong one at a glance. The trick is delightfully sneaky: deliberately break the code and see if the tests notice. That is mutation testing. A mutation tool makes a tiny, plausible edit to your source — flips a + to a -, a < to a <=, an and to an or, a True to a False — producing a deliberately buggy copy called a mutant. Then it runs your test suite against that mutant.
The logic is beautifully simple. If the mutant is buggy and a good suite should catch it, then some test ought to fail. A test that fails on the mutant has done its job — the mutant is killed. But if every test still passes on the broken code, the mutant is a survivor, and that survival is hard proof that your tests never actually checked the behaviour that line controls. The mutation score — killed ÷ total mutants — is therefore a direct, honest measure of how much your assertions bite, which is exactly the thing coverage pretends to measure but can't. Think of it as hiring a saboteur to plant faults in your product just to check that your quality inspectors are awake.
The catch that keeps the score below 100%: some mutants change the code without changing its behaviour at all — mutating x * 1 into x + 1 would be caught, but x * 1 into x ** 1 computes the same thing, so no test could ever kill it. These equivalent mutants are undetectable in general, which is why serious teams chase a high mutation score on their riskiest modules rather than a perfect one. The lab builds a miniature mutation tester and pits a strong suite (which pins the value) against a weak one (which only runs the code) so you can watch the score collapse.
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):
Follow the arrows left-to-right: the code is broken once, then fed to two different suites.
- The left blue box is your real source. The tool makes a tiny edit —
+becomes−— producing the amber mutant: a deliberately buggy copy. - The mutant fans out to two suites. Both run the same mutant; the difference is only what they assert.
- The green box (strong suite) pins the value, so the broken output makes it fail = mutant killed. The red box (weak suite) only runs the code and asserts nothing, so it still passes = mutant survives — the honest signal that its assertions don't bite.
In short: Killed means a test noticed the planted bug; survived means your tests were asleep — which is exactly what mutation score counts.
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
Every technique so far tested inputs a human could imagine. Property tests, metamorphic relations, mutants — they all reason about meaningful inputs and whether the answer is right. But some of the nastiest production incidents don't come from a wrong answer; they come from an input so weird that the code never had a chance to give any answer at all. The parser that got a truncated file and threw IndexError. The decoder handed "-3/0" and died on ZeroDivisionError. The endpoint that received 40,000 bytes of emoji and hung. Nobody wrote a test for these because nobody thinks like that — but an attacker, or a flaky upstream system, absolutely will.
Fuzzing is the technique that thinks like that for you. Instead of a few careful cases, it fires an enormous, cheap stream of generated — often malformed, garbage, adversarial — inputs at your code and watches for one specific failure mode: not "wrong output" but a crash. An unhandled exception, an infinite hang, a memory fault. The bar is deliberately low, and that's the point: "never crash on any input" is a promise you can check millions of times per second without ever knowing what the correct output would have been. It's the ultimate no-oracle test — you're only asserting that the code stays on its feet.
Naive fuzzing throws purely random bytes, which is like trying to open a combination lock by mashing digits — you'll almost never stumble onto the deep code paths where the interesting bugs hide. So modern fuzzers are coverage-guided, and this is the idea that made fuzzing legendary (AFL, libFuzzer). The fuzzer instruments your program to see which lines each input reaches. When an input triggers a branch it has never reached before, that input is a lead worth following — the fuzzer saves it and mutates it further, evolving a corpus of inputs that burrow deeper and deeper into the code. Random discovers the shallow bugs; coverage-guidance grows toward the buried ones.
The mindset shift worth internalizing: a passing fuzz run never proves your code is correct — it only proves nobody found a crash this time. Fuzzing is a hunt, not a proof, which is why it targets parsers, decoders, deserializers, and anything eating untrusted input, and why it's usually run continuously rather than once. The lab strips this to its skeleton — a loop that feeds biased random inputs to a buggy fraction parser and reports the first crash — so the crash-finding core is visible before you meet the real tools (Atheris, AFL++, libFuzzer) in the callout.
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.
Read left-to-right through one iteration, then follow the dashed loop back to the start.
- The left box makes an input — in a coverage-guided fuzzer by mutating something already in the corpus, not just fresh randomness.
- Run target produces one of three outcomes, shown by the three arrows: handled/ok (grey, keep going), CRASH (red, the failure you report), or new coverage (green).
- The green box is the engine of the technique: an input that reached a branch never seen before is saved to the corpus, and the dashed feedback edge sends it back to be mutated further — so the fuzzer evolves toward the deep, buggy paths.
In short: Random finds shallow crashes; keeping and mutating inputs that unlock new branches is what burrows down to the buried ones.
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
Four techniques in one chapter invites a natural mistake: treating them as competitors and picking a favourite. They're not rivals — they answer four genuinely different questions, and reaching for the wrong one wastes effort while leaving the real risk untested. The way to keep them straight is to hold onto the question each one uniquely answers, because that question is what tells you when to reach for it.
Line them up by the question: Property-based asks "does an invariant hold for all inputs?" — reach for it on pure logic, parsers, and data transforms where you can check the answer and want confidence far beyond a few examples. Metamorphic asks "does a relation hold when I have no way to check the answer?" — reach for it on ML models, rankers, simulations, and LLM output, exactly where the oracle is missing. Mutation asks "do my tests actually catch bugs?" — it isn't a test of your code at all, it's a test of your test suite, so reach for it when you need to trust a suite that guards something important. Fuzzing asks "does any input crash me?" — reach for it wherever untrusted or malformed data enters: parsers, decoders, deserializers.
Notice they operate at different layers, which is why mature teams run all four rather than choosing. Property and metamorphic tests define what correct means; mutation testing then audits whether those tests are strong enough to defend the definition; fuzzing hunts the pathological inputs none of the others thought to write down. A useful way to picture it: property/metamorphic tests are the rules of the game, mutation testing is the referee checking the rules are enforced, and fuzzing is the stress-tester trying to break the stadium. The table below is your at-a-glance decision guide; the callout after it drives home that they compose into layers, not a single choice.
Read the stack top-to-bottom — each band is a different job, not a competing option.
- The top blue band is where correctness is defined: property tests assert an invariant over all inputs, metamorphic tests assert a relation when there's no oracle.
- The middle green band is mutation testing — it doesn't test your code, it audits the tests above it, checking their assertions actually catch planted bugs.
- The bottom amber band is fuzzing, working underneath everything to find the malformed inputs none of the others thought to write down.
In short: Four distinct questions become four stacked layers — that's why you run all of them on your riskiest modules instead of picking one.
| 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.