Defining Coverage Thresholds
Coverage thresholds are the numeric quality gates that decide whether a test suite is allowed to merge, and getting them wrong is one of the most common ways teams turn a healthy suite into a source of friction. Set them too low and they certify nothing; set them too high or too uniformly and they reward assertion-free tests written purely to move a percentage. Defining coverage thresholds well means anchoring every number to architectural risk rather than vanity, and treating the threshold object as code that lives alongside the runner configuration described in the broader Test Strategy & Pyramid Design approach. This section gives you a deterministic methodology — measure first, tier by risk, enforce in CI, and scale per package — using Vitest as the primary runner and Istanbul or v8 as the instrumentation backend.
Architectural Scope & Boundaries
A coverage threshold is a floor, not a goal. It applies at exactly one tier of measurement: the line, branch, function, and statement counts that an instrumentation provider records while your tests execute. It does not measure assertion quality, mutation resistance, or behavioural correctness — a fact that the argument in why 100% coverage is the wrong target develops in full. Threshold enforcement belongs primarily to the unit and integration layers, where instrumentation is cheap and deterministic. End-to-end runs in a real browser are intentionally excluded from threshold aggregation: their coverage is noisy, their instrumentation overhead is high, and conflating their numbers with unit coverage inflates metrics without improving confidence.
The boundary you must hold is the difference between executed and verified. Instrumentation marks a line covered the instant it runs, regardless of whether any expectation observed its output. Thresholds therefore guard against the obvious regression — untested code shipping unnoticed — but they cannot certify that the tested code is correct. Keep that limitation explicit when you communicate targets to a team, and pair raw percentages with the cost framing in the Cost-Benefit Analysis of Test Layers so a number is never mistaken for a guarantee.
Three things sit outside this scope and should be excluded from instrumentation entirely: generated code, type-only declarations, and test scaffolding. Counting them dilutes the denominator and lets real gaps hide behind a comfortable aggregate.
Prerequisites
actions/setup-node.npm install -D vitest).@vitest/coverage-v8(fast, default) or@vitest/coverage-istanbul(richer ignore hints, slower).
Step-by-Step Implementation
Step 1: Measure before you enforce
Never invent a threshold. Run coverage once with no gate and read the actual numbers, so your floor is grounded in reality rather than aspiration.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
},
},
});
npx vitest run --coverage
# Read coverage/coverage-summary.json — the .total block is your starting point.
Step 2: Set a global floor just below the measured baseline
A floor exists to catch regressions, not to demand instant improvement. Place it a point or two under the current number so honest refactors never trip it.
// vitest.config.ts (excerpt)
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
thresholds: {
statements: 80,
branches: 72,
functions: 80,
lines: 80,
autoUpdate: false, // never let CI rewrite the floor
},
},
Step 3: Raise the floor for high-risk paths
Vitest accepts glob keys inside thresholds, letting you demand more from auth, billing, or core domain code than from a settings panel.
// vitest.config.ts (excerpt)
thresholds: {
// global floor
statements: 80,
branches: 72,
functions: 80,
lines: 80,
// critical paths held to a higher bar
'src/domain/billing/**': {
statements: 95,
branches: 90,
functions: 95,
lines: 95,
},
'src/auth/**': {
statements: 95,
branches: 90,
functions: 95,
lines: 95,
},
},
Step 4: Exclude noise from the denominator
Generated and type-only files have no behaviour to test. Excluding them keeps the percentage honest.
// vitest.config.ts (excerpt)
coverage: {
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.{test,spec}.{ts,tsx}',
'src/**/__mocks__/**',
'src/**/generated/**',
'src/main.tsx',
],
},
Step 5: Add per-file enforcement where averages lie
Aggregate thresholds let one heavily tested module mask several untested ones. perFile: true forces every file to clear the bar independently — apply it to critical globs rather than the whole tree to avoid blocking trivial files.
// vitest.config.ts (excerpt)
thresholds: {
perFile: true, // each file must individually meet the floor
statements: 80,
branches: 72,
functions: 80,
lines: 80,
},
Step 6: Wire the gate into CI with delta protection
A failing threshold must fail the build, and a silent drop below baseline must be visible. Combine the runner’s own exit code with a delta check against the stored baseline.
# .github/workflows/coverage.yml
name: Coverage Gate
on: [pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- name: Run coverage (fails on threshold breach)
run: npx vitest run --coverage
- name: Guard against regression
run: |
CURRENT=$(jq '.total.lines.pct' coverage/coverage-summary.json)
BASELINE=$(jq '.total.lines.pct' .coverage-baseline.json 2>/dev/null || echo 0)
DROP=$(echo "$BASELINE - $CURRENT" | bc -l)
if (( $(echo "$DROP > 0.5" | bc -l) )); then
echo "::error::Line coverage dropped ${DROP}% below baseline"
exit 1
fi
A coverage-gate decision flow makes the branching logic above easier to reason about at a glance:
Configuration Reference Table
The options below are the ones that actually change gate behaviour. Vitest surfaces them under test.coverage; the equivalent Istanbul/nyc and Jest keys are noted so the mapping is unambiguous when a team migrates runners.
| Option | Runner / Key | Type | Default | Effect |
|---|---|---|---|---|
provider |
Vitest coverage.provider |
'v8' | 'istanbul' |
'v8' |
Selects the instrumentation backend; Istanbul honours /* istanbul ignore */ hints, v8 is faster. |
thresholds.lines |
Vitest coverage.thresholds |
number |
none | Minimum line percentage; build fails below it. |
thresholds.branches |
Vitest coverage.thresholds |
number |
none | Minimum branch percentage — the metric most resistant to gaming. |
thresholds.functions |
Vitest coverage.thresholds |
number |
none | Minimum percentage of declared functions invoked. |
thresholds.statements |
Vitest coverage.thresholds |
number |
none | Minimum statement percentage; close to lines for most code. |
thresholds.perFile |
Vitest coverage.thresholds |
boolean |
false |
Enforces the floor on every file individually instead of the aggregate. |
thresholds.autoUpdate |
Vitest coverage.thresholds |
boolean |
false |
When true, rewrites thresholds to current values — keep off in CI. |
thresholds['glob/**'] |
Vitest coverage.thresholds |
object |
none | Per-path overrides for risk-tiered gates. |
include |
Vitest coverage.include |
string[] |
all | Files instrumented even if no test imports them; prevents zero-coverage files vanishing. |
exclude |
Vitest coverage.exclude |
string[] |
sensible defaults | Removes generated/type/test files from the denominator. |
reporter |
Vitest coverage.reporter |
string[] |
['text'] |
Output formats; include lcov for tooling and json-summary for delta checks. |
check-coverage |
Istanbul/nyc |
boolean |
false |
The nyc flag that turns recorded coverage into a hard gate. |
coverageThreshold |
Jest config | object |
none | Jest’s equivalent threshold object, including per-glob keys. |
Verification & Assertions
Confirm the gate is real by making it fail on purpose. Lower a threshold deliberately, run the suite, and observe a non-zero exit — a gate that cannot fail is decoration.
npx vitest run --coverage
# Expected on breach:
# ERROR: Coverage for branches (68.4%) does not meet threshold (72%)
# process exits 1
Assert three properties. First, the exit code is non-zero on breach (echo $? returns 1). Second, excluded files are absent from coverage-summary.json — grep the report for a known generated path and expect no match. Third, per-path overrides apply: temporarily drop a critical-glob file’s coverage and confirm the stricter number, not the global floor, triggers the failure. Only when all three hold is the configuration trustworthy.
Edge Cases & Failure Modes
Zero-coverage files silently excluded. If a source file is never imported by any test, v8 may omit it entirely, so a wholly untested module shows no red. Fix: set an explicit include glob so every source file is instrumented whether or not a test touches it.
The averaging trap. A package at 92% aggregate can contain three files at 40%. Diagnosis: aggregate passes while real gaps persist. Fix: enable perFile: true on critical globs, or read the per-file table rather than the summary line.
Mock-inflated coverage. A test that calls vi.mock on a whole module marks every line of that module as executed without verifying anything. Diagnosis: high coverage on a file with no direct test. Fix: use vi.importActual partial mocks so real code paths run, and treat the metric with the skepticism detailed in why 100% coverage is the wrong target.
Flaky tests poisoning the number. Non-deterministic tests change which branches execute between runs, making the percentage jitter and the gate flap. Diagnosis: coverage delta varies on identical code. Fix: stabilize or quarantine them via the flaky test mitigation workflow before trusting any threshold delta.
Performance & CI Impact
The provider choice dominates cost. v8 instrumentation is near-free because it rides the engine’s built-in profiler; Istanbul rewrites source and can add 20–40% to suite runtime on large codebases. Choose v8 by default and reach for Istanbul only when you need its fine-grained ignore comments. Generate coverage in a single dedicated CI job rather than on every shard — merging per-shard reports and gating once is faster and avoids double-instrumentation overhead. Cache node_modules and the Vitest cache directory between runs, and emit lcov only when a downstream tool consumes it, since serializing large LCOV files is itself measurable on big repositories. For monorepos, the merge-and-gate pattern is detailed below; weigh the wall-clock cost of full instrumentation against the speed targets in the Cost-Benefit Analysis of Test Layers.
In-Depth Guides
- Enforcing coverage thresholds in a monorepo — set per-package floors, merge LCOV across workspaces, and fail CI on regression without blocking unrelated packages.
- Why 100% coverage is the wrong target — the data on diminishing returns, why mutation testing measures what coverage cannot, and what to gate on instead.
Related
- Cost-Benefit Analysis of Test Layers — frame threshold targets against the cost of each test tier.
- Flaky Test Mitigation — stabilize non-deterministic tests before trusting coverage deltas.
- Unit vs Integration vs E2E Mapping — decide which layer each threshold should guard.
- Configuring Vitest for Next.js App Router — the runner setup these thresholds attach to.
- Back to Test Strategy & Pyramid Design.
Why 100% Coverage Is the Wrong Target
Why 100% code coverage misleads teams: diminishing returns, the executed-vs-verified gap, mutation testing, and what to gate on instead in Vitest projects.
Enforcing Coverage Thresholds in a Monorepo
Set per-package coverage thresholds, merge LCOV across workspaces, and fail CI on regression in a Vitest monorepo without blocking unrelated packages.