---
title: 'Coverage Measurement'
source: 'https://academia.sh/en/courses/unit-testing/coverage-measurement'
course: 'Unit Testing and Test-Driven Development'
language: en
updated: '2026-08-23T14:25:21+00:00'
license: 'CC BY-SA 4.0'
---

# Coverage Measurement

Hand-counting statement, branch, and path coverage on the same function, showing with a run the bug a codebase with one hundred percent statement coverage missed, and the limit of using a coverage percentage as a target.

The previous lessons separated **what** tests verify: which assertion catches which
regression, which test double answers which question. The question that was never asked is
this: how much of the codebase did the tests written so far touch?

The common answer to this question is a percentage, and that percentage is calculated in
at least three different ways. The three measurements give different numbers on the same
code; the difference between them determines what the measurement says. This lesson counts
all three by hand on the same function.

## The Function to Measure

The late-fee calculation contains two conditions: the free-day allowance being exceeded,
and the book being returned to a different location than the one it was borrowed from.

```js
// fee.mjs — late fee and cross-location return fee
export const LATE_FEE = { freeDays: 3, dailyFee: 2, cap: 40, crossLocationFee: 5 };

export function lateFee(record, checkin) {
  const delay = checkin.day - record.dueDay;
  let fee = 0;
  if (delay > LATE_FEE.freeDays) {
    fee = (delay - LATE_FEE.freeDays) * LATE_FEE.dailyFee;
  }
  if (checkin.location !== record.location) {
    fee += LATE_FEE.crossLocationFee;
  }
  return Math.min(fee, LATE_FEE.cap);
}
```

Two tests have been written. Both pass.

```js
// coverage.test.mjs — two tests, one hundred percent statement coverage
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { lateFee } from './fee.mjs';

const RECORD = { dueDay: 1014, location: 'central' };

test('a delayed return is charged per day', () => {
  assert.equal(lateFee(RECORD, { day: 1024, location: 'central' }), 14);
});

test('a delayed cross-location return gets an extra fee', () => {
  assert.equal(lateFee(RECORD, { day: 1024, location: 'hillside' }), 19);
});
```

```sh
node --test --test-reporter=tap coverage.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - a delayed return is charged per day
ok 2 - a delayed cross-location return gets an extra fee
# tests 2
# pass 2
# fail 0
```

## Counting the Three Measurements by Hand

Coverage measurement is defined once the unit to be counted is chosen.

**Statement coverage** is the ratio of executed lines to the total number of lines.
**Branch coverage** counts whether both the true and the false outcome of each condition
were seen. **Path coverage** counts the **combinations** of condition outcomes: a function
with two conditions has four paths, one with three has eight.

A tracing copy of the same function gives these three numbers directly.

```js
// measure.mjs — the version of the same function that traces its statement, branch, and path coverage
import { LATE_FEE } from './fee.mjs';

const statements = new Set();
const branches = new Set();
const paths = new Set();

function tracedFee(record, checkin) {
  statements.add('S1');
  const delay = checkin.day - record.dueDay;
  statements.add('S2');
  let fee = 0;
  statements.add('S3');
  const d1 = delay > LATE_FEE.freeDays;
  branches.add(d1 ? 'D1-true' : 'D1-false');
  if (d1) {
    statements.add('S4');
    fee = (delay - LATE_FEE.freeDays) * LATE_FEE.dailyFee;
  }
  statements.add('S5');
  const d2 = checkin.location !== record.location;
  branches.add(d2 ? 'D2-true' : 'D2-false');
  if (d2) {
    statements.add('S6');
    fee += LATE_FEE.crossLocationFee;
  }
  statements.add('S7');
  paths.add(`${d1 ? 'T' : 'F'}${d2 ? 'T' : 'F'}`);
  return Math.min(fee, LATE_FEE.cap);
}

const RECORD = { dueDay: 1014, location: 'central' };
tracedFee(RECORD, { day: 1024, location: 'central' });
tracedFee(RECORD, { day: 1024, location: 'hillside' });

const ALL_STATEMENTS = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7'];
const ALL_BRANCHES = ['D1-true', 'D1-false', 'D2-true', 'D2-false'];
const ALL_PATHS = ['TT', 'TF', 'FT', 'FF'];

const report = (label, all, seen) => {
  const missing = all.filter((x) => !seen.has(x));
  const percent = Math.round(((all.length - missing.length) / all.length) * 100);
  console.log(`${label} ${all.length - missing.length}/${all.length} (${percent}%)  missing: ${missing.join(', ') || 'none'}`);
};

report('statement coverage', ALL_STATEMENTS, statements);
report('branch coverage   ', ALL_BRANCHES, branches);
report('path coverage     ', ALL_PATHS, paths);
```

```
statement coverage 7/7 (100%)  missing: none
branch coverage    3/4 (75%)  missing: D1-false
path coverage      2/4 (50%)  missing: FT, FF
```

Same two tests, same function, three different numbers. The statement measurement looks
complete: seven statements out of seven ran. The branch measurement reports one missing:
the false outcome of the first condition — that is, a return with no delay — was never
tried. The path measurement reports two missing: neither location case for a non-delayed
return ever occurred.

The order of the three numbers is not a coincidence. Statement coverage is always greater
than or equal to branch coverage, and branch coverage is always greater than or equal to
path coverage. For a statement to run, entering one branch is enough, but seeing both
outcomes of a branch needs separate inputs, and the combination of paths needs still more.

