Mutation Testing: Who Tests the Tests?

Measuring what a test suite verifies, not just what it executes

Adam Daw
Based on the paper Mutation Testing: Who Tests the Tests? (October 2026)
Navigate with ← → or the menu below · Use the Theme button to change appearance

100% coverage, three different answers

Author's three JavaScript examples · all nine tests pass · Stryker 10.0.0, full operator set
FileLine / branch / function coverageMutantsKilledSurvivedScore
alerts.js100% / 100% / 100%100100.00%
dates.js100% / 100% / 100%119281.82%
discount.js100% / 100% / 100%96366.67%
Total30151550.00%

Coverage tells us what code ran; it does not tell us what the tests checked.

The idea

1

Make a small, deliberate change to the code — a mutant

2

Run the existing test suite against it

3

At least one test fails → mutant killed ✅
Every test passes → mutant survived ⚠️

A surviving mutant: the code was changed and no test noticed.

By hand: two test runs on the one line you doubt — mutated, then restored. With a tool: the same loop, automated at breadth.

What gets mutated

OperatorOriginalMutant
Relationala >= ba > b
Arithmetica + ba - b
Logicala and ba or b
Negationif ok:if not ok:
Constant1819
Return valuereturn xreturn None
Statement deletionsave(user)(removed)

Each mutant applies exactly one such change.

Worked example

def is_adult(age):
    return age >= 18

def test_is_adult():
    assert is_adult(30) is True
    assert is_adult(5) is False
MutantResult
age <= 18killed ✅
return Truekilled ✅
age > 18survived ⚠️
age >= 19survived ⚠️

The fix the survivors ask for

def test_is_adult_boundary():
    assert is_adult(18) is True
    assert is_adult(17) is False

With it added, every mutant in the table is killed.

A survivor is a question, not a failure:
"Did you mean for this behaviour to be untested?"
It is either a test gap or an equivalent mutant — telling the two apart is part of reading it.

Mutation score — read the definition

Academic

killed / non-equivalent mutants × 100

Mutants no test could kill don't count against the suite

Stryker

detected / valid × 100

Equivalent mutants stay in the denominator

Same code, same tests: 50.00% from 30 mutants (full operator set) vs 42.86% from 7 mutants (EqualityOperator only). Only the operator set changed.
  • A score means something only alongside its operator set and mutant count
  • A useful signal of test strength, but a poor target
  • The real information is in the individual survivors

Why it works

  • Competent programmer hypothesis — real code is usually nearly right, so real bugs tend to be small deviations from a correct program
  • Coupling effect — tests that catch simple faults tend to catch complex ones too
  • Idea proposed by Richard Lipton around 1970–71; classic published treatment: DeMillo, Lipton & Sayward (1978)
  • Just et al. (2014), 357 real faults: mutant detection correlates with real-fault detection, even when controlling for coverage — but while 73% of the real faults were coupled to the generated mutants, 27% were not
Strong evidence about a suite, but not a complete account of every fault it might miss.

The catches

  • Cost — the suite runs once per mutant: roughly mutants × suite runtime
  • Equivalent mutants — changes that don't change behaviour:
    i = 0
    while i < 10: i += 1    # vs.  while i != 10: i += 1
    Starting at 0 and stepping by 1, both stop at 10 — no test can kill it. "To detect if a program and one of its mutants programs are equivalent is undecidable" — Jia & Harman
  • Noise — mutants in logging, default values, boilerplate
Case study · manual audits

Investigating survivors

AuditAppliedKilledSurvived
1880
215150
315114 — no observable difference on the audited fixture

GitNexus by Abhigyan Patwari (indexes code into a graph of symbols and edges) — audits on the author's Apex fork. Hand-selected mutants, applied and reverted by hand — not automated scores.

Checking a survivor

Identical graph on the site's own fixture, with and without the mutant — after a positive control proved the comparison could see a difference. Evidence on that fixture, not proof for every input.

What a negative assertion can't kill

An assertion expecting no edge can't kill a mutant that also emits no edge. The killing fixture made the mutant emit an extra, incorrect edge.

In practice

  • Start by hand — two test runs on the line you doubt: one mutated, one restored
  • Mutate only changed code — run it in code review, not over the whole repository
  • Start with critical modules — pricing, authentication, parsing
  • Skip the noise — exclude logging and generated code
  • Run full analysis nightly or on demand, not on every commit
  • Read survivors; don't chase the score

Tools

LanguageTool
Java / JVMPIT (pitest) — "a state of the art mutation testing system"
JS / TS, C#, ScalaStryker — used for the author's examples
Pythonmutmut, Cosmic Ray
Rustcargo-mutants
PHPInfection
C / C++Mull
Salesforce Apexapex-mutation-testing — third-party plugin by Sébastien Colladon

Your turn (2 min)

def discount(total):
    if total > 100:
        return total * 0.9
    return total

def test_discount():
    assert discount(200) == 180
    assert discount(50) == 50

Find a mutant that survives — then the test that would kill it.

Takeaways

01

Coverage measures execution; mutation testing measures verification

02

Each survivor is a test gap to close or an equivalent mutant to justify

03

Keep it cheap: start by hand on the line you doubt, mutate changed code, read the survivors

Questions?

Full paper: mutation-testing-paper.pdf — references available there.