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.
It is worth dwelling on why this gap is so easy to hide. Coverage tooling produces a single, satisfying number that trends upward as tests are added, and upward-trending numbers are psychologically rewarding in a way that “assertion density” or “mutation score” are not. A team can therefore watch coverage climb from 60% to 85% and feel measurable progress while the verified fraction — the share of that executed code whose output an assertion actually inspected — stays flat or even declines, because the fastest way to move the coverage number is to write import-and-call tests that assert nothing. The threshold, being a floor on the wrong quantity, offers no resistance to this drift. This is not an argument against coverage; it is an argument for treating the number as a smoke detector rather than a certificate. A smoke detector that never goes off is useful; one you trust to prove the building is fireproof is dangerous. Thresholds are the smoke detector — they catch the gross case of an entirely untested module shipping — and everything past that gross case has to be earned with assertions, branch analysis, and, on critical code, mutation testing.
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
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.
Dynamic imports and lazy routes undercounting. Code behind a dynamic import() or a lazily-loaded route is only instrumented if a test actually reaches that code path, so a large lazy chunk can sit entirely outside the denominator and never turn red. Diagnosis: a route or feature module you know is untested does not appear in the report at all. Fix: ensure at least one test triggers each lazy boundary, or add the chunk’s files to an explicit include glob so the instrumentation counts them as zero-covered rather than omitting them silently.
The ratchet that only tightens. Teams sometimes wire autoUpdate or a script that raises the floor to the current number after every green run, believing it locks in progress. In practice it converts every incidental coverage bump into a permanent obligation, so a single well-tested feature quietly raises the bar for every unrelated pull request that follows. Diagnosis: contributors complain the gate creeps upward without anyone deciding it should. Fix: keep autoUpdate off and raise floors deliberately, as reviewed changes, so the threshold reflects a team decision rather than the high-water mark of an accidental run.
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.
There is a scheduling decision hiding inside the provider choice. Because coverage instrumentation slows every test it observes, running it on the same job that produces your fast pull-request feedback taxes the signal developers wait on most. A common and effective split is to run the suite without coverage on pull requests for speed, and to run a single instrumented pass — the one that gates thresholds and records the baseline — either on the merge queue or on the protected branch. Developers still see pass/fail in seconds; the coverage gate runs once, authoritatively, where its cost is amortized rather than paid on every push. The one caveat is that a threshold breach then surfaces slightly later in the flow, so pair it with a fast local vitest run --coverage habit so contributors catch a shortfall before CI does. The worst configuration is the accidental double-instrumentation, where coverage runs on every shard and again on a merge job; watch for it whenever a pipeline’s coverage step appears in more than one place, because it silently doubles the most expensive part of the run.
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.
Frequently Asked Questions
What numbers should I actually put in the threshold object?
Whatever your measured baseline is, minus a point or two — never a round number pulled from a blog post. The purpose of the first run in Step 1 is to replace aspiration with evidence: if the suite currently sits at 83% lines, set the floor at 80–81% so honest refactors do not trip it while genuine regressions still do. The specific tier values (80% standard, 95% critical) are conventions that work for a lot of codebases, but the only defensible number is one anchored to what your tests reach today, ratcheted upward deliberately over time rather than mandated in one jump that forces the team to write filler tests to comply.
Should I gate on line coverage or branch coverage?
Branch coverage is the more honest gate on any code with real conditional logic, because line coverage is trivially satisfied by executing a branch without ever taking its alternate path. A function full of if/else and early returns can show 100% lines while half its branches never ran. Keep a line floor as a coarse catch-all, but hold conditional-heavy modules — parsers, pricing logic, permission checks — to a branch floor as well, since that is the metric that actually notices an untested else.
Why not just set the same global threshold everywhere?
Because risk is not uniform, and a single number is simultaneously too strict for boilerplate and too loose for critical code. Set one global floor for 95% for the whole tree and you will block a pull request over an untested logging helper while a payment module sits at exactly the same number with far more at stake. Per-path overrides let the gate demand more where a defect is expensive and less where it is cheap, which keeps the gate both meaningful and non-obstructive — the same tiering logic that enforcing coverage thresholds in a monorepo applies at the package level.
Does high coverage mean my code is well tested?
No — it means your code was executed, which is a necessary but not sufficient condition for being tested. Coverage is a lower bound on quality: a low number reliably signals under-testing, but a high number does not signal correctness, because an assertion-free test lifts coverage without verifying anything. Treat a high number as permission to look closer, not as a conclusion, and reach for mutation testing on critical paths, as why 100% coverage is the wrong target explains in detail.
How do I stop the threshold from blocking unrelated work?
Set the floor below the current baseline, exclude generated and type-only files so the denominator reflects code you wrote, and apply perFile: true only to critical globs rather than the whole tree. Most spurious blocks come from one of three causes: a floor set at or above the live number, noise files dragging the denominator, or per-file enforcement applied to trivial modules that were never meant to be tested. Fixing all three leaves a gate that fires on genuine regressions and stays silent the rest of the time.
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.