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

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.

    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 0

    The gate passes because the denominator is 21 lines. The project has 25, in four files.

  2. 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 1

    One flag moved the same code and the same tests from passing a 90% gate to failing it.

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

    Exit 0, and a file at 0% on every column sits two rows above it. This is the state most repositories are in.

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

    Same percentages, same tests, different verdict. The ERROR now names the path.

  5. 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 1

    Five 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

Sign: The coverage gate has never failed since it was added.Cause: A threshold applies to the files the report measured, and by default a provider measures only files a test imported. Untested files leave the denominator with their own lines, so the percentage rises as untested code is added.
Sign: Coverage sits comfortably above the gate and a module has no tests.Cause: A global threshold averages over the codebase, so a large well-tested area pays for a small untested one. Only thresholds.perFile turns the same numbers into a failure, and it names the path when it does.
Sign: A per-file failure says global threshold.Cause: Vitest reuses the same message for both modes. The difference is the suffix: a line ending in a source path is a per-file failure, and a line with no path is the total. Grepping for the word global finds both, so grep for the path instead.

Thresholds

84% lines, 60% branches Source: Measured on the project above with @vitest/coverage-v8 5.0.0 on 2026-09-12, counting all four files in src/. The same run reports 100% lines when the include glob is left at its default.

What to check next

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.

intermediate6 minpublished updated Maks Verny