cloud-devops

Vitest Coverage Thresholds: Fail CI on Low Coverage (2026)

June 23, 2026

Vitest Coverage Thresholds: Fail CI on Low Coverage (2026)

To make Vitest fail CI when coverage drops, set numeric thresholds under coverage.thresholds in vitest.config.ts and run vitest run --coverage. If any metric falls below its threshold, Vitest prints an error and exits with code 1, which fails the CI job.1

TL;DR

In Vitest 4, coverage thresholds live under coverage.thresholds (not as flat coverage.lines keys). Set lines, functions, branches, and statements, then run vitest run --coverage; a shortfall exits non-zero and breaks the build. The most common reason a threshold "doesn't fail" is a flat config — numbers placed directly under coverage instead of under coverage.thresholds — which Vitest silently ignores. This guide gives you a runnable Vitest 4 config, per-file and glob gates, and a GitHub Actions workflow — all verified against vitest@4.1.9.2

What you'll learn

  • How to make Vitest fail CI when coverage drops below a threshold
  • Why your coverage threshold isn't failing the build (the silent-ignore trap)
  • What changed for coverage in Vitest 4
  • How to set per-file and glob-pattern thresholds
  • What thresholds.100 and autoUpdate do
  • Whether to use the v8 or istanbul coverage provider
  • How to enforce Vitest coverage in GitHub Actions, including PR comments

How do I make Vitest fail CI when coverage drops?

Add numeric thresholds under coverage.thresholds and run with --coverage. When any metric is below its threshold, Vitest exits with code 1 and CI fails.1

Install the test runner and a coverage provider:

npm i -D vitest @vitest/coverage-v8

Create a small source file to test:

// src/math.ts
export function add(a: number, b: number): number {
  return a + b
}

export function isPositive(n: number): boolean {
  if (n > 0) {
    return true
  }
  return false
}

Configure thresholds. In Vitest 4 they go under coverage.thresholds:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json-summary', 'json'],
      include: ['src/**/*.ts'],
      thresholds: {
        lines: 90,
        functions: 90,
        branches: 90,
        statements: 90,
      },
    },
  },
})

Add a test that only covers part of the file:

// test/math.test.ts
import { expect, test } from 'vitest'
import { add } from '../src/math'

test('add', () => {
  expect(add(2, 3)).toBe(5)
})

Run it:

npx vitest run --coverage

Because isPositive is untested, Vitest prints the shortfall and exits non-zero:

ERROR: Coverage for lines (25%) does not meet global threshold (90%)
ERROR: Coverage for functions (50%) does not meet global threshold (90%)
ERROR: Coverage for statements (25%) does not meet global threshold (90%)
ERROR: Coverage for branches (0%) does not meet global threshold (90%)

The process exits with code 1. Add a test that exercises isPositive (both branches) and coverage hits 100%, so the same command exits 0. That non-zero exit is the entire mechanism: any CI runner treats it as a failed step. You do not need a separate "coverage gate" action for the hard fail — Vitest's own exit code is the gate.

A positive threshold is a minimum percentage. A negative threshold is the maximum number of uncovered items allowed: lines: -10 means "no more than 10 uncovered lines."1

Why is my Vitest coverage threshold not failing the build?

The usual cause is a flat config. Thresholds must be nested under coverage.thresholds — the layout Vitest has required since v1.0 — so a flat coverage: { lines: 90 } is silently ignored, the run exits 0, and nothing gates.1

This config — copied from countless older tutorials — enforces nothing in current Vitest:

// vitest.config.ts — BROKEN: thresholds must be nested (silently does nothing)
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      include: ['src/**/*.ts'],
      // ❌ flat keys are ignored — they must live under `thresholds`
      lines: 90,
      functions: 90,
      branches: 90,
      statements: 90,
    },
  },
})

Run it against the same partially tested file and Vitest exits 0 with no ERROR lines at all. The fix is to wrap the numbers in thresholds, exactly as in the working config above. If your "gate" has been green for months, confirm it actually prints ERROR: lines on a deliberately under-tested branch before you trust it.

A second, subtler cause is the removal of coverage.all in Vitest 4. Previously Vitest included every matching file in the report by default; now it includes only files loaded during the test run unless you set coverage.include.3 Without include, a source file with zero imports in your tests never appears in the report, so your global percentage looks healthy while whole modules go unmeasured. Always set coverage.include to your source globs so untested files count against you.

What changed for coverage in Vitest 4?

The coverage.thresholds object itself is unchanged — it has been the required layout since Vitest 1.0. What Vitest 4 changed is the coverage report: it removed coverage.all, removed ignoreEmptyLines, and made AST-based V8 remapping the only mode. Vitest 4.0 also requires Vite ≥ 6.0.0 and Node.js ≥ 20.0.0.3

AreaVitest 3Vitest 4
Report scopecoverage.all defaulted to true (every matching file)coverage.all removed; report covers only loaded files unless coverage.include is set3
Empty linesignoreEmptyLines availableremoved; lines without runtime code are no longer counted3
V8 remappingv8-to-istanbul by default (AST remapping opt-in since v3.2.0)AST-based remapping is the default and only mode3

Because the threshold API did not move, a config written for Vitest 1–3 keeps enforcing under Vitest 4. The catch is accuracy: the more precise V8 remapping can shift reported percentages slightly, so re-baseline your numbers after upgrading.3

How do I set per-file and glob coverage thresholds in Vitest?

