ESC

Type to search the knowledge base.

Coverage Metrics Pitfalls

Why 100% line coverage can still ship bugs — gaming metrics, useless tests, and what coverage is actually good for.

intermediate3 min read
  • testing
  • coverage-metrics

Coverage tools answer: which lines/branches ran during tests? They do not answer: are the assertions meaningful? Chasing 100% creates cargo-cult tests that execute code without checking behavior.

Docs: Istanbul/nyc, V8 coverage in Vitest/Jest.

What the numbers mean

Metric Measures
Line Statements executed
Branch if/?:/&& paths
Function Functions entered
Statement Similar to line (tool-dependent)

Branch coverage is usually more honest than line coverage for UI logic.

Classic gaming patterns

// Executes code, asserts nothing useful
test('renders', () => {
  render(<Page />);
});
// Forces branch without checking outcome
test('error branch', () => {
  render(<Page fail />);
  // no expect
});

Coverage goes up. Confidence doesn’t.

False confidence examples

  1. Snapshots of entire trees — any change updates the snap; bugs “expected.”
  2. Mocked everything — test verifies mock configuration.
  3. Untested catch blocks with empty handlers that still “run.”
  4. CSS/UI states not represented as branches in JS — uncovered UX.

What coverage is good for

  • Finding untested modules after a feature lands.
  • PR diffs: “new file at 0%” is a smell.
  • Critical paths (auth, payments, permissions) with high branch coverage goals.
  • Dead code hunts when combined with other signals.
vitest run --coverage

Read uncovered lines in the HTML report — don’t only watch the badge percentage.

Reasonable policy

Area Policy
Design system primitives High branch coverage + a11y tests
Glue / generated code Exclude from totals
Critical money paths High coverage + E2E
Experimental pages Don’t block on global 90%

Global thresholds (e.g. 80%) can work if not gamed and exclusions are reviewed.

// vitest config sketch
"coverage": {
  "thresholds": { "branches": 70, "lines": 70 },
  "exclude": ["**/*.stories.*", "**/generated/**"]
}

Mutation testing (next level)

Tools like Stryker change code and check if tests fail. If coverage is high but mutants survive, assertions are weak. Expensive — use on core packages.

Footguns

  1. Counting stories/E2E toward unit coverage inconsistently.
  2. Excluding hard files instead of testing them.
  3. Failing CI on 0.1% drops from line shuffling.
  4. No review of what’s uncovered in security-sensitive code.

Interview out-loud answer

“Coverage shows what ran, not what’s correct. I use it to find gaps and protect critical branches, not as a vanity 100% goal. I watch for tests without assertions and prefer behavior checks with Testing Library over line-chasing.”

Diff coverage

Prefer gates on new lines in a PR (diff coverage) over global percentages that punish legacy. A 40% global codebase can still require 80%+ on touched lines — that drives behavior without fake tests on dead modules.

Extra practice

Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.

Further reading

Related guides