4.5.16 · D2Software Engineering

Visual walkthrough — Mutation testing

2,329 words11 min readBack to topic

This is a deep dive of Mutation testing. If a word or symbol is new, we define it the moment it appears — nothing is used before it is earned.


Step 1 — Start with one honest program

WHAT. We begin with a program that is correct and a small suite of tests that all pass.

def classify(n):
    if n > 0:          # the line we will attack
        return "pos"
    return "nonpos"

Tests (both currently pass):

assert classify(5)  == "pos"
assert classify(-3) == "nonpos"

WHY. Mutation testing never asks "is my code right?" — we assume the code is right. It asks a sharper question: "if my code were secretly wrong, would these tests notice?" To ask that, we first need a working baseline everyone agrees on. That green baseline is the picture below.

PICTURE. The number line (values of ) is split by a single fence at . Everything right of the fence is "pos"; the fence and everything left is "nonpos". The two test inputs and are the two dots — comfortably far from the fence.

Figure — Mutation testing

Notice already: both test dots are far from the fence. Hold that thought — the whole story turns on it.


Step 2 — Inject one tiny bug: a mutant is born

WHAT. Apply the operator >>= to our fence line. The program is now:

if n >= 0:   # mutant: the fence moved
    return "pos"

WHY this operator? Because > versus >= is the single most common real bug in the wild — the "off-by-one at the boundary." Mutation testing deliberately mimics the kinds of mistakes humans actually make, so a suite that kills these mutants is a suite that would have caught the real thing (this is why Boundary value analysis and mutation testing are cousins).

PICTURE. Watch the fence. In the original, was on the "nonpos" side. In the mutant, the fence slides so that flips to "pos". The only region that changed behaviour is the single point , shaded below.

Figure — Mutation testing

The changed zone is one dot wide. To catch this mutant, a test must land exactly on that dot.


Step 3 — Run the old tests: this mutant SURVIVES

WHAT. Feed our two existing tests to the mutant:

  • classify(5) → still "pos" ✓ ( is far right of the fence, unaffected)
  • classify(-3) → still "nonpos" ✓ ( is far left, unaffected)

Both pass. Therefore the mutant survived. 😱

WHY it survived. Look back at Step 2's shaded zone: the only place the mutant misbehaves is . Our test inputs are and neither of them is . The tests literally never visit the one spot where the bug lives. A silent smoke alarm.

PICTURE. Overlay the "changed zone" (one dot at 0) with the "tested points" (dots at 5 and −3). They do not overlap. That non-overlap is the survival.

Figure — Mutation testing

Step 4 — Not every survivor is your fault: the equivalent mutant

WHAT. Consider a different mutation on a different line. Suppose the code had a redundant guard:

if n > 0 and n > -1000000000:   # the second clause is always true for our domain

Mutating the second clause n > -1000000000n >= -1000000000 changes nothing observable: whenever the first clause n > 0 is true, is already far above , so both versions of the second clause are true. The mutant and original agree on every input.

WHY it matters. This mutant is unkillable — not because our tests are weak, but because there is nothing to catch. Punishing the suite for "failing" here would be blaming a firefighter for not extinguishing a fire that does not exist.

PICTURE. Two number lines, original and mutant, painted identically end to end — no shaded "changed zone" anywhere. Empty set of disagreement.

Figure — Mutation testing

We must keep these separate from real survivors. That separation is the next step's job.


Step 5 — Sort every mutant into three bins (and meet the symbols)

WHAT. Generate all mutants (every operator on every eligible spot). Now we give each pile a name symbol as we introduce it:

  • Let = the total number of mutants generated (all of them).
  • Let = the number of killed mutants (some test failed).
  • Let = the number of real survivors (tests all passed, yet a distinguishing input exists).
  • Let = the number of equivalent mutants (unkillable by anyone).

Each mutant lands in exactly one of three bins after we run the suite and inspect it:

Bin Meaning Symbol
Killed some test failed
Survived (real) tests passed, but a difference exists (the catchable gap)
Equivalent no input can ever tell it apart

The three bins together account for every mutant, so .

WHY three bins, not two? Because "survived" secretly contains two very different animals: real survivors (your tests are weak — fixable) and equivalent survivors (nothing to fix — a mirage). Mixing them would blame the suite for the mirage.

