AI EngineeringZero to ProductionHome·About·What’s new·Contact
Software Testing · Chapter TQ7

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.

⏱️ ~3 hours🧪 6 labs🎯 Beginner→Industry

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.
▶ Runnable companionEach technique appears twice: a from-scratch stdlib version that runs here (seeded, deterministic), and a note on the library you would reach for in production. Anything marked “needs <lib>” requires a 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.

generate inputs check property shrink on fail
🗺️ How to read this diagram

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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · a mini property-tester from scratch (runs)
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 familyInvariantExample
Round-trip / inversedecode(encode(x)) == xserialize/parse, compress/decompress
Idempotencef(f(x)) == f(x)sort, normalize, dedupe
Invariantsome relation always truesorted output is ordered; length preserved
Oracle / modelf(x) == reference(x)fast impl vs slow reference
Commutativityf(a,b) == f(b,a)set union, addition
The real tool: Hypothesis (needs hypothesis)In production you use Hypothesis (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).

input x (doc) output f(x) score = ? input x′ (doc + query word) output f(x′) score = ? transform assert relation f(x′) ≥ f(x) no oracle: ✗ can't check f(x) alone You never name the answer, only how it must move. Both exact scores are unknown (top-right, no oracle), yet a known transform links the two inputs — so the relation between their outputs is assertable.
🗺️ How to read this diagram

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.

Python · a metamorphic relation, checked (runs)
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):

source return a + b mutant return a − b flip + → − strong suite fails assert add(2,3)==5 → KILLED weak suite passes no assert → SURVIVES A mutant is a planted bug that audits your tests. A suite that fails on the broken code killed the mutant (good); a suite that still passes let it survive — proof its assertions never checked that behaviour.
🗺️ How to read this diagram

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.

Python · a from-scratch mutation tester (runs)
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:

ToolEcosystemNote
mutmut / cosmic-rayPythonneeds the lib; mutates real modules and reruns pytest
StrykerJS/TS, C#, Scalaindustry-standard mutation framework
PIT (pitest)Java/JVMthe reference JVM mutation tool
Equivalent mutantsSome mutants change the code but not its behaviour (e.g. 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.

generate input mutate a corpus entry run target watch outcome new coverage save to corpus + mutate more handled / ok expected reject — keep going CRASH unhandled exception → report feedback: evolve toward deep paths The loop hunts crashes and grows toward them. Most inputs are handled or ok; a crash is the target failure; an input that hits a new branch is saved and mutated — that feedback edge is what makes it coverage-guided, not random.
🗺️ How to read this diagram

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.

Python · a minimal fuzz loop that finds a crash (runs)
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.

The real tools: Atheris / libFuzzer / AFL++ (need the lib)For Python, Atheris (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.

Property + Metamorphic — define what correct means invariant holds for all inputs · relation holds with no oracle Mutation testing — audit that those tests bite seed bugs; do the assertions above actually catch them? Fuzzing — hunt inputs nobody wrote a test for does any malformed input crash the code? four different questions → four layers, run together on high-risk code Not rivals — layers. Each band answers a different question: the top defines correctness, the middle checks that definition is enforced, the bottom stress-tests for crashes. Mature teams stack all four.
🗺️ How to read this diagram

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.

TechniqueAnswersNeedsReach for it when
Property-baseddoes an invariant hold for all inputs?a stated property + generatorpure logic, parsers, data transforms
Metamorphicdoes a relation hold when you have no oracle?a metamorphic relationML, ranking, simulation, LLM output
Mutationdo my assertions actually catch bugs?the code + an existing suiteauditing a suite's strength
Fuzzingdoes any input crash the code?a target that shouldn't crashparsers, decoders, untrusted input
They composeThese are layers, not rivals: property tests define correctness, mutation testing verifies the property tests bite, and fuzzing finds the malformed inputs neither anticipated. A mature suite uses all four on its highest-risk modules.

✓ 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

✓ 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
Mutation testing measures whether your assertions detect a behaviour change, not merely whether a line ran. The weak suite calls 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.
✓ Knowledge check

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
Metamorphic testing. You cannot assert an exact score, but you can assert a relation between related inputs — for example, appending a word that appears in the query must not decrease the document's score (monotonicity), or duplicating the whole document must not change the relative ranking of two docs. Any run that violates the relation is a real bug, no oracle required — which is exactly how you test LLM and ML systems in TQ8/ch05-evaluation.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Your first property: round-tripBeginner

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.

Exercise 2 · Idempotence and an invariantIntermediate

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_all harness 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.

Exercise 3 · A metamorphic relation for a scorerAdvanced

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.

Exercise 4 · Mutation-score a suiteExpert

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/total for 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.

Exercise 5 · A coverage-guided-ish fuzzerProfessional

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.

Exercise 6 · Layer property + mutation gates in CIIndustry scenario

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.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in