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.

The gap between executed and verified code Coverage marks a line covered the moment it executes; only an assertion that observes the output turns execution into verified behavior, a gap thresholds cannot see. Executed code path ran coverage counts it — green but nothing checked the result Verified assertion observed output behavior is actually protected a failing version would go red the gap
A covered line only becomes a verified line once an assertion observes its output.

It is worth dwelling on why this gap is so easy to hide. Coverage tooling produces a single, satisfying number that trends upward as tests are added, and upward-trending numbers are psychologically rewarding in a way that “assertion density” or “mutation score” are not. A team can therefore watch coverage climb from 60% to 85% and feel measurable progress while the verified fraction — the share of that executed code whose output an assertion actually inspected — stays flat or even declines, because the fastest way to move the coverage number is to write import-and-call tests that assert nothing. The threshold, being a floor on the wrong quantity, offers no resistance to this drift. This is not an argument against coverage; it is an argument for treating the number as a smoke detector rather than a certificate. A smoke detector that never goes off is useful; one you trust to prove the building is fireproof is dangerous. Thresholds are the smoke detector — they catch the gross case of an entirely untested module shipping — and everything past that gross case has to be earned with assertions, branch analysis, and, on critical code, mutation testing.

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

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,
  },
},
Risk-tiered coverage floors A bar chart of coverage floors by risk tier: standard code held to eighty percent, auth and billing held to ninety-five percent, and generated or type-only code excluded from the denominator entirely. Coverage floor by risk tier 0% 100% Standard code 80% Auth & billing 95% Generated / types excluded removed from the denominator
One floor does not fit all code — critical paths earn a higher bar, noise is excluded.

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? Below baseline? Build failed Merge allowed yes no no yes
The coverage gate: a threshold breach or a drop below baseline both fail the build.

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.

Dynamic imports and lazy routes undercounting. Code behind a dynamic import() or a lazily-loaded route is only instrumented if a test actually reaches that code path, so a large lazy chunk can sit entirely outside the denominator and never turn red. Diagnosis: a route or feature module you know is untested does not appear in the report at all. Fix: ensure at least one test triggers each lazy boundary, or add the chunk’s files to an explicit include glob so the instrumentation counts them as zero-covered rather than omitting them silently.

The ratchet that only tightens. Teams sometimes wire autoUpdate or a script that raises the floor to the current number after every green run, believing it locks in progress. In practice it converts every incidental coverage bump into a permanent obligation, so a single well-tested feature quietly raises the bar for every unrelated pull request that follows. Diagnosis: contributors complain the gate creeps upward without anyone deciding it should. Fix: keep autoUpdate off and raise floors deliberately, as reviewed changes, so the threshold reflects a team decision rather than the high-water mark of an accidental run.

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.

There is a scheduling decision hiding inside the provider choice. Because coverage instrumentation slows every test it observes, running it on the same job that produces your fast pull-request feedback taxes the signal developers wait on most. A common and effective split is to run the suite without coverage on pull requests for speed, and to run a single instrumented pass — the one that gates thresholds and records the baseline — either on the merge queue or on the protected branch. Developers still see pass/fail in seconds; the coverage gate runs once, authoritatively, where its cost is amortized rather than paid on every push. The one caveat is that a threshold breach then surfaces slightly later in the flow, so pair it with a fast local vitest run --coverage habit so contributors catch a shortfall before CI does. The worst configuration is the accidental double-instrumentation, where coverage runs on every shard and again on a merge job; watch for it whenever a pipeline’s coverage step appears in more than one place, because it silently doubles the most expensive part of the run.

In-Depth Guides

Frequently Asked Questions

What numbers should I actually put in the threshold object?

Whatever your measured baseline is, minus a point or two — never a round number pulled from a blog post. The purpose of the first run in Step 1 is to replace aspiration with evidence: if the suite currently sits at 83% lines, set the floor at 80–81% so honest refactors do not trip it while genuine regressions still do. The specific tier values (80% standard, 95% critical) are conventions that work for a lot of codebases, but the only defensible number is one anchored to what your tests reach today, ratcheted upward deliberately over time rather than mandated in one jump that forces the team to write filler tests to comply.

Should I gate on line coverage or branch coverage?

Branch coverage is the more honest gate on any code with real conditional logic, because line coverage is trivially satisfied by executing a branch without ever taking its alternate path. A function full of if/else and early returns can show 100% lines while half its branches never ran. Keep a line floor as a coarse catch-all, but hold conditional-heavy modules — parsers, pricing logic, permission checks — to a branch floor as well, since that is the metric that actually notices an untested else.

Why not just set the same global threshold everywhere?

Because risk is not uniform, and a single number is simultaneously too strict for boilerplate and too loose for critical code. Set one global floor for 95% for the whole tree and you will block a pull request over an untested logging helper while a payment module sits at exactly the same number with far more at stake. Per-path overrides let the gate demand more where a defect is expensive and less where it is cheap, which keeps the gate both meaningful and non-obstructive — the same tiering logic that enforcing coverage thresholds in a monorepo applies at the package level.

Does high coverage mean my code is well tested?

No — it means your code was executed, which is a necessary but not sufficient condition for being tested. Coverage is a lower bound on quality: a low number reliably signals under-testing, but a high number does not signal correctness, because an assertion-free test lifts coverage without verifying anything. Treat a high number as permission to look closer, not as a conclusion, and reach for mutation testing on critical paths, as why 100% coverage is the wrong target explains in detail.

How do I stop the threshold from blocking unrelated work?

Set the floor below the current baseline, exclude generated and type-only files so the denominator reflects code you wrote, and apply perFile: true only to critical globs rather than the whole tree. Most spurious blocks come from one of three causes: a floor set at or above the live number, noise files dragging the denominator, or per-file enforcement applied to trivial modules that were never meant to be tested. Fixing all three leaves a gate that fires on genuine regressions and stays silent the rest of the time.