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