Coverage, mutation testing, and what's worth testing
Coverage measures execution, not verification — a suite at 94% line coverage can assert nothing. Branch coverage finds untested paths; mutation testing finds untested behavior. Chase mutation score on critical code, not a 100% coverage mandate.
Your dashboard reads 94% coverage, the CI gate is green, and a one-character bug ships to prod: a discount rule that should have used >= used >, so the customer at exactly the threshold got charged full price. You open the test file expecting a hole and find the opposite — there is a test for the discount function, it runs the function, and the line shows up green in the report. But the test does service.applyDiscount(order) and then asserts… nothing. It never checked the returned total. The line was executed, so coverage counted it, and the suite that “covered” the bug couldn’t have caught it if it tried. Coverage told you the code ran. It never told you anyone looked at the result.
How coverage is collected, and why line ≠ branch
Before you trust that green percentage on your CI dashboard, you need to understand exactly what it measures — because the number that looks like assurance is hiding a gap that’s large enough to ship a one-character prod bug through.
Node’s built-in coverage (node --test --experimental-test-coverage, or c8 wrapping any runner) uses V8’s native coverage: the engine already tracks which byte ranges of compiled code execute, so the runtime hands back precise execution counts with near-zero overhead — no source rewriting, unlike the old Istanbul/babel-instrument approach. The report rolls that up into four metrics, and the gap between them is where bugs hide. Line/statement coverage asks “did this line run?” Function coverage asks “was this function called at least once?” Branch coverage asks the only question that matters: “for each decision point — every if, &&, ||, ternary, ?. — did tests exercise both outcomes?”
The trap is that 100% line coverage can sit on top of a completely untested branch. Consider a short-circuit:
export function fee(order) {
const base = order.amount * 0.029;
// one line, two branches: the `||` short-circuits
return order.isPremium || base < 0.30 ? 0.30 : base;
}
// the only test
test("charges the percentage fee", () => {
assert.equal(fee({ amount: 100, isPremium: false }), 2.9);
});One test, and the line-coverage report says 100% — every line ran. But branch coverage tells the truth: the isPremium === true path never executed, and the base < 0.30 minimum-fee floor never executed. Two real behaviors — the premium waiver and the small-order floor — are completely untested, and a line-coverage gate would wave them through. This is why a senior reads the branch column first: a falsy short-circuit, a default parameter, a catch block, an early return guard — each is a branch that line coverage silently absorbs into a “covered” line.
The coverage trap: execution is not verification
Here is the deeper, more dangerous version of the same lie, and it is the heart of why coverage dashboards mislead. Coverage instruments execution. It cannot see assertions. A test that runs code without checking anything counts as 100% covered:
test("applyDiscount works", () => {
const order = { total: 100, tier: "gold" };
service.applyDiscount(order); // executes every line of applyDiscount...
// ...and asserts nothing. Coverage: 100%. Bugs caught: 0.
});This is not hypothetical — it is the most common way real suites hit a high number and verify nothing. The pattern shows up as: tests with no expect/assert at all; snapshot-only tests where the snapshot was generated from already-buggy output and just locks the bug in; tests that mock the very call they pretend to verify (vi.spyOn(svc, "charge").mockResolvedValue(true) and then assert the mock returned true — you tested the mock, not the code). I have seen a billing module sit at 91% line coverage with effectively zero meaningful assertions — every test executed the path and none inspected the result. The TRADEOFF is real and cuts both ways: a coverage gate is cheap and does catch genuinely untested files (a new module with 0% lights up instantly), but it is trivially gamed by assertion-free tests, and the marginal climb from 90% to 100% is usually the least valuable work in the codebase — error branches that can’t fire, defensive default: cases, generated code — so a hard 100% mandate spends your most expensive engineering hours fighting the report instead of finding bugs.
▸Why this works
Why can’t coverage just detect a missing assertion? Because at the bytecode level there is nothing to detect. assert.equal(a, b) is just a function call that runs and either returns or throws — to V8 it is indistinguishable from console.log(a, b) or Math.max(a, b). The coverage instrument records that bytes executed; it has no concept of “this execution checked a property of the result.” Verification is a semantic notion that lives above the runtime, which is exactly the gap mutation testing was invented to fill: instead of asking “did the code run,” it changes the code and asks “did a test notice.”
Mutation testing: does your suite actually verify anything?
Mutation testing answers the question coverage structurally cannot. A mutation tool — Stryker for JS/TS — takes your source and injects tiny, behavior-changing faults called mutants, one at a time, then reruns the test suite against each. The mutations are surgical and mimic real bugs: flip < to <=, swap + for -, replace a boolean literal with its opposite, delete a statement, change a return x to return null, turn a conditional’s body into an empty block. For each mutant there are two outcomes. A killed mutant means at least one test failed when the code changed — good, a test was watching that behavior. A surviving mutant means every test still passed despite the injected bug — which is a proof, not a guess, that no assertion constrains that behavior. The mutation score = killed ÷ (total non-equivalent mutants), and it measures suite quality far better than coverage because it is defined in terms of caught bugs, not executed lines.
// the function under test
export function isAdult(age) {
return age >= 18; // Stryker mutates >= to >, and to <=, and to true/false
}
// the only test
test("21 is an adult", () => {
assert.equal(isAdult(21), true);
});Coverage here is a clean 100% — the line ran. Run Stryker and the report is brutal: the >= → > mutant survives, because isAdult(21) is true under both >= and >; the test never probes the boundary at 18 where they differ. That surviving mutant is the exact class of the one-character prod bug from the hook. A whole module can post 100% line coverage and a 60% mutation score — 40% of injected bugs sail through untouched, and every one of those survivors is a behavior your suite would not have caught breaking. The TRADEOFF is cost: mutation testing reruns the (relevant subset of the) suite once per mutant, so a module with a few hundred mutants and a fast suite is seconds-to-minutes, but a large codebase is minutes-to-hours. So you don’t run it on every commit — you point it at the critical modules (billing, auth, pricing), run it in nightly CI or a pre-release gate, and use Stryker’s incremental mode and per-test coverage to mutate only what changed.
You own a payment-calculation module that handles money and must not regress. Which testing-quality strategy do you adopt?
What’s actually worth testing
The fix is not “test more,” it’s “test the right seams and verify them.” The testing trophy (and the older pyramid) both point the same way: bias your effort toward integration tests that exercise real seams — a request hitting a real router, service, and an in-memory or test database — over a swarm of brittle unit tests of trivial code. Don’t test framework code, getters, or pass-through wrappers; testing getName() { return this.name } adds a covered line and zero confidence, and it freezes your implementation so every refactor breaks tests that assert nothing about behavior. Test behavior and the branches and boundaries that matter: the off-by-one at the threshold, the empty-list case, the timeout path, the malformed input — exactly the places mutants survive. The FAILURE MODE to avoid is the 100%-coverage mandate itself: chasing the number produces brittle, assertion-light, implementation-coupled tests that slow every refactor (you spend the afternoon fixing tests that never verified anything) while still missing the bug class that matters, because line coverage was never measuring verification. Tie your team’s quality target to mutation score on the modules that matter, treat coverage as a cheap floor that flags wholly-untested code, and you optimize for caught bugs instead of executed lines.
A module reports 100% line coverage, yet Stryker shows a surviving mutant where >= was changed to >. What does that prove?
- 01How can a function have 100% line coverage but still ship a bug that tests should have caught?
- 02What is a surviving mutant, why is mutation score a better quality signal than coverage, and what's the cost?
Coverage is collected from V8’s native execution counts and reported as line, statement, function, and branch — and the only one that tells the truth is branch, because 100% line coverage routinely sits on top of an untested || short-circuit, default parameter, or guard clause that the line column silently absorbs. The deeper trap is that coverage instruments execution, not verification: a test that runs code and asserts nothing — including snapshot-only tests and tests that mock away the call they pretend to check — counts as fully covered while catching nothing, which is how a real suite reaches 90%+ coverage with near-zero meaningful assertions. Mutation testing is the fix: Stryker injects small faults (<→<=, +→-, a negated boolean, a deleted statement) and reruns the suite, and a surviving mutant proves no test asserts on that behavior; the mutation score (killed ÷ total) measures suite quality far better than coverage — a 100%-covered module can score 60%, leaking 40% of injected bugs. Because mutation reruns the suite per mutant (minutes-to-hours), run it on critical modules in nightly CI, not every commit. And what’s worth testing follows the testing trophy: integration tests of real seams over brittle unit tests of trivial code, behavior and boundaries over implementation detail, never framework code or getters — because a 100% coverage mandate produces exactly the brittle, assertion-light tests that slow refactoring and still miss the bug class that matters. Target mutation score where it counts; treat coverage as a cheap floor. Now when you see a dashboard at 94% and a prod bug in the same week, you know the question to ask is not “why didn’t coverage catch it?” but “what did the test actually assert about the return value?”
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.