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.

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

  • actions/setup-node.
  • npm install -D vitest).
  • @vitest/coverage-v8 (fast, default) or @vitest/coverage-istanbul (richer ignore hints, slower).

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:

Coverage gate decision flow A flowchart: run coverage, then check whether thresholds pass and whether coverage dropped below baseline, routing to merge allowed or build failed. Run coverage Thresholds met? yes Below baseline? no yes no Build failed Merge allowed

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.

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.

In-Depth Guides