A tiny proof of concept showing what mutation testing adds on top of code coverage.
Both modules in src/ have 100% line, branch, statement and function coverage. Only one of them has a test suite that actually verifies the behavior. Mutation testing is what tells them apart.
npm installnpm test # 9 tests, all green
npm run test:coverage # 100% coverage on both files
npm run test:mutation # mutation score: 91.49%, 4 survived, EXITS 1
npm run test:mutation:solution # mutation score: 100%, all killed, exits 0The HTML report is written to reports/mutation/mutation.html.
stryker.config.json sets thresholds.break: 95, so a weak
suite fails the build the same way a failing test would:
ERROR MutationTestReportHelper Final mutation score 91.49 under breaking
threshold 95, setting exit code to 1 (failure).
high: 100 / low: 80 only color the report; break is the one that decides the
exit code. Setting break: 100 is usually a mistake, since equivalent mutants make it
unreachable, so teams pick a realistic floor and ratchet it up over time.
.github/workflows/ci.yml wires this into GitHub Actions:
coverage runs first (green), the mutation gate runs second (red), and the HTML
report is uploaded as an artifact with if: always(), because the report is
exactly what you need when the gate is what failed.
Stryker makes a small change to the production code (a mutant) and re-runs the tests:
- tests fail → the mutant is killed ✅ (your tests noticed the change)
- tests pass → the mutant survived ❌ (that line is executed, but not verified)
mutation score = killed / total mutants
Mutators used here include: conditional boundaries (>= → >), equality operators,
arithmetic operators (* → /), boolean/string literals, and block removal.
The tests cover every equivalence class and the boundaries between them: 2000 (÷400), 1900 (÷100 but not ÷400), 2024 (÷4 but not ÷100), 2023, and a non-integer input asserted with the exact error message.
The tests pass and cover every line, but 4 mutants survive:
| Survivor | Why it survives |
|---|---|
total < 0 → total <= 0 |
0 is never tested, so rejecting it goes unnoticed |
total >= 100 → total > 100 |
the discount is tested at 200, never at exactly 100 |
price < 50 → price <= 50 |
shipping is tested at 10, never at exactly 50 |
'total must not be negative' → '' |
the test asserts only toThrow(RangeError), not the message |
Three of the four are off-by-one bugs at a business rule boundary, the kind of defect that reaches production with a green coverage badge.
src/discount.solution.test.js adds the four tests
that kill them. It is skipped by default and enabled with SOLUTION=1
(see jest.config.js), so you can run the "before" and "after"
side by side.
- Coverage asks "was this line executed?"; mutation testing asks "would a change to this line be caught?" It measures the assertions, not the execution.
- Surviving mutants are actionable: each one is either a missing test case, a weak assertion, or dead/redundant code you can delete.
- Equivalent mutants are the known false positive: a mutation that cannot change
observable behavior (e.g. mutating a pure logging call), so no test can kill it.
That's why 100% is not always the right goal. Stryker's
thresholdsand// Stryker disablecomments exist for this. - Cost: the test suite runs once per mutant. This PoC uses
coverageAnalysis: "perTest", so only the tests covering the mutated line run (the report shows ~2.9 tests per mutant instead of all 9). Other levers:--incrementaland--sincein CI, so only changed files are mutated. - Where it pays off: business rules, pricing, validation, permissions, logic dense in boundaries. Less so in glue and I/O code.
- src/leapYear.js / src/leapYear.test.js: the strong example
- src/discount.js / src/discount.test.js: the weak example
- src/discount.solution.test.js: the tests that fix it
- stryker.config.json: Stryker configuration