How to check code coverage threshold
Gate the run: npx vitest run --coverage --coverage.thresholds.lines=80. The runner prints ERROR: Coverage for lines (84%) does not meet global threshold (90%) and exits 1 when the total falls short. Set thresholds.perFile as well, or a file at 0% passes inside a healthy total.
Why check this
A coverage threshold exists to fail a build. It is worth checking on the day it is introduced, and again whenever someone reports that coverage "is enforced" without being able to name the command that enforces it.
Two failures hide here, and the second is the expensive one. A threshold that never fails is a threshold on the wrong denominator: the run below passes a 90% gate while four of twenty-five source lines are outside the measurement entirely. A threshold that fails on the total but not per file is worse, because it reports a healthy number over a codebase that holds a module nobody tested. Step 3 shows the gate returning exit 0 with format-address.js at 0% on every column.
The check settles one thing: which command, with which flags, turns a percentage into a non-zero exit code. It says nothing about whether the covered code is correct.
Prerequisites
- Node 22 and npm. The output here came from Node 22.23.2, Vitest 5.0.0 and
@vitest/coverage-v85.0.0. - A project to gate. Build it in an empty directory outside your repository. See the Vitest coverage thresholds documentation.
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.
format-address.jshas no test and is the file every gate below has to catch.
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.jsfails about one run in five here, and a failing test exits 1 for its own reason, so read the ERROR lines rather than the exit code alone.
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.
Put a 90% line gate on the run as it comes out of the box.
npx vitest run --coverage --coverage.thresholds.lines=90; echo "exit $?"---------------|---------|----------|---------|---------|------------------- 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 ) ================================================================================ exit 0The gate passes because the denominator is 21 lines. The project has 25, in four files.
- Step 2.
Count every source file, keep the same gate, and run it again.
npx vitest run --coverage --coverage.include='src/**' --coverage.thresholds.lines=90; echo "exit $?"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 ) ================================================================================ ERROR: Coverage for lines (84%) does not meet global threshold (90%) exit 1One flag moved the same code and the same tests from passing a 90% gate to failing it.
- Step 3.
Lower the gate to a number the total clears, and read what it now lets through.
npx vitest run --coverage --coverage.include='src/**' --coverage.thresholds.lines=80; echo "exit $?"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 ) ================================================================================ exit 0Exit 0, and a file at 0% on every column sits two rows above it. This is the state most repositories are in.
- Step 4.
Apply the same number to each file instead of to the total.
npx vitest run --coverage --coverage.include='src/**' --coverage.thresholds.lines=80 --coverage.thresholds.perFile; echo "exit $?"=============================== Coverage summary =============================== Statements : 85.29% ( 29/34 ) Branches : 60% ( 6/10 ) Functions : 92.3% ( 12/13 ) Lines : 84% ( 21/25 ) ================================================================================ ERROR: Coverage for lines (0%) does not meet global threshold (80%) for src/format-address.js exit 1Same percentages, same tests, different verdict. The ERROR now names the path.
- Step 5.
Move the gate into the config file so CI and your machine run the same one.
// vitest.config.js import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { coverage: { provider: 'v8', include: ['src/**'], thresholds: { lines: 80, branches: 60, functions: 90, statements: 80, perFile: true }, }, }, });npx vitest run --coverage; echo "exit $?"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 ) ================================================================================ ERROR: Coverage for branches (50%) does not meet global threshold (60%) for src/discount.js ERROR: Coverage for lines (0%) does not meet global threshold (80%) for src/format-address.js ERROR: Coverage for functions (0%) does not meet global threshold (90%) for src/format-address.js ERROR: Coverage for statements (0%) does not meet global threshold (80%) for src/format-address.js ERROR: Coverage for branches (0%) does not meet global threshold (60%) for src/format-address.js exit 1Five ERROR lines, one per metric per file. The word "global" appears in all of them even though four are per-file failures; the trailing path is the only thing that distinguishes them.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| exit 0 and no ERROR line | Every configured metric met its number | Confirm the metrics you meant to gate are configured, not only lines |
| ERROR … does not meet global threshold (90%) with no path | The total across the measured files fell short | Fix the gap, or lower the gate on purpose and record why |
| The same ERROR text ending in for src/… | One file fell short under perFile | Test that file. The total is not the problem |
| A gate that has never failed | Probably the wrong denominator | Compare the summary count with the number of source lines you have |
| exit 1 with no ERROR line at all | A test failed, not the threshold | Read the test result above the coverage table |
Common mistakes
Thresholds
What to check next
- How to check test coverage: how the number the gate reads is produced.
- How to check branch coverage: the metric worth gating once lines saturate.
- How to check typescript errors: the other build gate that has to fail on its own.
- How to check eslint errors: same question about exit codes, different tool.
- Pull request checklist: the gates worth running before a merge.
FAQ
How do I set a coverage threshold in Vitest?
Either on the command line, --coverage.thresholds.lines=80, or under test.coverage.thresholds in vitest.config.js. The config file is what CI reads, so treat the flag as a way to try a number and the file as the gate.
Which threshold number should I pick?
Pick the one your current measurement already meets, then raise it when it stops failing. A number nobody can reach gets deleted within a month. thresholds.autoUpdate writes the current figures back into the config, which makes the ratchet explicit.
Does the threshold fail the build or only print a warning?
It fails. Step 2 and step 4 both exit 1, which is what CI reads. A failing test also exits 1, so when a run goes red, check whether an ERROR line about coverage is present before blaming the gate.
Can I set a threshold per directory?
Yes. Keys under thresholds that look like globs, such as 'src/checkout/**', carry their own numbers and override the global ones for matching files. This is the usual way to hold new code to a higher bar than old code.
Why did coverage drop the day we enabled the include glob?
Because the denominator grew. Before the change the report counted 21 lines, after it 25, with the same 21 covered. Nothing about the tests changed, and the lower number is the accurate one.
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