Vitest Coverage Reports: CI Setup and Team Visibility

By Alex Gandy June 29, 2026

A Vitest coverage report is one —coverage flag and a few lines in vitest.config.ts away. The part the official docs skip is what happens to that report after the run: how a team keeps coverage history, sees it without rerunning the suite, and watches the number trend up or down across branches. Gaffer’s own dashboard is a pnpm monorepo that uploads Vitest coverage from every CI run, so the after-the-run problem this guide describes is the exact one this codebase solves daily. This guide covers setup and CI enforcement, then keeps going to durable storage, team visibility, and per-package coverage in a pnpm monorepo.

Vitest coverage providers: v8 vs Istanbul

Vitest supports two coverage providers, v8 (the default) and Istanbul. v8 reads coverage data straight from the V8 engine with no instrumentation step, so it is fast and zero-config. Istanbul instruments your source before the run, which costs time but produces more precise branch coverage.

Which provider should you choose?

v8 is the default and the fastest for most projects; Istanbul gives more precise branch coverage at a speed cost. The two providers agree on line and function coverage almost all the time. Where they diverge is branch coverage: v8’s source-map-based remapping occasionally miscounts branches in heavily-transpiled code (TypeScript with downlevel targets, decorators, some JSX). If your branch percentage looks suspiciously off, switch to Istanbul and compare.

v8Istanbul
SpeedFaster (no instrumentation pass)Slower
SetupZero-config, defaultNeeds the istanbul package
Branch precisionGood, occasional remap errorsMore precise
Best forMost projectsBranch-sensitive enforcement

Start with v8. Move to Istanbul only when you have a concrete reason.

Installing @vitest/coverage-v8 or @vitest/coverage-istanbul

The provider ships as a separate package. Install the one you want:

Terminal window
# Default, recommended
npm install --save-dev @vitest/coverage-v8
# Only if you need Istanbul's branch precision
npm install --save-dev @vitest/coverage-istanbul

Vitest prompts to install the provider on first --coverage run if it is missing, but pinning it in devDependencies keeps CI reproducible.

Configuring coverage in vitest.config.ts

Coverage configuration lives under the test.coverage key in vitest.config.ts. The minimum useful config sets the provider, the reporters, and which files to include so coverage counts your whole source tree rather than only the files a test happened to import.

vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "json", "lcov"],
include: ["src/**/*.{ts,tsx}"],
reportsDirectory: "./coverage",
},
},
});

The reporter array controls the output formats. text prints a summary table to the console, html writes the browsable report, json and lcov are the machine-readable formats that CI tools and hosted dashboards ingest. Generating all of them in one pass costs nothing extra; each reporter reads the same collected data.

Setting coverage thresholds

coverage.thresholds makes Vitest fail when coverage drops below the numbers you set. Each metric (lines, functions, branches, statements) takes a percentage, and Vitest exits non-zero if any of them falls short.

