AI EngineeringZero to ProductionHome·About·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

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
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

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).

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?

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):

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*1x+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.

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

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