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

Coverage is first-class in the Angular CLI: 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.

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 ittext
# 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.

Dimensionv8 (Angular default)istanbul (runnerConfig only)
Package@vitest/coverage-v8@vitest/coverage-istanbul
How it collectsRuntime via node:inspector, source runs as-isInstruments source files before running
Set viaJust coverage: true in angular.jsoncoverage.provider: 'istanbul' in a Vitest config
Angular supportFirst-class, documentedUnsupported file contents (runnerConfig)
Limit to specific modulesNoYes
Accuracy vs istanbulIdentical 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 thresholdbash
# 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 setupVitest builder equivalentNote
karma-coverage in karma.conf.jscoverage + coverageReporters in angular.jsonNo coverage config file for the common case.
istanbul reporter, fixedv8 provider; istanbul only via runnerConfigProvider is not a first-class Angular option.
check thresholds in karma.conf.jscoverageThresholds (statements/branches/functions/lines)Fails the run below the floor, same gate.
--no-watch --no-progress for CIRun mode auto-detected; pass --no-watch anywayNo 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

Angular Vitest Migration Kit

Migrating this suite to Vitest?

The Angular Vitest Migration Kit sizes your migration by risk, then packages the config, the fakeAsync cookbook, an AI-agent skill, and a runnable demo. The conversions in this article, done for you.

Verified on Angular 21.2.14 / Vitest 4.1.8. Includes the migration estimator, the PDF guide, copy-paste config, an AI-agent skill, and a runnable demo.

Get the kit · US$15 →

André Ramosavailable for remote roles, UTC−3Get in touch →

Related

Guide · 7 min

Karma to Vitest in Angular: The Config That Actually Runs

What the Angular v21 Vitest test target actually is (one line), where polyfills and styles come from, the install that drops Karma, and the few options worth adding.

Read article →

Guide · 6 min

When to Move an Angular Test Suite to Vitest

Vitest is the Angular CLI default, but migrating an existing suite is still experimental: which specs to pilot first, what fakeAsync breaks, and how to keep CI trustworthy.

Read article →

Guide · 9 min

fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest

Why fakeAsync, tick, and waitForAsync throw the ProxyZone error under Angular's Vitest runner, and the two fixes: the experimental zone testing patch, or a rewrite to Vitest fake timers.

Read article →