export default defineConfig({
test: {
coverage: {
provider: "v8",
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
});

A useful variant is thresholds.autoUpdate: true, which rewrites the threshold numbers to the current coverage whenever it goes up. That ratchets coverage in one direction: it can only ever be raised by a passing run, never silently lowered. Pair it with a committed config so the ratchet is visible in the diff.

Excluding files from coverage

Coverage of generated code, config files, and type-only modules is noise. Use coverage.exclude to drop them from the denominator:

import { coverageConfigDefaults } from "vitest/config";
coverage: {
exclude: [
...coverageConfigDefaults.exclude,
"**/*.config.ts",
"**/*.d.ts",
"**/types/**",
"**/__mocks__/**",
"src/**/*.stories.tsx",
],
},

Supplying coverage.exclude replaces Vitest’s default exclude list (node_modules, the test files themselves, dist) rather than extending it, so spread coverageConfigDefaults.exclude (from vitest/config) to keep the defaults. Be deliberate here: excluding a file you simply have not tested yet inflates the percentage and defeats the point.

Running Vitest coverage in CI (GitHub Actions)

In CI you run vitest run --coverage so the process exits when the run finishes instead of dropping into watch mode, then you do something durable with the generated report. The reporter config from vitest.config.ts carries over unchanged; CI only adds the flag and the upload.

Generating the coverage report artifact

Add --coverage to the test step and upload the generated report directory as a build artifact or to a hosting service. The bare workflow:

.github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx vitest run --coverage
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/

The if: always() matters. The runs you most want coverage from are the ones where something failed, so gating the upload on a passing job throws away the data you need.

Blocking PRs when coverage drops below threshold

With coverage.thresholds set in vitest.config.ts, no extra CI scripting is needed. Vitest exits non-zero when coverage falls below the configured percentage, and a non-zero exit fails the GitHub Actions job, which blocks the merge if the check is required.

Terminal window
npx vitest run --coverage
# Exits 1 if any metric is below its threshold.
# That exit code fails the job. No grep, no jq, no custom gate.

To require the check, mark the workflow job as required in the branch protection rule for main. The threshold lives in version control, so changing it is a reviewable diff rather than a hidden CI setting.

Posting coverage summaries as PR comments

Two common patterns put the number in front of reviewers without making them open an artifact. The first uses a marketplace action that reads the coverage-summary.json Vitest writes and posts a comment. The second uses the json-summary reporter plus a short step that writes to $GITHUB_STEP_SUMMARY:

- run: npx vitest run --coverage
- name: Coverage summary
if: always()
run: |
echo "### Coverage" >> "$GITHUB_STEP_SUMMARY"
npx --yes coverage-summary-md coverage/coverage-summary.json >> "$GITHUB_STEP_SUMMARY"

Add "json-summary" to your coverage.reporter array for either approach. A PR comment answers “did this PR move coverage?” for one pull request. It does not answer “is coverage trending up over the last month?” That second question is the one the next section is about.

Storing and sharing Vitest coverage reports with Gaffer

Local coverage reports vanish after each run, so to share them and watch trends you upload them somewhere durable that keeps history. This is the after-the-run problem the Gaffer dashboard runs into on its own pnpm monorepo, and the reason the upload step below exists.

Why local coverage reports disappear after each run

The HTML reporter writes coverage/index.html to the same directory every run, with no run ID, timestamp, or history in the path. Run the suite again and the previous report is overwritten. On CI it is worse: the report lives on a runner that gets destroyed when the job ends, surviving only as a zipped artifact behind the Actions tab. To open it, a teammate navigates to the run, clicks into the job, scrolls to artifacts, downloads a zip, and unzips it. To compare two runs, they do all of that twice. CI artifacts are storage for the report, not a viewing surface, and they expire.

Dumping coverage/ into an S3 bucket gets you a permalink but nothing else: no cross-run trend, no per-package breakdown that survives, no way to ask “when did coverage start dropping?” You have a static file with a URL. That is a starting point, not the answer.

Uploading coverage to Gaffer from CI

Gaffer ingests the coverage report Vitest already produces (lcov or the JSON summary) and gives it a durable home with a persistent URL and cross-run history. The GitHub Action reference is gaffer-sh/gaffer-uploader@v2:

- run: npx vitest run --coverage
- name: Upload coverage to Gaffer
if: always()
uses: gaffer-sh/gaffer-uploader@v2
with:
gaffer_upload_token: ${{ secrets.GAFFER_UPLOAD_TOKEN }}
report_path: ./coverage
commit_sha: ${{ github.sha }}
branch: ${{ github.ref_name }}
test_framework: vitest

The same upload works from the CLI if you would rather not use the Action:

Terminal window
gaffer upload ./coverage \
--token $GAFFER_UPLOAD_TOKEN \
--commit-sha $GITHUB_SHA \
--branch $GITHUB_REF_NAME \
--test-framework vitest

The flags map one-to-one with the CLI: --token accepts a gfr_… project token (or falls back to GAFFER_PROJECT_TOKEN, then GAFFER_UPLOAD_TOKEN, then GAFFER_TOKEN), --commit-sha and --branch are recorded as tags so you can filter runs by branch later, and --test-framework labels the run. The full setup, including which coverage formats Gaffer parses and how thresholds surface on the dashboard, is in the coverage docs.

One coverage report tells you today’s number. Fifty tell you whether coverage has been quietly sliding for three weeks while every PR stayed just above the threshold. Gaffer’s code coverage solution keeps each upload as its own record, addressable by commit and branch, so the dashboard shows a trend line instead of a single number and you can compare a PR branch against main at a glance.

Because Gaffer stores the test run alongside the coverage report, pass rate and coverage land in one view. A PR that adds tests should move both numbers, and seeing them together catches the case where coverage went up but the new tests are flaky. If you want the pass-rate side of that picture in depth, Gaffer’s Vitest test reporting covers it for the same upload.

Vitest coverage in monorepos

Vitest does not merge coverage across a workspace automatically. In a monorepo you run coverage per package, then aggregate the per-package results into one report. This is the part that trips teams the first time they add a second package.

Per-package coverage in pnpm workspaces

The clean approach is to give each package its own Vitest config and run coverage per package, then collect the results. With pnpm workspaces, a recursive run executes each package’s tests in its own root:

Terminal window
pnpm -r --filter "./packages/*" exec vitest run --coverage

Each package writes its own coverage/ directory. The catch is the include and reportsDirectory paths: a config at the repo root that points include at src/** will only ever see the root’s source, not each package’s. Keeping a small Vitest config per package, or using Vitest’s projects config to define each workspace package as a project, keeps the per-package numbers honest.

Aggregating coverage across packages

To get a single repo-wide number, collect the per-package lcov files and merge them. The lcov format concatenates, so the common pattern is to point a merge tool at every package’s lcov.info:

Terminal window
# Collect each package's lcov into one file
find packages -name lcov.info -path "*/coverage/*" \
| xargs cat > coverage/merged.lcov

The following is illustrative of the shape, not a live figure from any run:

{
"total": {
"lines": { "total": 4120, "covered": 3460, "pct": 83.98 },
"branches": { "total": 1880, "covered": 1390, "pct": 73.93 }
}
}

Uploading the merged result to a hosted layer sidesteps the merge math entirely for the trend view: Gaffer keeps each package’s report and the combined picture, so you can drill from the repo-wide number into the one package that is dragging it down. This piece sits in the broader story of running multiple test frameworks in one place, which is where the per-package, per-framework reports converge into a single dashboard.

FAQs

Which Vitest coverage provider should you use, v8 or Istanbul?

v8 is the default and the fastest for most projects, because it reads coverage straight from the V8 engine with no instrumentation pass. Istanbul instruments your code before running, which is slower but gives more precise branch coverage. Start with v8 and switch to Istanbul only if you need branch numbers it gets wrong.

How do you run Vitest coverage in GitHub Actions?

Add the --coverage flag to your Vitest test step, then upload the generated coverage directory as a build artifact or to a hosting service so the report survives after the runner is destroyed. Use vitest run --coverage rather than the watch-mode default so the process exits when the run finishes.

How do you fail a build when Vitest coverage drops below a threshold?

Set coverage.thresholds in vitest.config.ts. Vitest exits with a non-zero code when any configured metric falls below its percentage, which fails the CI job automatically with no extra scripting.

How do you view a Vitest coverage report in the browser?

Add the html reporter to coverage.reporter, which writes a browsable index.html under coverage/. Open that file to see line-by-line coverage per source file. The report only exists on the machine that ran the tests unless you host it somewhere durable.

How do you track Vitest coverage across a monorepo or pnpm workspace?

Run coverage per package, then aggregate the results. Most reporters do not merge workspace coverage automatically, so you either generate one report per package or use a coverage merge step, then upload the combined result to a place that keeps history.

Local coverage reports vanish after each run and CI artifacts expire, so you need to upload them somewhere durable that keeps history. A hosted layer gives you a persistent URL and a coverage trend across branches and over weeks instead of a single number per run.

Start Free