Per-Directory Coverage Thresholds in Vitest
A single repository-wide coverage threshold is the worst of both worlds: high enough to force pointless tests for generated clients and configuration objects, low enough that the pricing module can quietly slip. Vitest supports thresholds per glob, which lets the number match the consequence — ninety for the code that decides what customers are charged, sixty for the adapter that wraps an SDK, none at all for generated files. This guide covers configuring those globs, choosing the numbers from observed data rather than from taste, using autoUpdate to ratchet without arguments, and keeping the configuration honest as the codebase grows. It sits under defining coverage thresholds.
Root Cause Analysis
A global threshold is an average, and averages hide exactly what you care about. A repository at eighty-two per cent might have domain logic at ninety-five and an untested payment adapter at thirty; the number reports health while the risk sits in plain sight. Worse, the average is stable against local change — adding a large well-tested module can raise the total while the untested one gets no attention at all.
The second problem is incentive. When the gate is global, the cheapest way to pass is to test whatever is easiest, which is rarely what matters. Engineers write tests for simple pure helpers because they lift the number quickly, while the code with branches and consequences stays uncovered because covering it is hard work that the metric does not distinguish.
The third is that a global number cannot be ratcheted safely. Raising it by two points forces every area up, including ones where the remaining uncovered lines are error handlers that no reasonable test will exercise. Per-directory thresholds allow the ratchet where it is productive and a ceiling where it is not.
Reproducible Setup
Enable coverage with a provider and a reporter that emits machine-readable output, so the per-directory numbers can be read as well as enforced.
npm install -D vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
reportsDirectory: './coverage',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/**/*.d.ts', 'src/generated/**'],
},
},
});
npx vitest run --coverage
# % Coverage report from v8
# All files | 82.14 | 71.02 | 79.88 | 82.14 |
Implementation
Step 1 — Read the current numbers per directory before choosing any target. Thresholds derived from observation stick; thresholds derived from a meeting get lowered within a month.
// scripts/coverage-by-dir.ts
import { readFileSync } from 'node:fs';
const summary = JSON.parse(readFileSync('coverage/coverage-summary.json', 'utf8'));
const byDir = new Map<string, { covered: number; total: number }>();
for (const [file, data] of Object.entries<any>(summary)) {
if (file === 'total') continue;
const dir = file.replace(process.cwd() + '/', '').split('/').slice(0, 3).join('/');
const acc = byDir.get(dir) ?? { covered: 0, total: 0 };
acc.covered += data.lines.covered;
acc.total += data.lines.total;
byDir.set(dir, acc);
}
for (const [dir, { covered, total }] of [...byDir].sort((a, b) => a[1].covered / a[1].total - b[1].covered / b[1].total)) {
console.log(`${((covered / total) * 100).toFixed(1).padStart(6)}% ${dir}`);
}
Step 2 — Write glob thresholds that match the shape of the codebase. Vitest accepts a glob key inside thresholds, and each glob carries its own set of metrics.
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
thresholds: {
// the global floor, deliberately modest
lines: 70,
branches: 60,
'src/domain/**': { lines: 92, branches: 85, functions: 92 },
'src/lib/pricing/**': { lines: 95, branches: 90, functions: 95 },
'src/adapters/**': { lines: 60, branches: 45, functions: 60 },
'src/components/**': { lines: 80, branches: 70, functions: 80 },
},
},
},
});
Step 3 — Exclude rather than lowering a threshold to zero. A glob with a threshold of zero still appears in the report and in every conversation about coverage; an exclusion states plainly that the metric does not apply.
coverage: {
exclude: [
'src/generated/**', // regenerated from a schema
'src/**/*.stories.tsx', // rendered by Storybook, not by tests
'src/main.tsx', // the bootstrap, exercised by every e2e test
],
},
Step 4 — Ratchet with autoUpdate instead of arguing about increments. With it enabled, a run that exceeds a threshold writes the new figure back into the configuration file, so the bar follows the work upward and never drifts down.
thresholds: {
autoUpdate: true,
'src/domain/**': { lines: 92, branches: 85 },
},
npx vitest run --coverage
# thresholds updated: src/domain/** lines 92 → 94
git diff vitest.config.ts # the ratchet arrives as a reviewable change
Step 5 — Fail the build on the per-glob thresholds, not only the global one. Vitest exits non-zero when any threshold is unmet, so the gate needs no extra scripting — but it does need the coverage run to be part of the pipeline rather than an optional local step.
# .github/workflows/pr.yml
- run: npx vitest run --coverage
Step 6 — Keep the configuration next to the code it describes. In a single package the thresholds live in one file, which is fine. Once areas start to have genuinely different owners, put each area’s numbers where its owners will see them in a diff — a per-package configuration in a workspace, or at minimum a comment naming the owning team beside each glob, so raising a bar is a conversation with someone rather than an edit nobody notices.
Verification
Verify each glob actually matches files, because a typo produces a threshold that silently applies to nothing and passes forever.
npx vitest run --coverage --reporter=verbose 2>&1 | grep -i threshold
# ERROR: Coverage for lines (58.3%) does not meet "src/adapters/**" threshold (60%)
Then verify the enforcement by lowering coverage deliberately in the strictest area and confirming the build fails there and only there.
git stash -- src/domain/pricing.test.ts
npx vitest run --coverage
# ERROR: Coverage for branches (71.4%) does not meet "src/lib/pricing/**" threshold (90%)
# (no error for src/adapters/** — the other globs are unaffected)
git stash pop
Finally, verify the exclusions are doing what you intend by checking the file count in the report. A generated directory that still appears means the exclusion pattern does not match, which usually shows up as an inexplicably low overall figure.
node -e "
const s = require('./coverage/coverage-summary.json');
const files = Object.keys(s).filter(k => k !== 'total');
console.log('files measured:', files.length);
console.log('generated leaked:', files.filter(f => f.includes('/generated/')).length);
"
# files measured: 214
# generated leaked: 0
Troubleshooting
Symptom: a threshold never fails even for obviously untested code. Diagnosis: the glob does not match — Vitest resolves these relative to the project root, and a leading ./ or a missing src/ prefix is enough to match nothing. Fix: confirm by deliberately setting the threshold to 100 and checking that it fails; a glob that cannot fail at 100 matches no files.
Symptom: autoUpdate keeps producing noisy diffs. Diagnosis: coverage fluctuates slightly between runs, usually because of conditionally-executed code or a non-deterministic test. Fix: round the ratchet down — update only when the gain exceeds a point or two — or disable autoUpdate for globs over unstable areas and raise those by hand.
Symptom: a new package starts failing the global floor immediately. Diagnosis: the fallback applies to anything without its own glob, which is right in principle but hostile to work in progress. Fix: add a glob for the new area with a threshold matching its current state, and ratchet from there; the floor should be a safety net, not the first thing a new module meets.
Symptom: branch coverage is far below line coverage everywhere. Diagnosis: this is normal rather than a misconfiguration — branches include every default parameter, optional chain and short-circuit, many of which no reasonable test exercises. Fix: set branch thresholds ten to twenty points below line thresholds rather than treating the gap as a defect.
FAQ
How many globs is too many?
One per meaningful area, which for most repositories is between four and eight. Beyond that the configuration becomes a second representation of the directory tree that drifts from the real one. If you find yourself wanting twenty, the honest fix is usually to restructure the code so that the areas with different risk profiles are actually separate.
Should thresholds differ between line, branch and function coverage?
Yes, and the gap is informative. Lines and functions track each other closely; branches lag for structural reasons. Setting all three to the same number forces pointless tests for defensive branches, so set branches lower and treat a large gap in one specific module as a hint worth investigating rather than a rule violation.
Does this work in a monorepo?
It works per project, which is the right granularity — each package enforces its own thresholds in its own configuration, and no repository-wide number is computed at all. The setup is described in enforcing coverage thresholds in a monorepo.
Is a high threshold on domain logic enough?
It is necessary and not sufficient, because coverage measures execution rather than detection. A module at ninety-five per cent coverage can still have assertions that would not notice a wrong answer, which is what mutation testing and assertion quality exists to measure. Use coverage to find untouched code, and mutation to judge the tests that touch it.
Related
- Back to Defining Coverage Thresholds
- Choosing between v8 and Istanbul coverage — the provider these numbers come from.
- Excluding generated code from coverage reports — getting the denominator right.
- Why 100% coverage is the wrong target — why the bars differ by area at all.