Mutation Testing
Deliberately break the production code and see whether the tests notice — the only practical measurement of whether a test suite actually asserts anything.
3 min read · updated 19 September 2026
Coverage tells you a line executed. Mutation testing tells you whether anybody was watching.
The technique: make a small change to the production code — flip a comparison, remove a statement, swap an operator — and run the tests. If a test fails, the mutant is killed. If every test still passes, the mutant survived, and you have found a fault your suite cannot detect.
#Why it finds what coverage cannot
export function shippingCents(order: Order): number {
if (order.subtotalCents >= 5_000) return 0; // ← free over £50
return 395;
}it('is free over the threshold', () => {
expect(shippingCents(anOrder({ subtotalCents: 10_000 }))).toBe(0);
});
it('is charged below it', () => {
expect(shippingCents(anOrder({ subtotalCents: 1_000 }))).toBe(395);
});100% line coverage. 100% branch coverage. And the mutation
>= → > survives: neither test uses a subtotal of exactly 5,000, so
nothing notices when the boundary moves.
That surviving mutant is the missing test:
it('is free at exactly the threshold', () => {
expect(shippingCents(anOrder({ subtotalCents: 5_000 }))).toBe(0); // kills it
});Off-by-one at a boundary is among the most common real defects there is, and this is the only automated technique that reliably points at it.
#The tools
| Language | Tool |
|---|---|
| JavaScript / TypeScript | Stryker |
| .NET | Stryker.NET |
| Java / Kotlin | PITest |
| Python | mutmut, cosmic-ray |
| Ruby | mutant |
| PHP | Infection |
// stryker.config.mjs
export default {
packageManager: 'npm',
testRunner: 'jest',
reporters: ['html', 'clear-text', 'progress'],
coverageAnalysis: 'perTest', // only run tests that touch the mutant
mutate: ['src/pricing/**/*.ts', '!src/**/*.test.ts'],
thresholds: { high: 85, low: 70, break: 60 },
// Practical: mutate only what changed, so the run fits in a pipeline.
since: { enabled: true, ignoreStatic: true }
};npx stryker run<!-- PITest, Maven -->
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<configuration>
<targetClasses><param>com.example.pricing.*</param></targetClasses>
<mutationThreshold>70</mutationThreshold>
<withHistory>true</withHistory> <!-- incremental, much faster -->
</configuration>
</plugin>#Reading the report
src/pricing/shipping.ts
Line 12 ConditionalExpression >= → > SURVIVED
Line 12 EqualityOperator >= → < killed
Line 13 BlockStatement removed killed
Line 15 ArithmeticOperator * → / SURVIVED
Mutation score: 50% (2/4)Two findings:
- Line 12,
>=→>survived. The boundary is untested. Real gap. - Line 15,
*→/survived. Something arithmetic is not asserted at all — often a value the test computes rather than states literally.
A surviving mutant is either a missing test, a missing assertion, or code that does not matter. All three are worth knowing.
#Equivalent mutants
Some mutations do not change behaviour and therefore cannot be killed:
for (let i = 0; i < items.length; i++) // mutated to i != items.lengthBehaviourally identical for this loop, so it survives forever. Equivalent mutants are the main source of noise, detecting them is undecidable in general, and the practical response is to ignore a small number of known ones rather than chase a 100% score.
This is why a mutation score of 100% is not a sensible target. 70–85% on the code that matters is an excellent result.
#Making it affordable
Mutation testing runs your suite once per mutant. A 500-mutant run against a 30-second suite is four hours if done naively.
What makes it practical:
1. Mutate only what changed. Stryker's since, PITest's
withHistory. This turns an overnight job into a two-minute one.
# GitHub Actions — on pull requests, changed files only
- run: npx stryker run --since=origin/main2. Per-test coverage analysis. Run only the tests that execute the
mutated line. coverageAnalysis: 'perTest' typically gives a 5–20x speedup.
3. Scope it to code where correctness is expensive. Pricing, permissions, tax, state machines, anything financial. Not the UI, not glue, not configuration.
4. Run it on a schedule for the full codebase, weekly, and act on the report.
#What a low score means
A module with 90% coverage and a 40% mutation score is a common and alarming discovery. It usually means one of:
- Tests that call code and assert nothing meaningful
- Assertions on the wrong thing —
toBeDefined()where a value was intended - Tests that assert on mock interactions rather than outcomes
- Boundaries tested at one side only
Each is a real weakness, and none of them is visible in a coverage report.
#When it is worth it
Yes: financial calculations, tax, pricing, permissions, state machines, parsers, anything where a silent wrong answer is expensive. Also excellent as a one-off audit of a suite you have inherited and do not trust.
No: UI rendering, glue code, configuration, generated code, and any codebase where the unit suite is so slow that a mutation run is measured in days. Fix the suite speed first.
Used well it is the only practical answer to "are these tests any good", which is a question coverage is routinely and wrongly asked.
Common questions
- What is a mutation score?
- The percentage of introduced faults that at least one test detected. Unlike coverage it accounts for assertions — a suite that executes every line but asserts nothing scores near zero, which is the correct answer.
- Is mutation testing too slow to be practical?
- Run against a whole codebase, usually yes — the suite runs once per mutant. Run against changed files only, or against the handful of modules where correctness is expensive, it is entirely practical and frequently revealing.
- What is an equivalent mutant?
- A mutation that changes the code without changing its behaviour, so no test can possibly kill it. They are a known limitation, they are usually a small fraction of survivors, and detecting them automatically is undecidable in general.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/coverage/mutation-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Code CoverageWhat the percentage measures, why it is a finding tool rather than a target, how to collect it in each ecosystem, and how to gate on it without causing harm.
- Coverage MetricsLine, statement, branch, condition and path coverage — what each measures, why branch coverage is worth three times line coverage, and what none of them see.
- Unit TestingWhat a unit actually is, what belongs in a unit test and what does not, and the properties that separate a unit suite people run from one they skip.
- Test-Driven DevelopmentRed-green-refactor, what TDD actually changes about a codebase, where it fits badly, and the honest evidence for and against it.