PICTURE. One big box of mutant tokens pours through a sorter into three coloured trays: killed (magenta), real survivors (orange), equivalent (violet).

Figure — Mutation testing

Now, the crucial accounting move: how many mutants could ever be killed?


Step 6 — Count only what is catchable

WHAT. The equivalent mutants ( of them) can never be killed by anyone. Remove them from the pool. What remains — the killable population — is:

Reading it term by term: is everything we made; is the impossible-to-kill mirages; their difference is the honest denominator — the number of mutants that represent a real opportunity for a test to catch a bug. (Notice : the killed plus the real survivors — exactly the catchable ones.)

WHY subtract instead of ignore? If we used as the denominator, a suite could never reach a perfect score just because a few equivalent mutants exist that nobody could ever kill. Subtracting makes the score measure the suite, not the mirages.

PICTURE. From Step 5's three trays, the violet (equivalent) tray is lifted out and set aside. Only the magenta + orange trays remain on the scale — that combined pile is .

Figure — Mutation testing

Step 7 — Assemble the score: successes over opportunities

WHAT. Divide killed () by killable ().

WHY a ratio and not, say, just ? Because means nothing without knowing "out of how many?" Killing 40 of 48 catchable mutants () is strong; killing 40 of 4000 would be terrible. A ratio normalises to a comparable -to- scale — a score of means every catchable bug is caught.

PICTURE. A horizontal bar of length (all opportunities). The killed part is filled magenta; the real survivors stay hollow orange. The fraction filled is the score.

Figure — Mutation testing

Step 8 — The degenerate cases (so nothing surprises you)

Every case must be covered — here are the corners of the formula. Each corner gets its own WHY.

  • All mutants killed, none equivalent (, ): . WHY. When every generated mutant is caught and there are no mirages to remove, numerator and denominator are the same count, so the fraction is exactly — the perfect suite, catching every injected bug.

  • Every mutant is equivalent (): denominator , so the fraction is undefined. WHY. If every mutant is a mirage, there was literally nothing killable to measure. Dividing by zero here is the maths politely saying "the question does not apply." Tools report "N/A", never — a would falsely accuse a fine suite.

  • Empty test suite (no asserts at all): , so score . WHY. With no assertions, no test can ever fail, so no mutant is killed — the numerator is stuck at while opportunities remain. This is the case that exposes Code coverage: empty tests can still execute every line for 100% coverage, yet catch nothing.

  • No equivalent mutants (): the formula collapses to the honest simple . WHY. The whole point of the term is to discount mirages. When there are none, there is nothing to discount, so the correction vanishes and the score is just "killed over total."

Figure — Mutation testing
Recall Quick self-check on the corners

If every mutant is equivalent, what is the score? ::: Undefined () — reported as N/A, because there was nothing killable to measure. An empty test suite scores what, and why is that scary? ::: — it can still show 100% code coverage, so coverage alone would have called it "complete."


The one-picture summary

Everything above, compressed: mutants flow out of the code, sort into three trays, the equivalent tray is removed, and the score is the magenta slice of what remains.

Figure — Mutation testing
Recall Feynman: tell the whole walkthrough to a friend

We start with a program we trust and tests that all pass (Step 1). We sneak in one tiny bug — a mutant — like moving a > to >=; the only place it misbehaves is a single point on the number line (Step 2). We rerun the old tests: they were aimed at points far from that spot, so they miss it and the mutant survives — proof our tests have a blind spot, not that our code is broken (Step 3). But some mutants can never misbehave anywhere — those equivalent ones are mirages, unkillable by anyone (Step 4). So we sort all mutants into three trays: killed , real survivors , equivalent (Step 5). We throw out the equivalent mirages because counting them would be unfair, leaving catchable mutants (Step 6). The score is simply how many we caught over how many we could have caught: — successes over opportunities (Step 7). And at the edges: all-equivalent means "nothing to measure" (), an empty suite scores even at 100% coverage (Step 8). One picture ties it together (Step 9): the score is just the filled slice of the killable bar.


Connections

  • Mutation testing — this page derives its central formula from zero.
  • Boundary value analysis — Step 2's > vs >= mutant is exactly a boundary bug.
  • Code coverage — Step 8 shows why 100% coverage can still score .
  • Unit testing — the tests being graded here.
  • Fault injection — the broader "break it on purpose and watch" family.