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
| File | Line / branch / function coverage | Mutants | Killed | Survived | Score |
|---|---|---|---|---|---|
alerts.js | 100% / 100% / 100% | 10 | 0 | 10 | 0.00% |
dates.js | 100% / 100% / 100% | 11 | 9 | 2 | 81.82% |
discount.js | 100% / 100% / 100% | 9 | 6 | 3 | 66.67% |
| Total | 30 | 15 | 15 | 50.00% |
Coverage tells us what code ran; it does not tell us what the tests checked.
The idea
Make a small, deliberate change to the code — a mutant
Run the existing test suite against it
At least one test fails → mutant killed ✅
Every test passes → mutant survived ⚠️
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
| Operator | Original | Mutant |
|---|---|---|
| Relational | a >= b | a > b |
| Arithmetic | a + b | a - b |
| Logical | a and b | a or b |
| Negation | if ok: | if not ok: |
| Constant | 18 | 19 |
| Return value | return x | return None |
| Statement deletion | save(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
| Mutant | Result |
|---|---|
age <= 18 | killed ✅ |
return True | killed ✅ |
age > 18 | survived ⚠️ |
age >= 19 | survived ⚠️ |
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.
"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
- 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
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
Investigating survivors
| Audit | Applied | Killed | Survived |
|---|---|---|---|
| 1 | 8 | 8 | 0 |
| 2 | 15 | 15 | 0 |
| 3 | 15 | 11 | 4 — 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
| Language | Tool |
|---|---|
| Java / JVM | PIT (pitest) — "a state of the art mutation testing system" |
| JS / TS, C#, Scala | Stryker — used for the author's examples |
| Python | mutmut, Cosmic Ray |
| Rust | cargo-mutants |
| PHP | Infection |
| C / C++ | Mull |
| Salesforce Apex | apex-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
Coverage measures execution; mutation testing measures verification
Each survivor is a test gap to close or an equivalent mutant to justify
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.