---
title: "Vitest Coverage and CI for Angular: Providers, Thresholds, and the Karma Gap"
description: "How coverage and CI work with the Angular Vitest builder: the ng test --coverage flag, coverageThresholds that fail the build, junit reporters for CI, and the one thing the builder does not expose that karma-coverage did."
deck: "Coverage is [first-class in the Angular CLI](https://angular.dev/guide/testing/code-coverage): `ng test --coverage` reads builder options from `angular.json`, and `coverageThresholds` fails the run below a floor you set (the docs use 80%). But the v8-vs-istanbul provider choice is not an Angular option. It lives in raw Vitest config, and Angular [does not support that file's contents](https://angular.dev/guide/testing/migrating-to-vitest)."
author: "André Ramos"
url: "https://andreramos.dev/angular/vitest-coverage-and-ci-angular/"
lang: "en"
type: "article"
---

# Vitest Coverage and CI for Angular: Providers, Thresholds, and the Karma Gap

> Coverage is [first-class in the Angular CLI](https://angular.dev/guide/testing/code-coverage): `ng test --coverage` reads builder options from `angular.json`, and `coverageThresholds` fails the run below a floor you set (the docs use 80%). But the v8-vs-istanbul provider choice is not an Angular option. It lives in raw Vitest config, and Angular [does not support that file's contents](https://angular.dev/guide/testing/migrating-to-vitest).

## Coverage is a builder option, not a config file

With Karma, coverage meant `karma-coverage` wired into `karma.conf.js`: a reporter block, an istanbul output directory, thresholds in a `check` object. The Vitest builder moves all of that into `angular.json` under `architect.test.options`. There is no coverage config file to maintain for the common case. You set `coverage: true` and the keys next to it.

The flag is `ng test --coverage`, and the docs are explicit that coverage is a first-class CLI feature. The one dependency you add is `@vitest/coverage-v8`. Its own help text tells you the default state: `If not specified, the coverage configuration from a runner configuration file will be used if present. Otherwise, coverage is disabled by default.` So coverage is off until you ask for it, on the flag or in the file.

The reporter names are the istanbul set you already know from Karma: `text`, `text-summary`, `html`, `lcov`, `lcovonly`, `cobertura`, `json`, `json-summary`. The `lcov` and `cobertura` ones are the two a CI dashboard ingests, so those are the ones worth pinning in the committed config rather than passing on the command line.

*Coverage in angular.json, the way the docs show it*

```text
# angular.json -> projects.<name>.architect.test.options
# Requires: npm i -D @vitest/coverage-v8
{
  "builder": "@angular/build:unit-test",
  "options": {
    "coverage": true,
    "coverageReporters": ["text-summary", "lcov", "cobertura"],
    "coverageThresholds": {
      "statements": 80,
      "branches": 80,
      "functions": 80,
      "lines": 80
    }
  }
}
```

## The gap: the provider choice is not yours to make here

This is the one place the Angular surface is narrower than it looks, and it is worth knowing before you promise a team an istanbul report. Vitest has two coverage providers: `v8` (the default) collects coverage at runtime through `node:inspector` and runs your source as-is, while `istanbul` instruments the source files and runs on any JavaScript runtime. They differ in real ways: v8 cannot limit collection to specific modules, and only v8 needs a V8-based runtime.

The Angular code coverage page documents exactly one provider package, `@vitest/coverage-v8`. There is no `coverageProvider` option in the builder. Switching to istanbul is a raw-Vitest setting, `coverage.provider: 'istanbul'`, which means a `vitest.config.ts` reached through the `runnerConfig` option. And the migration guide is blunt about that file: the Angular team does not support its contents or third-party plugins. You can do it; you are off the supported path when you do.

My read: stay on v8 unless you have a concrete reason not to. Since Vitest 3.2 the v8 provider uses AST-based remapping that the docs say produces reports identical to istanbul, so the old accuracy argument for switching is mostly gone. I would only reach for istanbul if I needed its module-scoped instrumentation, and I would treat that `runnerConfig` file as something the team owns and reviews, not a default to copy from a blog.

| Dimension | v8 (Angular default) | istanbul (runnerConfig only) |
|---|---|---|
| Package | `@vitest/coverage-v8` | `@vitest/coverage-istanbul` |
| How it collects | Runtime via `node:inspector`, source runs as-is | Instruments source files before running |
| Set via | Just `coverage: true` in `angular.json` | `coverage.provider: 'istanbul'` in a Vitest config |
| Angular support | First-class, documented | Unsupported file contents (`runnerConfig`) |
| Limit to specific modules | No | Yes |
| Accuracy vs istanbul | Identical since Vitest 3.2 (AST remap) | Baseline |

## Thresholds that actually fail the build

A coverage number nobody enforces is decoration. The point of `coverageThresholds` is that it turns coverage into a gate: the Angular docs state plainly that if your coverage drops below the configured floor when you run your tests, the command will fail. That non-zero exit is what makes the gate real in CI, and it is the behavior karma-coverage's `check` block gave you, now expressed in `angular.json`.

The four keys are `statements`, `branches`, `functions`, and `lines`, each a percentage. I do not pick 80 because it is a good number; I pick the floor the suite already clears today, commit it, and ratchet it up as coverage improves. A threshold set above where you are is just a red build on day one, which trains the team to pass `--no-coverage` and defeats the point.

One caution specific to the move from Karma. Coverage percentages shift when the provider or the included file set changes, so the number you enforced under karma-coverage is not guaranteed to be the number Vitest reports for the same code. Measure first on the new runner, then set the threshold from that measurement.

*Local coverage check before you trust the threshold*

```bash
# See the real number Vitest reports for your code, then set the floor from it.
ng test --coverage --coverage-reporters text-summary --no-watch

# Narrow the included set if vendored or generated files skew the percentage.
ng test --coverage --coverage-include "src/app/**/*.ts" --no-watch
```

## What changes on the CI runner

The mechanical CI difference from Karma is that you no longer fight a browser launcher. The Vitest builder runs in jsdom by default, so there is no headless-Chrome flag to babysit. What you do still want is run-once behavior and a machine-readable report.

Run mode is mostly automatic. Vitest starts in run mode when `process.env.CI` is set, and falls back to run mode when stdin is not a TTY, so a plain `ng test` on most CI providers already runs once and exits. I still pass `--no-watch` explicitly in the CI command. Relying on environment detection is the kind of implicit behavior that turns into a hung pipeline the day you run the same command in a context that happens to look interactive.

For the report, the test execution reporters include `junit`, the format Jenkins, GitLab, and Azure Pipelines parse natively for per-test results. Coverage and test results are two separate streams: `--reporters junit` is the test outcome, `--coverage-reporters cobertura` (or `lcov`) is the coverage your dashboard reads. The Karma-era `--no-watch --no-progress` pair collapses to the run-mode default plus the reporters you actually need.

| Karma setup | Vitest builder equivalent | Note |
|---|---|---|
| `karma-coverage` in `karma.conf.js` | `coverage` + `coverageReporters` in `angular.json` | No coverage config file for the common case. |
| istanbul reporter, fixed | v8 provider; istanbul only via `runnerConfig` | Provider is not a first-class Angular option. |
| `check` thresholds in `karma.conf.js` | `coverageThresholds` (statements/branches/functions/lines) | Fails the run below the floor, same gate. |
| `--no-watch --no-progress` for CI | Run mode auto-detected; pass `--no-watch` anyway | No browser launcher to configure. |
| `karma-junit-reporter` | `--reporters junit` (built in) | Coverage and test reports are separate streams. |

## Watch locally, isolate by default

Locally the default is the opposite of CI, and that is what you want. The `--watch` flag defaults to `true` in a TTY, so a bare `ng test` at your desk re-runs on save. There is no separate `--watch` invocation to remember; the terminal context decides.

Two Vitest defaults are worth knowing because they explain both the speed and the occasional flake. The default pool is `forks`, and test files run in parallel across worker processes, which is most of why the suite feels faster than Karma. Within a single file, tests still run sequentially. Isolation is on by default: each file gets a fresh environment. That isolation is also a knob. Disabling it (`--no-isolate`, or `test.isolate: false` in a Vitest config) is faster but lets state bleed across files, so it is a CI speed lever I would only pull after the suite is green and I have a measurement that says it helps.

## Reusable artifact: CI readiness checklist for the Vitest coverage cutover

- Add `@vitest/coverage-v8` as a devDependency and set `coverage: true` under `architect.test.options`.
- Pin `coverageReporters` in the committed config: a human one (`text-summary`) plus a machine one (`lcov` or `cobertura`).
- Run `ng test --coverage --no-watch` once and read the real Vitest number before setting any threshold.
- Set `coverageThresholds` (statements/branches/functions/lines) to the floor the suite clears today, not an aspirational number.
- Confirm the build actually fails below the threshold (non-zero exit), not just prints a warning.
- Add `--reporters junit` to the CI command so per-test results are machine-readable; keep it separate from coverage reporters.
- Pass `--no-watch` explicitly in CI rather than relying on `process.env.CI` detection.
- Decide the provider on purpose: stay on v8 unless you need istanbul's module-scoped instrumentation, and document that the `runnerConfig` file is unsupported by Angular.

## FAQ

### How do I enable coverage with the Angular Vitest builder?

Install `@vitest/coverage-v8` as a devDependency, then either pass `ng test --coverage` or set `coverage: true` under `architect.test.options` in `angular.json`. Coverage is off by default until you ask for it. Add `coverageReporters` (for example `lcov` and `text-summary`) to control the output formats.

### Can I use the istanbul coverage provider in Angular instead of v8?

Not through a first-class Angular option. The CLI documents only `@vitest/coverage-v8`, and there is no `coverageProvider` builder option. Switching to istanbul means setting `coverage.provider: 'istanbul'` in a `vitest.config.ts` reached via the `runnerConfig` option, and Angular explicitly does not support the contents of that file. Since Vitest 3.2 the v8 provider's AST remapping produces reports identical to istanbul, so most teams have no reason to switch.

### How do I make Angular tests fail when coverage is too low?

Set `coverageThresholds` in `angular.json` with `statements`, `branches`, `functions`, and `lines` percentages. The Angular docs state that if coverage drops below the configured floor when you run the tests, the command fails with a non-zero exit. Set the floor to what the suite clears today and ratchet it up, rather than starting above where you are.

### How do I run Angular Vitest tests once in CI without watch mode?

Vitest auto-detects CI: it starts in run mode when `process.env.CI` is set, and falls back to run mode when stdin is not a TTY. Locally, watch defaults on in a terminal. For a CI command I still pass `--no-watch` explicitly so the behavior does not depend on environment detection. Add `--reporters junit` for machine-readable per-test results.

### What is the difference between coverageReporters and reporters in the Angular test builder?

`reporters` configures test-execution output (built-in names include `default`, `verbose`, `junit`, `tap`); `coverageReporters` configures coverage output (`text`, `lcov`, `lcovonly`, `cobertura`, `html`, `json`, `json-summary`). They are separate streams: junit reports which tests passed, while lcov or cobertura reports how much code they covered.

## Sources checked

- https://angular.dev/guide/testing/code-coverage
- https://angular.dev/cli/test
- https://angular.dev/guide/testing/migrating-to-vitest
- https://angular.dev/guide/testing
- https://vitest.dev/guide/coverage
- https://vitest.dev/config/coverage
- https://vitest.dev/guide/parallelism
- https://blog.angular.dev/announcing-angular-v21-57946c34f14b

## Related

- [Karma to Vitest in Angular: The Config That Actually Runs](https://andreramos.dev/angular/karma-to-vitest-angular-config/)
- [When to Move an Angular Test Suite to Vitest](https://andreramos.dev/angular/move-angular-test-suite-to-vitest/)
- [fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest](https://andreramos.dev/angular/angular-vitest-fakeasync-proxyzone-error/)
