Skip to content

Mutation Testing

Mutation testing measures the quality of your tests, not their coverage. The tool deliberately injects small faults (mutants) into your source — flip a comparison, remove a call, change a constant — then runs your test suite.

If the tests fail, the mutant is killed (good). If they still pass, the mutant survived, and that line is a hole in your suite.

original code ──► mutate ──► mutant
│ │
│ ▼
│ run test suite
│ │
│ ┌───────────┴───────────┐
│ ▼ ▼
│ tests fail tests pass
│ (mutant killed ✓) (mutant survived ✗)
│
└── mutation score = killed / total mutants

A mutation score near 100% means your assertions actually detect change; line coverage can be 100% while the mutation score is low — that is exactly the gap coverage hides.

Use it for

  • Critical modules: pricing, auth, money, permissions, parsers
  • Auditing tests you inherited and do not trust
  • Finding assertions that run but never check anything
  • A periodic quality gate, not an every-commit one

Avoid it when

  • The suite is slow — mutation testing multiplies runtime by the number of mutants
  • Code is trivial glue, generated, or has no branching to mutate
  • You run it on the whole repo every commit instead of a scoped subset
  • You chase a 100% score and start writing tests that only kill noise
Tool Ecosystem Notes
Stryker JS/TS, C#, Scala Rich reporter, incremental mode, dashboard
PIT (pitest) Java/JVM Fast, mature, plugin ecosystem
mutmut Python Simple CLI; parallel mutation
cosmic-ray Python Pluggable mutators, SQLite session store
cargo-mutants Rust Zero-config, respects cargo test
go-mutesting Go Mutation for Go packages
Mutmut / mutmut2 Python Community continuation of mutmut
Terminal window
npm install -D @stryker-mutator/core @stryker-mutator/vitest-runner
npx stryker run
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"testRunner": "vitest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
"thresholds": { "high": 90, "low": 80, "break": 70 },
"reporters": ["html", "clear-text", "progress"]
}
  • Do not run it everywhere, every time. Scope it to critical modules and run it nightly or pre-release; it multiplies your test runtime.
  • Use incremental mode. Stryker and PIT can cache unchanged mutants so repeat runs are cheap.
  • Prioritise survived mutants with real impact. A surviving mutant in a boundary check matters more than one in a log message.
  • Do not chase 100%. Equivalent mutants (same behaviour, different code) can never be killed; treat the score as a signal, not a target.
  • Fix tests, not the tool. If a mutant survives, add or tighten an assertion — never suppress it to raise the number.
  • Pair with coverage. Coverage tells you what ran; mutation testing tells you what was actually verified.

Self-assessment quiz

Pick one answer per question, then press Check answers.

  1. What does mutation testing measure?
  2. What is a surviving mutant?
  3. Why can 100% line coverage coexist with a low mutation score?
  4. Why should mutation testing be scoped rather than run on everything every commit?
  5. What is an equivalent mutant?