Enforcing Coverage Thresholds in a Monorepo
A single global coverage number is meaningless across a monorepo where a mature billing package sits next to a week-old experimental UI kit. This guide is for platform engineers and tech leads running a Vitest workspace (pnpm, npm, or Yarn workspaces, optionally orchestrated by Turborepo or Nx) who need per-package coverage thresholds, a merged report for dashboards, and a CI gate that fails on regression in one package without holding the rest of the repository hostage. The scope is Vitest 1.x/2.x with the v8 provider as primary and Istanbul noted where its behaviour differs. The patterns here extend the runner-level work in defining coverage thresholds to the workspace scale.
Root Cause Analysis
Monorepo coverage breaks for three structural reasons. First, aggregation flattens risk: when every package’s coverage rolls into one percentage, a high-traffic package can drag the average up while a critical one quietly rots, and a single global floor either over-constrains young packages or under-constrains mature ones. Second, reports are written in isolation: each package emits its own coverage/ directory with paths relative to that package root, so naively concatenating them produces broken or double-counted entries unless they are merged with a tool that understands absolute paths. Third, the gate scope is wrong: running the whole suite on every change wastes CI minutes and creates false failures, because a change to package A should not fail because package B was already below its (independently set) floor.
The symptom teams notice first is a green pipeline alongside shipping regressions — the average absorbed the drop. The second symptom is a slow, flaky gate that engineers learn to ignore. Both trace back to treating a federation of packages as one undifferentiated codebase. The fix is to make each package own its floor, merge only for reporting, and gate on the affected set.
The mathematics of the masking are worth making concrete, because they explain why the problem grows silently. An aggregate is weighted by lines of code, so a large, mature, well-tested package contributes most of the denominator and dominates the number. When a small new package ships at 40%, it moves the global figure by a fraction of a point — far too little to breach any sane global floor — even though that package is exactly where the untested risk now lives. As the mature package keeps growing, its gravitational pull on the average increases, so each new under-tested package moves the needle even less. The aggregate does not merely fail to catch the regression; it becomes progressively better at hiding it over time. Per-package floors invert this: each package is measured against its own bar regardless of how many lines its neighbors carry, so a 40% package fails its own gate loudly the moment it exists.
Reproducible Setup
Assume a pnpm workspace with two packages. Install Vitest and a coverage provider at the root.
npm install -D vitest @vitest/coverage-v8
# pnpm-workspace.yaml
packages:
- 'packages/*'
Give each package its own config so its threshold travels with its code. The critical package demands more than the standard one.
// packages/billing/vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
thresholds: {
statements: 95,
branches: 90,
functions: 95,
lines: 95,
},
},
},
});
// packages/ui-kit/vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
thresholds: {
statements: 70,
branches: 60,
functions: 70,
lines: 70,
},
},
},
});
Implementation
Step 1: Run each package’s gate independently
The most robust pattern is to let every package enforce its own floor by running its own config. Vitest exits non-zero the moment any package’s thresholds are missed, so the gate is the natural exit code — no extra scripting.
// package.json (root)
{
"scripts": {
"test:cov": "vitest run --coverage",
"test:cov:all": "pnpm -r --workspace-concurrency=4 run test:cov"
}
}
// packages/billing/package.json (each package repeats this)
{
"scripts": {
"test:cov": "vitest run --coverage"
}
}
Running pnpm -r run test:cov executes each package’s gate. Because each config carries its own thresholds, billing is held to 95% and the UI kit to 70% in the same command, and a breach in either fails the whole command with a non-zero code.
Step 2: Gate only the affected packages
Running every package on every commit is wasteful. Turborepo’s --filter (or Nx’s affected graph) restricts the run to packages touched since the base ref, so a UI-only change never re-runs the billing gate.
// turbo.json
{
"tasks": {
"test:cov": {
"outputs": ["coverage/**"]
}
}
}
# Only test packages affected by the diff against origin/main
turbo run test:cov --filter='...[origin/main]'
Step 3: Merge LCOV for a repo-wide report
Independent gates are correct for enforcement, but dashboards and PR comments want one number. Collect each package’s lcov.info, rewrite their relative paths to repo-absolute, and merge with a tool that deduplicates by file.
npm install -D lcov-result-merger
# Each package wrote coverage/lcov.info during step 1.
npx lcov-result-merger 'packages/*/coverage/lcov.info' './coverage/merged.lcov'
lcov-result-merger concatenates the per-package records and folds duplicate file entries, producing a single LCOV file an external dashboard can ingest. Keep this report strictly for visualization — enforcement already happened per package in step 1, so the merged file is never the gate.
Step 4: Fail CI on regression, not just on absolute breach
Absolute thresholds catch a package that drops below its declared floor. To also catch a slow drift that stays above the floor, compare each package’s json-summary against a committed baseline.
# .github/workflows/monorepo-coverage.yml
name: Monorepo Coverage
on: [pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- name: Run affected coverage gates
run: npx turbo run test:cov --filter='...[origin/main]'
- name: Regression check per package
run: node scripts/check-regression.mjs
// scripts/check-regression.mjs
import { readFileSync, globSync } from 'node:fs';
const MAX_DROP = 0.5; // percentage points
const summaries = globSync('packages/*/coverage/coverage-summary.json');
let failed = false;
for (const path of summaries) {
const pkg = path.split('/')[1];
const current = JSON.parse(readFileSync(path, 'utf8')).total.lines.pct;
let baseline = current;
try {
baseline = JSON.parse(
readFileSync(`packages/${pkg}/.coverage-baseline.json`, 'utf8'),
).total.lines.pct;
} catch {
/* first run — no baseline yet */
}
const drop = baseline - current;
if (drop > MAX_DROP) {
console.error(`::error::${pkg} dropped ${drop.toFixed(2)}% below baseline`);
failed = true;
}
}
process.exit(failed ? 1 : 0);
Verification
Prove each layer independently. Drop one package below its floor and confirm only that package’s gate fails while the others stay green:
pnpm -r run test:cov
# packages/billing ❯ ERROR: branches (88%) below threshold (90%)
# packages/ui-kit ❯ PASS
# Exit code: 1
Confirm the merge produced a single coherent file by counting records — the merged total should equal the union of per-package source files, not their sum-with-duplicates:
grep -c '^SF:' coverage/merged.lcov
Finally, confirm the affected filter works: change a file only in ui-kit, run the filtered command, and verify billing’s gate does not execute. A gate you cannot watch fail and pass on demand is not yet trustworthy — the same principle the parent coverage threshold methodology applies at the single-package level.
Troubleshooting
Merged report has broken or duplicated paths. LCOV records use paths relative to each package root, so src/index.ts collides across packages. Fix: run the merge from the repo root and ensure each package emits lcov (not just text); lcov-result-merger resolves against the invocation directory, so invoke it once at the top level rather than per package.
The affected filter runs everything. Turborepo’s ...[origin/main] needs full git history; a shallow CI checkout breaks the diff. Fix: set fetch-depth: 0 on actions/checkout so the base ref is reachable.
A package passes its floor but the repo regressed. Absolute thresholds alone miss above-floor drift. Fix: keep the per-package baseline comparison from step 4, and refresh .coverage-baseline.json only on merges to the default branch, never inside a PR run where it would mask the very drop it should catch.
A shared package’s coverage swings when a consumer changes. In a workspace, a utility package’s own coverage can shift because a dependent package’s tests exercise it indirectly, so a change that never touched the utility appears to move its number. Fix: measure each package’s coverage strictly from its own test run with its own include glob, not from an aggregated instrumentation pass across the whole graph, so a package’s number reflects only the tests that package owns. This keeps the baseline comparison meaningful — a drop then unambiguously means that package’s own tests got weaker, not that a neighbor’s suite stopped incidentally covering it.
The regression script passes on a brand-new package. A package with no committed baseline has nothing to compare against, so the drift check silently skips it, leaving it guarded only by its absolute floor until a baseline is recorded. Fix: on the first successful run, write the baseline immediately as part of the merge-to-main job, and treat a missing baseline on a package older than one sprint as its own CI warning so no package sits permanently outside drift protection.
FAQ
Should I use one root Vitest config or one per package?
Use one config per package. A per-package config lets each workspace carry its own risk-appropriate threshold and its own include/exclude set, which is the whole point of differentiating a critical package from an experimental one. A single root config can technically use glob keys for per-path thresholds, but it centralizes ownership away from the teams who maintain each package and makes the affected-package filtering in Turborepo or Nx far harder to reason about.
Does this work with Jest as well as Vitest?
Yes — the structure is identical. Replace each vitest.config.ts with a Jest config carrying a coverageThreshold object (Jest supports the same per-glob keys), run jest --coverage per package, and merge the resulting lcov.info files with the same lcov-result-merger step. The only real difference is the runner invocation; the per-package gate, merge-for-reporting, and baseline-regression pattern carry over unchanged.
How do I stop unrelated packages from slowing the gate?
Filter by the affected graph. Turborepo’s --filter='...[origin/main]' and Nx’s affected command both compute which packages a diff touches and run only those, so a documentation or UI change never re-runs the billing suite. Pair this with task caching so even affected packages skip re-execution when their inputs are unchanged.
Where should the merged LCOV file be used?
Only for reporting — dashboards, coverage badges, or PR summary comments. Enforcement already happened per package via each runner’s exit code, so the merged file must never become the thing CI gates on; doing so reintroduces the averaging problem this whole approach exists to eliminate. A useful mental model is that the merged number is a view, not a rule: it answers “how is the repository doing overall?” for a human reading a badge, while the rules that actually block a merge live entirely in the per-package exit codes. Keeping that separation strict is what lets you show a single reassuring repository-wide figure without ever letting that figure become the loophole a rotting package hides behind.
Related
- Back to Defining Coverage Thresholds.
- Why 100% coverage is the wrong target — what to measure beyond the percentage.
- Cost-Benefit Analysis of Test Layers — justify per-package targets against tier cost.
- Flaky Test Mitigation — keep workspace coverage deltas deterministic.
- Configuring Vitest for Next.js App Router — the runner config each package extends.