There is a special kind of confidence that comes from a 100% coverage badge sitting at the top of your README, glowing like a productivity halo. It says: every line of this code has been run by a test. It does not say — and this is the whole problem — that every line was run for the right reasons. Line coverage counts the lines your tests touched. It does not count the decisions your code makes on those lines. A ?:, an if, a && — one line, two futures, and your test suite only ever visited one of them.
This bubbled up from it-journey’s Testing Integration: Tiered CI/CD Test Gates quest, which wires coverage thresholds into CI so an untested change fails the build instead of merging quietly. That’s the right instinct. This is the part where the threshold you picked is the wrong one, shown with a function that scores a perfect 100% on lines while shipping a bug.
The setup: one function, one decision, one test
Here’s a tiny ES-module project. A pricing function that gives VIPs 20% off, and a test that checks exactly that:
// discount.js
// Apply a member discount. VIPs pay 80%; everyone else pays full price.
export function priceFor(subtotal, isVip) {
return isVip ? subtotal * 0.8 : subtotal;
}
// discount.test.js
import assert from 'node:assert';
import { test } from 'node:test';
import { priceFor } from './discount.js';
test('vip gets 20% off', () => {
assert.equal(priceFor(100, true), 80);
});
Install the coverage tool and run the tests under it. c8 wraps Node’s built-in V8 coverage, so it works with node --test and needs no instrumentation step:
$ npm install --save-dev c8
$ npx c8 node --test
-------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files | 100 | 66.66 | 100 | 100 |
discount.js | 100 | 66.66 | 100 | 100 | 3
-------------|---------|----------|---------|---------|-------------------
Read that row slowly. Lines: 100. Statements: 100. Functions: 100. Branches: 66.66. One test, and every line reports as covered — because the single line that holds the ternary did execute. The test called priceFor with isVip = true, the interpreter ran line 3, and line coverage does not care that the : subtotal half of that line was never evaluated. The non-VIP path — the one that decides what a normal customer pays — has never run.
Notice the last column, too: Uncovered Line #s points at line 3 even though % Lines is 100. That column isn’t only about lines; it’s telling you the branch that lives on line 3 is the one nobody tested.
The gate everybody reaches for — and waves the bug through
The it-journey quest’s move is to make the threshold block the build: --check-coverage with a floor. The number most people reach for first is lines, because “80% line coverage” is the phrase everyone repeats:
$ npx c8 --check-coverage --lines 80 node --test
$ echo "exit=$?"
exit=0
Exit 0. Green. The build passes, the PR merges, and the branch that decides what every non-VIP pays to your store went out untested with a gold star on it. The gate did exactly what you asked — it just asked the wrong question.
The fix: gate on branches
Add a branch floor. Now the same suite, same code, tells the truth:
$ npx c8 --check-coverage --branches 80 node --test
...
-------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files | 100 | 66.66 | 100 | 100 |
discount.js | 100 | 66.66 | 100 | 100 | 3
-------------|---------|----------|---------|---------|-------------------
ERROR: Coverage for branches (66.66%) does not meet global threshold (80%)
$ echo "exit=$?"
exit=1
You’ll know it worked when the run exits non-zero and prints a line naming the metric and both numbers — branches (66.66%) against the 80% you set. That non-zero exit is the whole point: in CI it fails the job, and the untested path can’t merge behind a 100% line badge.
(Why 66.66 and not 50 for a two-armed ternary? V8 counts three branch points in this snippet, not two, and two of them were exercised. The exact denominator is the compiler’s business; the signal you care about is that it’s under 100 and the failing arm is the non-VIP path.)
Close the gap, watch it go green
Coverage tools don’t write tests; they only tell you which ones are missing. Add the case the number was complaining about:
test('non-vip pays full price', () => {
assert.equal(priceFor(100, false), 100);
});
$ npx c8 --check-coverage --branches 80 node --test
-------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
discount.js | 100 | 100 | 100 | 100 |
-------------|---------|----------|---------|---------|-------------------
$ echo "exit=$?"
exit=0
Branches at 100, exit 0, and this time the green means what you thought the first green meant. That second test is the entire difference between “the line ran” and “the decision was checked.”
The second lie: the file no test ever imported
There’s a bigger gap hiding behind the badge, and it isn’t about branches at all. By default, coverage only reports on files your tests actually loaded. A module that no test imports doesn’t score badly — it doesn’t score at all. It’s invisible.
Drop an entirely untested file next to the tested one:
// refund.js — no test ever imports this. Pure, total, untested risk.
export function refund(amount, isFraud) {
return isFraud ? 0 : amount;
}
$ npx c8 node --test
-------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
discount.js | 100 | 100 | 100 | 100 |
-------------|---------|----------|---------|---------|-------------------
refund.js is nowhere in that table. Your “100% coverage” means “100% of the one file the tests bothered to import.” The refund logic — the code that decides whether to hand money back — could be anything.
The fix is --all (with --src pointing at your source), which tells c8 to include every source file whether a test touched it or not:
$ npx c8 --all --src . --check-coverage --lines 80 node --test
-------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files | 50 | 80 | 50 | 50 |
discount.js | 100 | 100 | 100 | 100 |
refund.js | 0 | 0 | 0 | 0 | 1-4
-------------|---------|----------|---------|---------|-------------------
ERROR: Coverage for lines (50%) does not meet global threshold (80%)
$ echo "exit=$?"
exit=1
There it is: refund.js at 0%, the overall number cut in half, and the gate failing. You’ll know it worked when a file with no tests drops your total instead of politely excusing itself. --all is what turns coverage from “grade the code I remembered to test” into “grade all the code.”
Make the gate permanent (so nobody forgets the flags)
Flags you type by hand are flags you forget in CI. Put the whole policy in a .c8rc.json next to package.json, and a bare npx c8 node --test enforces all of it:
{
"all": true,
"src": ["."],
"check-coverage": true,
"branches": 80,
"lines": 80,
"functions": 80,
"statements": 80
}
$ npx c8 node --test
...
ERROR: Coverage for lines (50%) does not meet global threshold (80%)
ERROR: Coverage for functions (50%) does not meet global threshold (80%)
ERROR: Coverage for statements (50%) does not meet global threshold (80%)
$ echo "exit=$?"
exit=1
No flags on the command line, and the gate still fires — because it lives in the repo now, not in someone’s memory of how to run the tests. Wire npx c8 node --test into your CI’s test step and an untested branch fails the build the same way a broken test does.
When this goes wrong
- You set
branchesbut notall. Then you’ve plugged one leak and left the other open: every branch in the files you test is checked, and the file you forgot to test entirely is still invisible. Set both. The two floors catch two different lies. - Branch coverage is a floor, not a proof. 100% branches means every arm executed at least once — not that you asserted the right thing on each. A test that runs the non-VIP path but forgets to check the number still counts toward branch coverage. The gate stops untested paths; it can’t stop lazy assertions.
--allwith the wrong--srcreports 0% on things you don’t care about. Pointsrcat your actual source directory (["src"], not["."]) or c8 will happily include scripts, config, and fixtures and tank your number for no reason. In this demo everything lives in one folder, so.is fine; in a real project, be specific.- The percentage denominators are V8’s, not yours. Don’t reverse-engineer the exact branch count to hit a round number. Chasing 100% for its own sake is how you get tests that assert nothing. Set a floor that catches the untested path, then write tests because the code needs them — not because the badge wants feeding.
The one-line version: line coverage tells you which lines ran, branch coverage tells you which decisions got tested, and --all tells you about the files you’d rather not think about. Gate on all three, keep them in .c8rc.json, and the green badge finally earns the confidence it was borrowing.
All console output above is real, captured from c8 12.0.0 on Node v22.23.1 (Ubuntu 24.04.4 LTS). These blocks aren’t run by this site’s build sandbox — it has no network to npm install c8 — so they’re pasted from a real local run, not harness-verified.