I used to treat code coverage like a report card. Green bar at 94%? Ship it, go home, sleep well. Then one afternoon a teammate deleted a single ! from an if condition as a joke during a pairing session, ran the suite, and every test still passed. 94% coverage, a completely inverted business rule, and not one red X to show for it. That's the day I stopped trusting coverage and started running mutation tests instead.
Coverage Measures Execution, Not Verification
Code coverage answers one question: did this line run while the tests were executing? It says absolutely nothing about whether anything checked the result of that line being right. This function has 100% branch coverage and would sail through code review:
function applyDiscount(price, isPremiumMember) {
if (isPremiumMember) {
return price * 0.9; // 10% off for premium members
}
return price;
}
test('applies discount logic', () => {
applyDiscount(100, true);
applyDiscount(100, false);
});
Both branches execute. Coverage tool is thrilled. But the test never calls expect() on anything β it's decorative. Flip the 0.9 to 1.1 and accidentally charge premium members more, and this test suite will applaud you the whole way to a support ticket pile-up. Coverage measured that the code ran. It never asked if the test would notice the code being wrong.
What Mutation Testing Actually Does
A mutation testing tool takes your source, generates dozens of slightly broken copies of it β called mutants β and reruns your test suite against each one. A mutant is created by a small, mechanical edit: flip > to >=, swap && for ||, change + to -, delete a line, replace a return value with null. Then for each mutant:
- If a test fails, the mutant is killed. Good β your suite caught the sabotage.
- If every test still passes, the mutant survives. Bad β you have code whose correctness nothing actually verifies.
Your mutation score is the percentage of mutants killed. It's a far harsher and more honest number than line coverage, because it's testing your assertions, not just your test runner's ability to import a file and call a function.
Running it against applyDiscount with Stryker (the standard mutation testing framework for JS/TS, with ports for Java, C#, and Scala too) would generate a mutant that changes 0.9 to 1.1, rerun the "test" above, watch it pass, and flag it:
Ran 1 test against 1 mutant, killed 0.
Mutation score: 0%
A 0% mutation score on code sitting inside a green CI pipeline is exactly the kind of gap that quietly grows for years, because coverage dashboards never surface it.
Fixing the Test, Not the Code
The fix isn't touching applyDiscount at all β it's writing a test that actually asserts something:
test('applies 10% discount for premium members', () => {
expect(applyDiscount(100, true)).toBe(90);
});
test('charges full price for non-premium members', () => {
expect(applyDiscount(100, false)).toBe(100);
});
Now the 0.9 β 1.1 mutant produces applyDiscount(100, true) === 110, the expect(90) assertion fails, and the mutant is killed. Run the boundary mutants too β Stryker will also try flipping the if (isPremiumMember) condition, deleting the whole block, and returning undefined β and a couple of well-aimed assertions kill all of them. That's the entire value proposition: mutation testing tells you which tests to write next, instead of leaving you to guess.
Where It Actually Pays Off in a Backend Codebase
I don't run mutation testing on the whole repo β it's computationally expensive since every mutant means a full test run, and on a large codebase that's hours, not minutes. Where it earns its keep at Cubet is on the modules where a silent logic bug is expensive: billing calculations, permission checks, rate-limit thresholds, retry/backoff logic. Anywhere a >= that should've been a > would ship quietly and cost someone money or a security incident before anyone noticed.
A workable setup for a Node/TypeScript service with Stryker:
// stryker.conf.json
{
"packageManager": "npm",
"testRunner": "jest",
"mutate": ["src/billing/**/*.ts", "src/auth/permissions.ts"],
"thresholds": { "high": 90, "low": 70, "break": 60 }
}
Scope mutate to the high-stakes paths, set a break threshold so CI fails if the score drops below it, and run it as a separate, less-frequent job β nightly or on a label like run-mutation-tests β rather than on every PR. Running the full mutant matrix on every push is how teams abandon mutation testing within a month; running it surgically on the modules that matter is how it survives.
The Honest Caveat
Mutation testing will also surface equivalent mutants β mutations that change the code's text but not its behavior (say, flipping i < arr.length to i <= arr.length - 1), which no test can ever kill because there's nothing behaviorally different to detect. Don't chase 100%. Treat a rising trend line and a sane floor (60-80%, tuned per module) as the goal, and spend your energy on the mutants that reveal a genuine, plausible bug rather than the ones that are mathematically un-killable curiosities.
Coverage tells you what ran. Mutation testing tells you what you'd actually notice going wrong. If your test suite has never faced a mutant, it's earned a grade it hasn't proven. Point Stryker (or PIT for Java, or mutmut for Python) at your riskiest module this week and see how many of your "passing" tests are just watching the code run without checking a single thing.