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
- Node 22 and npm. Every number on this page came from Node 22.23.2 with Vitest 5.0.0 and
@vitest/coverage-v85.0.0. - A project to measure. Build this one in an empty directory outside your repository and run every command on this page 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.jsholds a branch no test reaches, andformat-address.jshas no test at all.
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. One test in
export-job.test.jsraces real work against a deadline and fails about one run in five here, which changes nothing in the coverage table but does change the exit code.
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 the suite with coverage turned on and read the whole report.
npx vitest run --coverageRUN 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. - 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.jsappeared. - 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
expectin it. Deletetest/smoke.test.jsafterwards.
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
What to check next
- How to check branch coverage: the column that stays low while lines read 100%.
- How to check code coverage threshold: turning the number into a build that fails.
- How to detect flaky tests: a test that dies early changes the coverage it contributed.
- How to check which tests are slowest: coverage instrumentation adds time, and this shows where it lands.
- Pull request checklist: where this check sits among the others.
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.
Related on this site
basic5 minpublished updated Maks Verny