---
title: 'Vitest Coverage Thresholds: Fail CI on Low Coverage (2026)'
description: Set Vitest coverage thresholds that fail CI when coverage drops. Vitest 4 nested config, per-file and glob gates, plus a runnable GitHub Actions workflow.
url: https://nerdleveltech.com/vitest-coverage-thresholds-fail-ci-tutorial
language: en
date: '2026-06-23'
category: cloud-devops
tags:
  - vitest
  - coverage
  - testing
  - ci-cd
  - github actions
  - typescript
  - cloud-devops
markdown_url: https://nerdleveltech.com/vitest-coverage-thresholds-fail-ci-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/vitest-coverage-thresholds-fail-ci-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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.[^thresholds]

## 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`.[^versions]

## 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.[^thresholds]

Install the test runner and a coverage provider:

```bash
npm i -D vitest @vitest/coverage-v8
```

Create a small source file to test:

```typescript
// 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`:

```typescript
// 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:

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

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

Run it:

```bash
npx vitest run --coverage
```

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

```text
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."[^thresholds]

## 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.[^thresholds]

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

```typescript
// 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`.[^migration] 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**.[^migration]

| Area | Vitest 3 | Vitest 4 |
|------|----------|----------|
| Report scope | `coverage.all` defaulted to `true` (every matching file) | `coverage.all` removed; report covers only loaded files unless `coverage.include` is set[^migration] |
| Empty lines | `ignoreEmptyLines` available | removed; lines without runtime code are no longer counted[^migration] |
| V8 remapping | `v8-to-istanbul` by default (AST remapping opt-in since v3.2.0) | AST-based remapping is the default and only mode[^migration] |

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.[^migration]

## 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.[^thresholds]

```typescript
// 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.[^thresholds] 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.[^thresholds]

## 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.[^thresholds]

```typescript
// 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.[^thresholds] 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.[^providers] 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.[^providers]

```bash
# 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.[^thresholds] The default reporters are `['text', 'html', 'clover', 'json']`; add `json-summary` if you plan to post coverage to pull requests (next section).[^reporter]

## 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.[^reporter][^action]

```yaml
# .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.[^action] Keep both the threshold step and the comment step: one blocks the merge, the other shows reviewers what changed.

## FAQ

**How do I make Vitest fail CI when coverage drops?** 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.[^thresholds]

**Why isn't my Vitest coverage threshold failing the build?** You're likely using a flat config (`coverage: { lines: 90 }`). Vitest silently ignores flat keys — nest them under `coverage.thresholds` (the required layout since v1.0). Also set `coverage.include` so untested files actually count.[^thresholds]

**What changed for coverage in Vitest 4?** `coverage.all` and `ignoreEmptyLines` were removed and AST-based V8 remapping became the only mode; Vitest 4 needs Vite ≥ 6 and Node ≥ 20. The `coverage.thresholds` object is unchanged — it has been the layout since v1.0.[^migration]

**How do I set per-file coverage thresholds?** Set `thresholds.perFile: true` for global per-file checks, and add glob keys like `'src/**/*.ts': { lines: 100 }` for path-specific gates. Glob files still count toward global thresholds.[^thresholds]

**What does `thresholds.100` do?** It sets lines, functions, branches, and statements to 100 in one option; the CLI form is `--coverage.thresholds.100`.[^thresholds]

**v8 or istanbul?** Use `v8` (default) unless you need a runtime that doesn't expose V8 coverage (Firefox, Bun, Cloudflare Workers); then use `istanbul`. V8 accuracy now matches Istanbul.[^providers]

**Does the PR comment action enforce the threshold?** No. `davelosert/vitest-coverage-report-action` reports coverage; the failing gate is Vitest's own exit code from your thresholds.[^action]

## 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](https://nerdleveltech.com/mastering-unit-testing-strategies-for-reliable-code), the walkthrough on [testing AWS CDK apps in TypeScript](https://nerdleveltech.com/aws-cdk-testing-typescript) for asserting infrastructure, and [testing LLM prompts in CI with Promptfoo](https://nerdleveltech.com/promptfoo-test-llm-prompts-ci-tutorial) when your tests cover AI behavior.

## Related reads

- [AWS CDK Testing in TypeScript: Test Before Deploy 2026](https://nerdleveltech.com/aws-cdk-testing-typescript)

[^thresholds]: Vitest — coverage config (`coverage.thresholds`, `perFile`, `autoUpdate`, `100`, glob patterns). https://vitest.dev/config/coverage
[^versions]: 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
[^migration]: 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
[^providers]: Vitest — Coverage guide (v8 vs istanbul providers and runtime compatibility). https://vitest.dev/guide/coverage
[^reporter]: Vitest — `coverage.reporter` (default `['text','html','clover','json']`). https://vitest.dev/config/coverage#coverage-reporter
[^action]: 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