## What One Hundred Percent Statement Coverage Misses

The two uncovered paths are not an empty possibility — each is a runnable input.

```js
// missed.mjs — what the function returns on the uncovered paths
import { lateFee } from './fee.mjs';

const RECORD = { dueDay: 1014, location: 'central' };
const CASES = [
  ['FT  not delayed, cross-location', { day: 1016, location: 'hillside' }, 0],
  ['FF  not delayed, same location', { day: 1016, location: 'central' }, 0],
];

for (const [label, checkin, expected] of CASES) {
  const actual = lateFee(RECORD, checkin);
  console.log(`${label}: fee ${actual}, expected ${expected} -> ${actual === expected ? 'matches' : 'WRONG'}`);
}
```

```
FT  not delayed, cross-location: fee 5, expected 0 -> WRONG
FF  not delayed, same location: fee 0, expected 0 -> matches
```

The written rule defines the cross-location return fee as an addition to the late fee: a
book returned on time is free, whatever location it is left at. The implementation,
however, applies the location check independently of the delay, and takes five units from
a member who returns on time.

This bug was sitting underneath a codebase with one hundred percent statement coverage.
The measurement was not wrong; **the question it asked** was narrow. Statement measurement
asks "did this line run," not "under what conditions did this line run."

When the missing paths are turned into tests, the bug shows up in the report.

```js
// coverage.test.mjs — version 2: all four paths are tested
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { lateFee } from './fee.mjs';

const RECORD = { dueDay: 1014, location: 'central' };

test('a delayed return is charged per day', () => {
  assert.equal(lateFee(RECORD, { day: 1024, location: 'central' }), 14);
});

test('a delayed cross-location return gets an extra fee', () => {
  assert.equal(lateFee(RECORD, { day: 1024, location: 'hillside' }), 19);
});

test('a return within the free-day allowance is free', () => {
  assert.equal(lateFee(RECORD, { day: 1016, location: 'central' }), 0);
});

test('a cross-location return within the free-day allowance is free too', () => {
  assert.equal(lateFee(RECORD, { day: 1016, location: 'hillside' }), 0);
});
```

```sh
node --test --test-reporter=tap coverage.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - a delayed return is charged per day
ok 2 - a delayed cross-location return gets an extra fee
ok 3 - a return within the free-day allowance is free
not ok 4 - a cross-location return within the free-day allowance is free too
# tests 4
# pass 3
# fail 1
```

The fix separates a return within the free-day allowance with an early return.

```js
// fee.mjs — version 2: the cross-location fee is only charged on a delayed return
export const LATE_FEE = { freeDays: 3, dailyFee: 2, cap: 40, crossLocationFee: 5 };

export function lateFee(record, checkin) {
  const delay = checkin.day - record.dueDay;
  if (delay <= LATE_FEE.freeDays) return 0;
  let fee = (delay - LATE_FEE.freeDays) * LATE_FEE.dailyFee;
  if (checkin.location !== record.location) {
    fee += LATE_FEE.crossLocationFee;
  }
  return Math.min(fee, LATE_FEE.cap);
}
```

```sh
node --test --test-reporter=tap coverage.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - a delayed return is charged per day
ok 2 - a delayed cross-location return gets an extra fee
ok 3 - a return within the free-day allowance is free
ok 4 - a cross-location return within the free-day allowance is free too
# tests 4
# pass 4
# fail 0
```

The early return has a second effect: for a return within the free-day allowance, the
location condition is no longer evaluated at all. Four paths become three, because two
paths merge into a single exit. Reducing the number of paths is another way to raise
coverage besides writing tests, and it is usually cheaper.

## Reading the Percentage

The correct reading of a coverage percentage runs one way only. A low coverage number
gives **certain** information: a part of the code never ran. A high coverage number is
proof of nothing; it says the code ran, not that it behaved correctly. The tautological
test measured in the second lesson produces one hundred percent coverage and catches not a
single regression.

Two practical consequences follow from this. When coverage is used as a **target**, the
metric breaks down: the cheapest way to raise the number is to write assertion-free tests,
and that raises the percentage without producing any signal. When coverage is used as a
**list**, it is directly useful: the uncovered branches and paths are the names of
questions not yet asked. The bug above came straight out of that list.

The built-in test runner has an option that produces a coverage report. That option's
name, the report's format, and whether it is experimental depend on the runtime version;
the table it produces contains absolute file paths and details of the environment it ran
in. This is why coverage was counted by hand here: the **definition** of the measurement
is independent of the tool, its output is not.

## Summary

- Coverage measurement is defined once the unit to be counted is chosen; statement,
  branch, and path coverage give different numbers on the same code.
- In the measurement, two tests brought statement coverage to one hundred percent, branch
  coverage to seventy-five percent, and path coverage to fifty percent.
- Statement coverage is always greater than or equal to branch coverage, and branch
  coverage is always greater than or equal to path coverage.
- One of the uncovered paths harbored a real bug: the cross-location fee was being applied
  to a book returned on time.
- Low coverage gives certain information, high coverage is proof of nothing; coverage is
  read not as a target but as a list of unasked questions.

## Next Step

In this lesson, tests were written without looking inside the code: input was given, the
return value was checked. The spies and mocks in the sixth lesson, by contrast, looked
inside the code — they verified which call was made. Both approaches can verify the same
behavior, but they do not behave the same way in the face of a refactor. The next lesson
actually performs a refactor and shows, with a run, which of the two test forms breaks.
