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

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
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
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

  1. 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.js is at 100% lines and 50% branches, and its Uncovered Line #s are 2 and 3. Both of those lines ran.

  2. 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=coverage
    
     RUN  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 json reporter prints no table. It writes coverage/coverage-final.json, which carries a branchMap and a hit count per branch.

  3. 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 0

    The two numbers are hit counts per side. cond-expr line 2 0 1 is the ternary with its VIP side never taken; binary-expr line 3 1 0 is the ?? whose default value never applied.

  4. 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.

  5. 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.js is 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

Sign: A line at 100% line coverage is listed under Uncovered Line #s.Cause: That column is not a list of unexecuted lines. It merges lines missing statement coverage with lines carrying an untaken branch, and the header does not say so. Read it together with the % Branch cell or the numbers look self-contradictory.
Sign: Branch coverage drops after a refactor that changed no logic.Cause: Replacing an if with a ?? or adding a default parameter creates branch points the old code did not have. The denominator grows, the tests stay the same, and the percentage falls without any new risk.
Sign: Adding a test raises nothing, because the file is already at 100% lines.Cause: Line coverage saturates first and stays there. Once a file reads 100% lines, the branch column is the only one a new case can move, which is why a line-only gate stops giving feedback long before the tests are done.

Thresholds

4 branches in a 5-line function Source: Measured on src/discount.js with @vitest/coverage-v8 5.0.0 on 2026-09-12: one ternary and one ?? default, counted as 2 branches each, of which 2 were taken by the single existing test.

What to check next

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.

intermediate6 minpublished updated Maks Verny