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.