How to check test coverage

Install a coverage provider and run npx vitest run --coverage. The text reporter prints a table with % Stmts, % Branch, % Funcs and % Lines, then a summary counting covered lines over total lines. That number describes only the files a test imported, so set coverage.include before you trust it.

Why check this

Coverage is read at two moments: when a pull request adds code, and when a regression escapes and somebody asks which tests were supposed to catch it. Both readings depend on the denominator, and the denominator is the part nobody looks at.

The failure this prevents is specific. A file gets written, no test imports it, and the coverage report never mentions it. The build stays green at 100%, the dashboard stays green, and the file ships with nothing exercising it. On the project below that omission is worth 16 percentage points: the same tests over the same code report 100% or 84% depending on one configuration key.

What coverage settles is narrower than its reputation. It tells you which lines ran while the tests ran. It does not tell you that anything was asserted about them, that the assertions were correct, or that the inputs resembled production. Step 3 takes a file from 0% to 100% with a test that asserts nothing at all.

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 the suite with coverage turned on and read the whole report.

    npx vitest run --coverage
    
     RUN  v5.0.0 C:/…/covdemo
        Coverage enabled with v8
    
    Test Files  3 passed (3)
        Tests  9 passed (9)
     Duration  1.15s (tests 73%, import 10%, transform 9%, worker 8%)
    
    % Coverage report from v8
    ---------------|---------|----------|---------|---------|-------------------
    File           | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
    ---------------|---------|----------|---------|---------|-------------------
    All files      |     100 |       75 |     100 |     100 |
    discount.js   |     100 |       50 |     100 |     100 | 2-3
    ---------------|---------|----------|---------|---------|-------------------
    
    =============================== Coverage summary ===============================
    Statements   : 100% ( 29/29 )
    Branches     : 75% ( 6/8 )
    Functions    : 100% ( 12/12 )
    Lines        : 100% ( 21/21 )
    ================================================================================

    Four source files exist and the table names one. Lines : 100% ( 21/21 ) counts 21 lines in a project that has 25.

  2. Step 2.

    Count the files you wrote, not the files the tests loaded.

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

    Same tests, same code, 16 points lower. The denominator went from 21 lines to 25 and format-address.js appeared.

  3. Step 3.

    Add a test that calls the untested file and asserts nothing, then measure again.

    // test/smoke.test.js
    import { it } from 'vitest';
    import { formatAddress } from '../src/format-address.js';
    
    it('renders an address', () => {
      formatAddress({ street: '1 Main St', city: 'Kyiv', postcode: '01001', country: 'ua' });
    });
    
    npx vitest run --coverage --coverage.include='src/**'
    
    -------------------|---------|----------|---------|---------|-------------------
    File               | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
    -------------------|---------|----------|---------|---------|-------------------
    All files          |     100 |       70 |     100 |     100 |
    discount.js       |     100 |       50 |     100 |     100 | 2-3
    format-address.js |     100 |       50 |     100 |     100 | 3
    -------------------|---------|----------|---------|---------|-------------------
    
    =============================== Coverage summary ===============================
    Statements   : 100% ( 34/34 )
    Branches     : 70% ( 7/10 )
    Functions    : 100% ( 13/13 )
    Lines        : 100% ( 25/25 )
    ================================================================================

    Lines went from 84% to 100% on a test with no expect in it. Delete test/smoke.test.js afterwards.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Lines : 100% ( 21/21 ) on a 25-line project | Four lines are outside the denominator | Set coverage.include to your source glob and read the number again | | A source file missing from the table | No test imported it, so the provider never saw it | Treat a missing file as 0%, not as absent | | % Lines high and % Branch low | Every line ran, one side of a condition did not | Read the branch column, not the line column | | Uncovered Line #s listing a line at 100% line coverage | The line ran, a branch on it did not | Open that line and find the untaken side | | The file disappears from the table | Every column on it reached 100 | Nothing. The text reporter lists only files with a gap |

Common mistakes

Sign: Coverage reports 100% and a whole module has no tests.Cause: The v8 and istanbul providers instrument what the run loads. A file no test imports is not 0% in the report, it is absent from the report, and it leaves the denominator with it. Only coverage.include puts it back.
Sign: Coverage rises after a commit that added no assertions.Cause: Coverage counts executed lines. A test that imports a module and calls a function records every line it touched whether or not anything was checked afterwards. Step 3 moves a file from 0% to 100% with no expect at all.
Sign: Local coverage and CI coverage differ on the same commit.Cause: A failing or skipped test file still contributes the lines it managed to run before it stopped. A flaky test that dies early on one machine lowers the number there and nowhere else, so compare runs that had the same pass count.

What to check next

FAQ

How do you measure test coverage?

The runner instruments the code, runs the suite, and counts which statements, branches, functions and lines executed. It counts execution, never assertions, so the percentage answers "did this code run during the tests" and not "is this code tested".

How do I check unit test coverage percentage for one file?

Read the file's row in the table. Lines : 100% ( 21/21 ) in the summary is the whole project; the per-file rows carry the same four columns for one path. A file with no row was not measured at all.

How is test coverage calculated?

Covered units over total units, per category. The summary prints both halves: Statements : 85.29% ( 29/34 ) is 29 covered of 34 counted. Change what is counted and the percentage changes without a line of code or a test changing.

Does a coverage run change my test results?

It can change the timing. Instrumentation slows the run, which matters for any test with a time budget in it. In this project the deliberately flaky deadline test fails about one run in five with or without --coverage.

Which provider should I use, v8 or istanbul?

Both were run against this project on 2026-09-12 and reported the same figures, including 60% branches. v8 needs no source transform and is the default; istanbul is worth trying when a number looks wrong, because a second opinion costs one flag.

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.

basic5 minpublished updated Maks Verny