Set thresholds.perFile: true to apply global numbers to every file individually, and add glob-pattern keys to enforce stricter numbers on specific paths.1

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      include: ['src/**/*.ts'],
      thresholds: {
        // Applied to every file individually, not just the aggregate
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
        perFile: true,

        // Stricter gate for a critical module
        'src/**/math.ts': {
          statements: 100,
          branches: 100,
          functions: 100,
          lines: 100,
        },
      },
    },
  },
})

When a glob entry is violated, Vitest names both the rule and the file, for example: Coverage for lines (25%) does not meet "src/**/math.ts" threshold (100%) for src/math.ts. One important nuance from the docs: Vitest counts files matched by glob patterns into the global thresholds too — different from Jest, which scopes them out.1 Also, a glob entry only sets the metrics you list; it does not inherit the global numbers. If a glob block sets only lines, then only lines is enforced for those files.1

What do thresholds.100 and autoUpdate do?

thresholds.100: true is a shortcut that sets lines, functions, branches, and statements all to 100. thresholds.autoUpdate: true ratchets your thresholds upward in the config file whenever current coverage exceeds them.1

// 100% everywhere, the short way
export default defineConfig({
  test: {
    coverage: {
      include: ['src/**/*.ts'],
      thresholds: { 100: true },
    },
  },
})

You can also pass --coverage.thresholds.100 on the CLI for a one-off strict run. autoUpdate is the "coverage ratchet": when a PR pushes coverage from 85% to 88%, Vitest rewrites 85 to 88 in your config so the bar can never fall back. Pass a function to control formatting, such as autoUpdate: (newThreshold) => Math.floor(newThreshold) to keep whole numbers.1 Use autoUpdate locally or in a dedicated job — you do not want it rewriting config during a normal gated CI run.

Should I use the v8 or istanbul coverage provider?

Use v8 (the default) for most Node and browser projects; switch to istanbul only when you run somewhere V8 coverage isn't exposed, such as Firefox, Bun, or Cloudflare Workers.4 V8 uses AST-based remapping — introduced in v3.2.0 and the default and only mode since Vitest 4 — so its reports are now as accurate as Istanbul's, and the historical "V8 is fast but imprecise" tradeoff is largely gone.4

# v8 (default) — fast, no pre-instrumentation
npm i -D @vitest/coverage-v8

# istanbul — universal runtime compatibility
npm i -D @vitest/coverage-istanbul

Both providers support the same thresholds options, so you can switch providers without rewriting your gate.1 The default reporters are ['text', 'html', 'clover', 'json']; add json-summary if you plan to post coverage to pull requests (next section).5

How do I enforce Vitest coverage in GitHub Actions?

Run vitest run --coverage in a workflow. Thresholds make the step fail on low coverage; add the json-summary reporter and a reporting action if you also want a PR comment.56

# .github/workflows/test.yml
name: test
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read
  pull-requests: write   # only needed for the PR comment step

jobs:
  coverage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      # Fails the job if any threshold is missed (Vitest exits non-zero)
      - run: npx vitest run --coverage
      # Optional: post / update a coverage comment on the PR
      - if: github.event_name == 'pull_request'
        uses: davelosert/vitest-coverage-report-action@v2

Two things to keep straight. First, the hard gate is Vitest's own non-zero exit from the thresholds you configured — branch protection can then require the coverage check before merge. Second, davelosert/vitest-coverage-report-action reports and compares coverage in a single, auto-updating PR comment; it requires the json-summary reporter (and json for per-file detail) in your Vitest config and pull-requests: write permission.6 Keep both the threshold step and the comment step: one blocks the merge, the other shows reviewers what changed.

Bottom line and next steps

Coverage thresholds only protect you if they actually fail the build. In Vitest 4 that means nesting numbers under coverage.thresholds, setting coverage.include, and confirming a deliberately under-tested branch prints ERROR: lines and exits non-zero. Layer per-file or glob gates on critical modules, add autoUpdate to ratchet, and wire vitest run --coverage into CI with branch protection.

From here, deepen your testing pipeline: see our guide to unit testing strategies for reliable code, the walkthrough on testing AWS CDK apps in TypeScript for asserting infrastructure, and testing LLM prompts in CI with Promptfoo when your tests cover AI behavior.

Footnotes

  1. Vitest — coverage config (coverage.thresholds, perFile, autoUpdate, 100, glob patterns). https://vitest.dev/config/coverage 2 3 4 5 6 7 8 9 10 11 12 13 14

  2. npm registry, verified 2026-06-23: vitest@4.1.9, @vitest/coverage-v8@4.1.9. Reproduced on Node v22.22.3. https://www.npmjs.com/package/vitest

  3. Vitest — Migrating to Vitest 4.0 (prerequisites Vite ≥ 6 / Node ≥ 20; removal of coverage.all, coverage.extensions, coverage.ignoreEmptyLines; AST-based V8 remapping). https://vitest.dev/guide/migration#vitest-4 2 3 4 5 6 7

  4. Vitest — Coverage guide (v8 vs istanbul providers and runtime compatibility). https://vitest.dev/guide/coverage 2 3

  5. Vitest — coverage.reporter (default ['text','html','clover','json']). https://vitest.dev/config/coverage#coverage-reporter 2

  6. davelosert/vitest-coverage-report-action — requires the json-summary reporter and pull-requests: write; posts an auto-updating PR comment. https://github.com/davelosert/vitest-coverage-report-action 2 3

Frequently Asked Questions

Set numeric thresholds under coverage.thresholds in vitest.config.ts and run vitest run --coverage . A shortfall exits with code 1 , failing the CI step. 1