Your green coverage report is not lying to you.
It is just not telling you the whole truth.
Here is what it is hiding.
What is structural code coverage
Structural code coverage measures how much of your source code was executed during a test run. It tells you which lines, statements, functions, and decision paths your tests touched — and which ones they didn’t.
It is also called white-box coverage because it looks inside the code itself, not just at inputs and outputs.
It is different from functional coverage. Functional coverage measures whether your tests validated business requirements. Structural coverage measures whether your tests executed the code — regardless of whether the assertions were meaningful.
Istanbul (via its CLI, NYC) is the standard tool for measuring structural coverage in JavaScript and TypeScript projects. It instruments your source code before execution, collects counts as your tests run, and generates reports in HTML, LCOV, JSON, or text format.
The four metrics
Line coverage
Line coverage measures the percentage of executable lines that were executed during the test run.
An executable line is any line that contains runnable code — not blank lines, not comments, not closing braces on their own.
function add(a, b) { // executable
return a + b; // executable
}
Line coverage is the easiest metric to achieve a high number on. It is the most commonly reported metric. It is also the least meaningful on its own because it only tells you a line ran — not what happened when it ran.
Statement coverage
Statement coverage measures the percentage of individual statements that were executed.
A statement and a line are not always the same thing. One line can contain multiple statements:
let x = 1; let y = 2;
That is one line but two statements. Statement coverage tracks each one separately.
In cleanly formatted code, line and statement coverage tend to be close. In minified or densely written files the gap can be significant. Statement coverage is slightly more precise than line coverage, but both share the same fundamental limitation — they tell you code ran, not which path through it was taken.
Function coverage
Function coverage measures whether each function or method in your codebase was called at least once during the test run.
function login(user) { ... } // called → counted
function resetPassword(user) { ... } // never called → not counted
Function coverage is a coarse metric. It only tells you a function was entered. It says nothing about what happened inside it — which branches ran, which paths were skipped, whether the output was correct.
A function with 10 conditional branches can show 100% function coverage while 9 of those branches are never tested. This is one of the most common gaps in a test suite — everything looks covered at the function level, but the internal logic is largely untested.
Branch coverage
Branch coverage measures whether every decision path in your code was executed.
Every conditional construct creates at least two branches — the truthy path and the falsy path. This includes:
-
if / elsestatements -
switchcases -
Ternary operators
? : -
Logical operators
&&and|| -
Optional chaining
?.
Branch coverage checks that both sides of every decision ran during your tests.
function getLabel(user) {
if (user.isAdmin) {
return "Admin";
} else {
return "User";
}
}
If your test only calls getLabel({ isAdmin: true }):
-
The
ifbranch runs — counted -
The
elsebranch never runs — not counted -
Branch coverage: 50%
Any bug hiding in the else path survives your test suite completely undetected — even if line coverage, statement coverage, and function coverage all look healthy.
Branch coverage is the hardest metric to achieve because the number of paths grows with every conditional in your codebase. A function with 5 independent if/else blocks has 32 possible branch combinations. Real-world code has hundreds of these across thousands of functions.
It is also the most meaningful metric. High branch coverage means your tests are genuinely exercising your logic — not just passing through functions.
Why 100% line coverage is not enough
This is the most important practical point in this article.
function login(user) {
if (user.isAdmin) {
return "Admin";
} else {
return "User";
}
}
Test suite calls only login({ isAdmin: true }).
| Metric | Result | Reason |
|---|---|---|
| Function coverage | 100% | The function was called |
| Line coverage | High | Most lines executed |
| Statement coverage | ~75% | return "User" never ran |
| Branch coverage | 50% | The else branch never executed |
The report looks healthy. The else path is completely untested. If there is a bug in return "User" — a wrong value, a missing transformation, a null reference — it will reach production.
This is the exact gap that gives teams false confidence. Line coverage is easy to achieve. Branch coverage is not. A codebase with 95% line coverage and 40% branch coverage is a codebase with a lot of untested logic.
How Istanbul measures all four
Istanbul works in four phases on every test run.
Phase 1 — Instrumentation
Before your tests run, Istanbul rewrites your source file. It inserts counters at every measurable point — every statement, every function entry, every branch.
Your original code:
function login(user) {
if (user.isAdmin) {
return "Admin";
} else {
return "User";
}
}
After instrumentation:
cov.s[1]++;
function login(user) {
cov.f[1]++;
cov.s[2]++;
if (user.isAdmin) {
cov.b[1][0]++;
cov.s[3]++;
return "Admin";
} else {
cov.b[1][1]++;
cov.s[4]++;
return "User";
}
}
cov.s tracks statements. cov.f tracks functions. cov.b tracks branches — the array index distinguishes the truthy path [0] from the falsy path [1].
This instrumented version is what your test runner actually executes — not your original source.
Phase 2 — Execution
Your tests run normally. Every time an instrumented line of code is hit, its counter increments. If a branch never runs, its counter stays at 0.
Phase 3 — Collection
When the test run finishes, all counts are stored in a global coverage object at global.__coverage__. The structure looks like this:
{
statementMap: { /* maps statement IDs to source locations */ },
fnMap: { /* maps function IDs to source locations */ },
branchMap: { /* maps branch IDs to decision points */ },
s: { 1: 1, 2: 1, 3: 1, 4: 0 }, // statement counts
f: { 1: 1 }, // function counts
b: { 1: [1, 0] } // branch counts [truthy, falsy]
}
A count of 0 means that construct was never executed during the test run.
Phase 4 — Reporting
Istanbul reads the coverage object and calculates percentages:
Line coverage = executed lines / total executable lines
Statement coverage = executed statements / total statements
Function coverage = called functions / total functions
Branch coverage = executed branches / total branch paths
It then generates reports — HTML for visual inspection, LCOV for CI integration, JSON for programmatic use, text for the terminal.
Why branch coverage is hardest to achieve
Every conditional adds new paths. The complexity grows fast.
A single if/else — 2 branches.
Two independent if/else blocks — 4 combinations.
Five independent if/else blocks — 32 combinations.
Ten — 1024.
Real functions have multiple conditionals, nested conditions, ternaries, and logical operators. Covering every path requires tests that specifically target each combination — not just tests that call the function once with a happy-path input.
The specific constructs that are easiest to miss:
The else branch of a guard clause:
function process(data) {
if (!data) return null; // tested
return transform(data); // tested
// but what if data is an empty object? edge case, untested
}
The false side of a ternary:
const label = user.isAdmin ? "Admin" : "User";
// if tests only ever create admin users, "User" is never reached
Logical short-circuit operators:
const name = user.profile && user.profile.name;
// if user.profile is always truthy in tests, the false path never runs
Default parameter branches:
function connect(config = defaultConfig) {
// if tests always pass a config, the default branch never runs
}
Each of these is a branch that Istanbul will flag as uncovered — and each one is a real execution path that could contain a real bug.
Limitations of Istanbul
Istanbul measures structural coverage. It does not measure whether your tests are meaningful.
A test that calls every function and hits every branch but never asserts anything will show 100% across all four metrics. Istanbul has no way to know that your assertions are missing, wrong, or too weak.
This means:
-
High coverage does not guarantee correct behavior
-
High coverage does not guarantee business requirements are validated
-
High coverage does not guarantee edge cases are handled correctly
Istanbul tells you what code ran. It does not tell you whether running that code proved anything.
The other limitation is complexity. Istanbul tracks individual branches but does not track path coverage — the specific combinations of branches that execute together. A function with 10 branches might have 100% branch coverage but only a fraction of the meaningful execution paths actually tested.
Conclusion
Structural code coverage gives you four metrics — line, statement, function, and branch. They are not interchangeable and they are not equally meaningful.
Line and statement coverage are easy to achieve and easy to misread as confidence. Function coverage tells you what was entered, not what was tested. Branch coverage is the hardest to achieve and the most honest signal about whether your logic is actually being exercised.
Istanbul makes all four visible. Use it to find the gaps — especially the branches sitting at 0 that your tests have never touched.
Code Coverage · Istanbul · JavaScript · Test Automation · Quality Engineering
