Coverage That Means Something
Stage 3 · Testing, Quality & Craft · B.U.I.L.D. letter: D
You open the CI report. 94% coverage. Green. It feels good — until production goes down because the one branch you didn't think about wasn't the uncovered one. It was the covered one. With no assertions.
⚠️ The vibe trap
Coverage is one of the most gamed metrics in software. A test can execute every line of your code and assert absolutely nothing — and your coverage report will still show 100%. The number looks like proof. It isn't. Coverage tells you which code was run during tests. It says nothing about whether you actually checked that the code did the right thing. Chasing 100% is a trap that wastes your time and gives false confidence to everyone on the team.
🔬 What coverage actually measures
Coverage tools instrument your source code — they wrap each line (and each branch) with a counter. When your tests run, those counters tick. Afterward, the tool reports which counters were hit and which weren't.
Line coverage tells you which source lines were executed at least once. Branch coverage goes deeper: for every if/else, ternary, switch, or short-circuit (&&, ||), it asks "did tests ever take the true path AND the false path?" Branch coverage is the one that actually matters.
# Run your tests with V8 coverage (Node / Vitest / Jest)
npx vitest run --coverage
# Or with Jest
npx jest --coverage --coverageReporters=text-summary
# Output ends with a table like:
# File | % Stmts | % Branch | % Funcs | % Lines
# pricing.ts | 91.67 | 66.67 | 83.33 | 91.67
Mental model: Think of line coverage as "did the road get paved?" and branch coverage as "did we drive both directions at the fork?" A road that was paved but never forked left is still a road with untested risk.
Why it matters: A bug almost always lives in a branch — the else you forgot, the null case you didn't consider, the early return that fires when it shouldn't. Lines can be green while every fork in your logic is untested.
Common mistake: Running coverage only on utility files and ignoring the business-logic layer. Your formatCurrency helper doesn't need 100% coverage. Your checkout-flow applyDiscount function does.
🎭 High coverage, zero value — the assertion-free test
Here is the single most dangerous test you can write:
// USELESS: 100% line coverage, zero assertions
import { applyDiscount } from './pricing.js';
test('applyDiscount runs without crashing', () => {
applyDiscount(100, 'SAVE20'); // executes all lines
applyDiscount(0, 'FREESHIP'); // executes more lines
applyDiscount(-50, 'BOGUS'); // the negative-price bug fires here... silently
});
Every line of applyDiscount ran. Coverage: 100%. The function returned -40 for a negative input — a real bug — and the test passed because you never checked what came back.
// MEANINGFUL: same coverage, actual value
import { applyDiscount } from './pricing.js';
test('applyDiscount: valid code reduces price correctly', () => {
expect(applyDiscount(100, 'SAVE20')).toBe(80);
});
test('applyDiscount: invalid price throws instead of returning negative', () => {
expect(() => applyDiscount(-50, 'SAVE20')).toThrow('Price must be positive');
});
test('applyDiscount: unknown code returns original price unchanged', () => {
expect(applyDiscount(100, 'BOGUS')).toBe(100);
});
Mental model: An assertion-free test is theater. The curtain goes up, the actors walk around, the curtain comes down — but no one checked whether the play made sense.
Why it matters: Teams under deadline pressure write assertion-free tests to hit a coverage target. Every one of those tests is a lie you're telling to your future self.
Common mistake: Writing expect(fn).not.toThrow() as your only assertion. That's one small step above nothing — it only confirms the function didn't crash, not that it returned the right thing.
🗺️ Reading a real coverage report
After running coverage, open the HTML report (coverage/index.html) or read the terminal output. Here is what to look for:
Coverage Summary
===================================
File Stmts Branch Funcs Lines
--------------------------------------------------
src/
auth.ts 100% 100% 100% 100% ✅ critical path, fully covered
pricing.ts 91% 66% 83% 91% ⚠️ two branches uncovered
emailSender.ts 45% 30% 50% 45% 🔴 barely touched
utils/
slugify.ts 100% 100% 100% 100% fine, low risk anyway
colorPicker.ts 22% 10% 25% 22% low risk, don't care
--------------------------------------------------
Total 82% 72% 79% 82%
The question is never "is the total above 80%?" The question is: which files are under-covered AND high-risk?
auth.tsat 100%: great, authentication is the critical path.pricing.tsat 66% branch: two pricing forks were never tested — this is where bugs live.emailSender.tsat 30% branch: probably fine if email is non-critical, but flag it.colorPicker.tsat 10%: utility, low stakes, move on.
Mental model: Coverage is a heat map, not a scoreboard. Use it to find cold spots in risky territory, not to celebrate hot spots in safe territory.
Why it matters: A project with 60% total coverage can be safer than one with 95% if the 60% covers every critical path and the 95% is stuffed with assertion-free boilerplate.
Common mistake: Treating every uncovered line as equally important. File risk is not uniform. Identify your critical path first — auth, payments, data mutations — then demand coverage there.
🎯 The right coverage strategy
You don't need 100%. You need meaningful coverage of the code that can hurt you.
Here is how to think about it:
-
Identify the critical path. What flows, if broken, would take down the product or lose money? Auth, billing, data persistence, core business rules. Cover these thoroughly with branch coverage.
-
Identify risky logic. Any function with conditional branches, type coercions, external-data parsing, or error handling is risky. Cover every fork.
-
Ignore the noise. Pure UI rendering, log statements, dev-only utilities, and glue code that has no branching are low risk. Don't pad your test suite to cover them.
-
Use coverage to find what you missed, not to celebrate what you hit. After writing tests for a feature, run coverage and ask: "What branch did I not exercise?" That answer is your next test.
// Before looking at coverage, you wrote this:
test('getUserRole returns "admin" for admin users', () => {
expect(getUserRole({ role: 'admin', active: true })).toBe('admin');
});
// Coverage report reveals the `active: false` branch was never hit.
// You write the missing test:
test('getUserRole returns "guest" for inactive users regardless of role', () => {
expect(getUserRole({ role: 'admin', active: false })).toBe('guest');
});
// That second test just caught a real access-control bug.
Mental model: Coverage is a flashlight, not a trophy. Use it to illuminate dark corners, then decide if those corners are dangerous.
Why it matters: The bugs that kill products live in the branches you didn't think to test. Coverage shows you exactly where you didn't think.
Common mistake: Setting a coverage threshold in CI at, say, 80% and then writing empty tests to stay above it whenever you add new code. The threshold becomes adversarial. Better to enforce coverage on specific high-risk files than to enforce a global percentage.
🛠️ Your mission
- Run your test suite with coverage enabled (see the
bashblock above). - Open the branch-coverage column and find the file with the worst branch coverage that handles something real — not a utility, not a formatter.
- Open that file. Find the uncovered branch (the coverage HTML report highlights it in red).
- Write one test that exercises that branch AND asserts on the output.
- Re-run coverage and confirm the branch turns green.
- Ask yourself: did writing that test reveal anything surprising about how the code behaves?
If your project doesn't have a test runner yet, wire up Vitest (npm install -D vitest @vitest/coverage-v8) and write a single coverage-driven test for your riskiest function.
✅ You're done when…
- Coverage report generated and you can read the Branch column without confusion
- Pre-Ship Checklist updated with "branch coverage check on critical path" as a step
- At least one previously uncovered branch now has a meaningful assertion-bearing test
- You can explain to someone why 100% line coverage does not mean the code is tested
- No assertion-free tests exist in your suite (grep for
test(blocks with zeroexpect)
➡️ Next: Clean Code. Build It Right, Or Don't Build It At All. 🏛️