How to check branch coverage
Run npx vitest run --coverage --coverage.include='src/**' and read the % Branch column rather than % Lines. On the project below, discount.js reports 100% lines and 50% branches. Every line executed, and one side of a ternary plus one side of a ?? default never ran at all.
Why check this
Branch coverage is the column that answers the question a line count cannot: did the tests take both roads, or only the one the happy path uses. Run it on any pull request that adds a condition, and on any module where a bug report says "it works for normal users".
The failure it prevents has a shape. A pricing function gets a VIP tier, one test covers a standard order, the line runs, coverage reads 100%, and the VIP discount ships untested. Nothing in the line column can show this, because the line ran both times. On this project the standard test alone leaves two of four branches in a five-line function untaken, and the table still prints 100% under % Lines.
A second reason to look: branch points are not where people expect. order.shipping ?? 5 is a branch. So is a default parameter, an optional chain and a short-circuit. Step 3 lists them by type and line, which is usually a surprise the first time.
Prerequisites
- Node 22 and npm. Every figure here came from Node 22.23.2, Vitest 5.0.0 and
@vitest/coverage-v85.0.0. - A project to measure. Build it in an empty directory outside your repository and run every command here inside it. See the Vitest coverage guide.
mkdir covdemo && cd covdemo
npm init -y > /dev/null
npm pkg set type=module
npm install -D vitest@5 @vitest/coverage-v8@5
mkdir src test
- Four source files.
discount.jsis the specimen: two branch points, five lines, and one test.
cat > src/cart.js <<'EOF'
export function addItem(cart, item) {
const next = cart.slice();
const at = next.findIndex((i) => i.sku === item.sku);
if (at === -1) {
next.push({ ...item });
} else {
next[at] = { ...next[at], qty: next[at].qty + item.qty };
}
return next;
}
export function removeItem(cart, sku) {
return cart.filter((i) => i.sku !== sku);
}
export function itemCount(cart) {
return cart.reduce((n, i) => n + i.qty, 0);
}
export function subtotal(cart) {
return cart.reduce((n, i) => n + i.qty * i.price, 0);
}
EOF
cat > src/discount.js <<'EOF'
export function priceFor(order) {
const rate = order.tier === 'vip' ? 0.2 : 0.1;
const shipping = order.shipping ?? 5;
const net = order.total * (1 - rate);
return Math.round(net + shipping);
}
EOF
cat > src/format-address.js <<'EOF'
export function formatAddress(a) {
const lines = [a.street, a.city];
if (a.postcode) lines.push(a.postcode);
lines.push(a.country.toUpperCase());
return lines.join('\n');
}
EOF
cat > src/export-job.js <<'EOF'
export function makeRows(n) {
const rows = [];
for (let i = 0; i < n; i += 1) rows.push({ sku: 'A' + i, qty: (i % 7) + 1, price: (i % 13) + 0.5 });
return rows;
}
export async function exportRows(rows) {
const out = [];
for (const row of rows) {
out.push(`${row.sku},${row.qty},${(row.qty * row.price).toFixed(2)}`);
if (out.length % 2000 === 0) await new Promise((r) => setImmediate(r));
}
return out.join('\n');
}
EOF
- Three test files. The deadline test in
export-job.test.jsis deliberately unstable and fails about one run in five here; rerun if a coverage run exits non-zero.
cat > test/cart.test.js <<'EOF'
import { describe, it, expect } from 'vitest';
import { addItem, removeItem, itemCount, subtotal } from '../src/cart.js';
describe('cart', () => {
it('adds a new sku', () => {
expect(addItem([], { sku: 'A1', qty: 1, price: 10 })).toEqual([{ sku: 'A1', qty: 1, price: 10 }]);
});
it('merges an existing sku', () => {
const cart = [{ sku: 'A1', qty: 1, price: 10 }];
expect(addItem(cart, { sku: 'A1', qty: 2, price: 10 })[0].qty).toBe(3);
});
it('removes a sku', () => {
expect(removeItem([{ sku: 'A1', qty: 1, price: 10 }], 'A1')).toEqual([]);
});
it('counts items', () => {
expect(itemCount([{ sku: 'A1', qty: 2, price: 10 }])).toBe(2);
});
it('sums the subtotal', () => {
expect(subtotal([{ sku: 'A1', qty: 2, price: 10 }])).toBe(20);
});
});
EOF
cat > test/discount.test.js <<'EOF'
import { describe, it, expect } from 'vitest';
import { priceFor } from '../src/discount.js';
describe('priceFor', () => {
it('applies the standard rate', () => {
expect(priceFor({ tier: 'standard', total: 100, shipping: 5 })).toBe(95);
});
});
EOF
cat > test/export-job.test.js <<'EOF'
import { describe, it, expect } from 'vitest';
import { makeRows, exportRows } from '../src/export-job.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('exportRows', () => {
it('writes one line per row', async () => {
const csv = await exportRows(makeRows(3));
expect(csv.split('\n')).toHaveLength(3);
});
it('finishes the export before the deadline', async () => {
const rows = makeRows(20000);
const deadline = sleep(25).then(() => 'deadline');
const result = await Promise.race([exportRows(rows), deadline]);
expect(result).not.toBe('deadline');
});
it('waits for the upload to settle', async () => {
await sleep(700);
expect(await exportRows([])).toBe('');
});
});
EOF
Steps
- Step 1.
Run coverage over the source glob and compare the branch column with the line column.
npx vitest run --coverage --coverage.include='src/**'-------------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s -------------------|---------|----------|---------|---------|------------------- All files | 85.29 | 60 | 92.3 | 84 | discount.js | 100 | 50 | 100 | 100 | 2-3 format-address.js | 0 | 0 | 0 | 0 | 2-5 -------------------|---------|----------|---------|---------|------------------- =============================== Coverage summary =============================== Statements : 85.29% ( 29/34 ) Branches : 60% ( 6/10 ) Functions : 92.3% ( 12/13 ) Lines : 84% ( 21/25 ) ================================================================================discount.jsis at 100% lines and 50% branches, and itsUncovered Line #sare 2 and 3. Both of those lines ran. - Step 2.
Write the same measurement as JSON so the branch detail survives the table.
npx vitest run --coverage --coverage.include='src/**' --coverage.reporter=json --coverage.reportsDirectory=coverageRUN v5.0.0 C:/…/covdemo Coverage enabled with v8 Test Files 3 passed (3) Tests 9 passed (9) Duration 1.63s (tests 57%, transform 17%, import 14%, worker 12%)The
jsonreporter prints no table. It writescoverage/coverage-final.json, which carries abranchMapand a hit count per branch. - Step 3.
Print every branch whose hit count is zero, with its kind and its line.
node -e "const {basename}=require('path');const c=require('./coverage/coverage-final.json');for(const [f,d] of Object.entries(c))for(const [id,b] of Object.entries(d.branchMap))if(d.b[id].includes(0))console.log(basename(f), b.type, 'line ' + b.loc.start.line, d.b[id].join(' '))"discount.js cond-expr line 2 0 1 discount.js binary-expr line 3 1 0 format-address.js if line 3 0 0The two numbers are hit counts per side.
cond-expr line 2 0 1is the ternary with its VIP side never taken;binary-expr line 3 1 0is the??whose default value never applied. - Step 4.
Add the case for the untaken ternary side and measure again.
// add inside describe('priceFor', …) in test/discount.test.js it('applies the vip rate', () => { expect(priceFor({ tier: 'vip', total: 100, shipping: 5 })).toBe(85); });npx vitest run --coverage --coverage.include='src/**'-------------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s -------------------|---------|----------|---------|---------|------------------- All files | 85.29 | 70 | 92.3 | 84 | discount.js | 100 | 75 | 100 | 100 | 3 format-address.js | 0 | 0 | 0 | 0 | 2-5 -------------------|---------|----------|---------|---------|------------------- =============================== Coverage summary =============================== Statements : 85.29% ( 29/34 ) Branches : 70% ( 7/10 ) Functions : 92.3% ( 12/13 ) Lines : 84% ( 21/25 ) ================================================================================Statements, functions and lines did not move. Only the branch column did, from 50 to 75, and line 2 left the uncovered list.
- Step 5.
Add the case that omits
shipping, so the??default runs.// add inside the same describe block it('falls back to the default shipping', () => { expect(priceFor({ tier: 'standard', total: 100 })).toBe(95); });npx vitest run --coverage --coverage.include='src/**'% Coverage report from v8 -------------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s -------------------|---------|----------|---------|---------|------------------- All files | 85.29 | 80 | 92.3 | 84 | format-address.js | 0 | 0 | 0 | 0 | 2-5 -------------------|---------|----------|---------|---------|------------------- =============================== Coverage summary =============================== Statements : 85.29% ( 29/34 ) Branches : 80% ( 8/10 ) Functions : 92.3% ( 12/13 ) Lines : 84% ( 21/25 ) ================================================================================discount.jsis gone from the table. The text reporter lists only files with a gap, so a file dropping out is the success signal.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| % Lines 100 and % Branch 50 on one file | Every line ran, half the conditions took one road | Open the file and list its conditions, not its lines |
| A line number under Uncovered Line #s on a 100% line | The line ran, a branch on it did not | Look for a ternary, a ??, a && or a default value on it |
| cond-expr … 0 1 in the JSON | A ternary whose first side never evaluated | Write the case that makes the condition true |
| binary-expr … 1 0 in the JSON | A nullish or short-circuit operator whose right side never evaluated | Call with the property absent, null or falsy |
| if … 0 0 in the JSON | Neither side ran, so the whole function is untested | The gap is a missing test file, not a missing case |
| The file stops appearing in the table | All four columns reached 100 | Nothing |
Common mistakes
Thresholds
What to check next
- How to check test coverage: the whole report, and the files it leaves out of the denominator.
- How to check code coverage threshold: setting a branch gate that fails the build.
- How to detect flaky tests: the new cases you add here are the ones most likely to be unstable.
- How to check which tests are slowest: more branch cases means a longer suite, so watch where the time goes.
- Pull request checklist: where branch coverage sits among the other gates.
FAQ
What is the difference between branch coverage and line coverage?
Line coverage counts lines that executed. Branch coverage counts the roads out of each decision point. A line holding a ternary counts once for lines and twice for branches, so one test can take it to 100% lines and 50% branches at the same time.
Which constructs count as a branch?
In this project's report: a ternary (cond-expr), a ??, && or || (binary-expr), and an if with or without an else. Default parameters and optional chaining count too. Step 3 names the kind of every one it finds.
Is 100% branch coverage worth chasing?
It is a cheap gate on new code and an expensive one on old code. The measurement above shows why it is worth more than lines: adding a real test case moved the branch column and left every other column untouched.
Why did my branch percentage fall with no code change?
Check whether the provider or coverage.include changed. Adding files to the include glob adds their branches to the denominator, the way format-address.js contributes 2 untaken branches above without any test touching it.
Can I see which side of a condition is missing in the terminal?
Not from the table, which gives you a line number only. The JSON reporter in steps 2 and 3 carries the per-side hit counts, and the html reporter marks the missing side with an E or I in the rendered source.
Verified
Verified by Maks Vernynode 22.23.2vitest 5.0.0@vitest/coverage-v8 5.0.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate6 minpublished updated Maks Verny