Coverage vs mutation testing
Coverage shows what your tests ran; mutation testing shows what they would catch.
Predict first
Before you read: predict
ExamHall's scoring module and its two tests:
// score.test.mjs
it('grades a strong paper', () => { grade(90, 100, 70); });
it('fails a weak paper', () => { expect(grade(40, 100, 70)).toBe('fail'); });
Vitest measures line and branch coverage of score.mjs. Stryker then mutates line 3 only. Its five mutants replace the condition with true, false, pct > passMark and pct < passMark, and replace 'pass' with ""; it runs both tests against each mutant.
What do the two reports show?
// score.mjsexport function grade(points, max, passMark) { const pct = Math.round((points / max) * 100); if (pct >= passMark) return 'pass'; return 'fail';}The 30-second version
Coverage reports which lines and branches the tests ran, not whether any assertion would notice a fault. Mutation testing plants one small fault at a time and reruns the tests: a failing test kills the mutant, an all-green run lets it survive. The mutation score is the share detected; each survivor points at a missing input or assertion.
Coverage shows what your tests ran; mutation testing shows what they would catch.
How it works
Step 1 of 11
In the kitchen
- the belt counter logs every bag through the head → line coverage: the code ran (step 1)
Analogy: The metal detector on a crisp factory's bagging line
Every bag passes through the detector head, and the belt counter logs each one. Each hour a quality technician tapes a certified metal test piece into a bag and sends it through.
| On the bagging line | In the test suite |
|---|---|
| the belt counter logs every bag through the head | line coverage: the code ran (step 1) |
| a head turned to its lowest sensitivity still lets the counter tick | a test with no assertion still adds coverage (step 2) |
| a day when no bag set the detector off never moved the reject arm | a covered line with an untaken branch (steps 3–4) |
| the test piece's bag is rejected | a mutant killed (step 6) |
| the test piece rides through unrejected | a survivor: the head cannot see that fault (step 9) |
| a test piece sent down the side lane that bypasses the head | no coverage (step 7) |
| test pieces rejected over test pieces sent | the mutation score (step 8) |
In real terms: coverage counts what the tests executed; the mutation score counts the planted faults the tests' failures exposed.
Breaks down when: every test piece is a real contaminant, but some code mutants change nothing any input can show (equivalent mutants, step 10). And a detector inspects every bag the same way, while each test checks only what its assertions read.
Contrasting case: a code reviewer reading the diff finds faults without running anything. Mutation testing measures the suite, not the code.
Examples, run for real
4.1 ExamHall: the pass-mark refactor (EXH-2110)
EXH-2110: a refactor turned pct >= passMark into pct > passMark, so candidates exactly on the mark were failed. The suite stayed green. Its tests graded a 90 ('pass') and a 40 ('fail') against a pass mark of 70.
4.1 coverage score.mjs lines 100% (3/3) · branches 100% (2/2)
4.1 stryker line 3: 5 mutants · killed 4 · survived 1: pct > passMark
4.1 MR !2410 + expect(grade(70, 100, 70)).toBe('pass') · line 3: killed 5 · survived 0
Full coverage, yet one mutant lives: no test separates >= from >, because the two differ only on the mark itself (steps 6, 9). MR !2410 adds the paper on the mark, and Stryker now kills all five.
4.2 Proof domain, checkout: a fallback no test reached
The checkout shows a delivery label, falling back to a default when the slot has none. Its one test passes a slot label.
export function deliveryLabel(slotLabel) {
return slotLabel ?? 'Standard delivery';
}
4.2 coverage delivery.mjs lines 100% (1/1) · branches 50% (1/2)
4.2 stryker killed 2 · survived 0 · no coverage 1: 'Standard delivery' -> "" · score total 66.67
Every line ran, but the ?? fallback never did (steps 3–4). Stryker tracks coverage per mutant, not per line, so the default string's mutant gets No coverage on a fully covered line. It still counts as undetected: two of three valid mutants, 66.67 (steps 7–8).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
The checkout's shipping rule and its tests:
// shipping.test.mjs
it('ships a large basket free', () => { expect(shippingFee(80, 'GB')).toBe(0); });
it('charges a small basket', () => { expect(shippingFee(20, 'GB')).toBe(4.99); });
What line and branch coverage does Vitest report for shipping.mjs?
// shipping.mjsexport function shippingFee(subtotal, country) { if (country !== 'GB') return 12; return subtotal >= 50 ? 0 : 4.99;}Question 2 of 5
The coupon rule and its tests:
// coupon.test.mjs
it('takes ten percent off', () => { expect(couponValue(200, 'SAVE10').discount).toBe(20); });
it('ignores an unknown code', () => { expect(couponValue(200, 'NOPE').discount).toBe(0); });
Stryker's report includes this survivor on line 2: the first label string replaced with "".
Which one added test kills it?
// coupon.mjsexport function couponValue(total, code) { if (code === 'SAVE10') return { discount: total * 0.1, label: 'Ten percent off' }; if (code === 'FREESHIP') return { discount: 0, label: 'Free delivery' }; return { discount: 0, label: '' };}Question 3 of 5
The refund rule and its tests:
// refund.test.mjs
it('refunds a damaged order in full', () => { expect(refundAmount({ total: 80, shipping: 4 }, 'damaged')).toBe(80); });
it('refunds nothing for a change of mind', () => { expect(refundAmount({ total: 80, shipping: 4 }, 'changed mind')).toBe(0); });
Stryker's run on refund.mjs ends with 7 mutants killed, 2 survived, 1 with no coverage, no timeouts and no errors.
What does it print as the file's total mutation score?
// refund.mjsexport function refundAmount(order, reason) { if (reason === 'damaged') return order.total; if (reason === 'late') return Math.min(order.shipping, 5); return 0;}Question 4 of 5
The final-score rule and its tests:
// final.test.mjs
it('caps at 100', () => { expect(finalScore(95, 10, false)).toBe(100); });
it('adds the bonus', () => { expect(finalScore(40, 5, false)).toBe(45); });
it('rejects a big bonus', () => { expect(() => finalScore(50, 25, false)).toThrow(); });
The specification: raw is a whole number from 0 to 100, bonus a whole number from 0 to 30, late true or false, and a bonus over 20 throws RangeError('bonus over 20'). Stryker leaves 5 mutants undetected, these three among them.
Which of the three can no test written to this specification kill?
// final.mjsexport function finalScore(raw, bonus, late) { if (bonus > 20) throw new RangeError('bonus over 20'); const s = raw + bonus; if (late) return Math.floor(s / 2); if (s >= 100) return 100; return s;}Question 5 of 5
The checkout's loyalty-points service has 92% line coverage, above the release gate of 80%, and its lead proposes raising the gate to 95%. Last month a bug that expired members' points a day early shipped through its suite.
What should the team do with the service's coverage numbers?
Go deeper
Pro tips
- Mutate only the lines a change touches. Give Stryker a line range in
mutate, so a review-time run covers the diff rather than the whole module.
When: reviewing a change to a large module, where a whole-file run is slow and its report too long to act on. Verify:npx stryker run --mutate "src/score.mjs:3-3"tip whole file: 9 mutants on lines 1,2,3,4 · --mutate "src/score.mjs:3-3": 5 mutants on line 3. Status: RAN.
Source
Tool behaviour is version-specific; the pack ran Vitest 5.0.2, @vitest/coverage-istanbul 5.0.2 and Stryker 10.0.0. Valid as of 2026-10-01.
- Stryker Mutator, What is mutation testing?: https://stryker-mutator.io/docs/ (
stryker-intro.txt). Quoted: "If at least one of your tests fail, we say the mutant is killed. That's what we want! If no test fails, it survived." Supports steps 5–6. - Stryker Mutator, Mutant states and metrics: https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics/ (
stryker-states-metrics.txt). Quoted: "The mutant isn't covered by one of your tests and survived as a result." and "Mutation score detected / valid * 100". - Stryker Mutator, Equivalent mutants: https://stryker-mutator.io/docs/mutation-testing-elements/equivalent-mutants/ (
stryker-equivalent.txt). Quoted: "It is called equivalent mutant. There is no definitive way for Stryker to find and ignore them." - Stryker Mutator, Supported mutators: https://stryker-mutator.io/docs/mutation-testing-elements/supported-mutators/ (
stryker-supported-mutators.txt). Supports step 5. - Stryker Mutator, Configuration: https://stryker-mutator.io/docs/stryker-js/configuration/ (
stryker-config.txt). Quoted: ""src/app.js:1-11" will mutate lines 1 through 11 inside app.js." Supports pro tip 1. - Carlos Arguelles, Marko Ivanković and Adam Bender, Code Coverage Best Practices, Google Testing Blog, 2020: https://testing.googleblog.com/2020/08/code-coverage-best-practices.html (
google-coverage-best-practices.txt). Quoted: "Code coverage does not guarantee that the covered lines or branches have been tested correctly, it just guarantees that they have been executed by a test." Google's stated practice, as of 2026-10-01. - Goran Petrović and Marko Ivanković, State of Mutation Testing at Google, ICSE-SEIP 2018: https://storage.googleapis.com/gweb-research2023-media/pubtools/4203.pdf (
petrovic-ivankovic-2018.txt, text extracted from the fetched PDF). Quoted: "Traditional mutation analysis is computationally prohibitive which hinders its adoption as an industry standard." and "Only lines affected by the diff under review that are covered and are not arid are mutated." Google's practice as described in 2018, as of 2026-10-01.
Related lessons
Checked against the tools and docs as of 2026-10-01.