Modern Angular
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.
# 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.
# 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-watchWhat 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-v8as a devDependency and setcoverage: trueunderarchitect.test.options. - Pin
coverageReportersin the committed config: a human one (text-summary) plus a machine one (lcovorcobertura). - Run
ng test --coverage --no-watchonce 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 junitto the CI command so per-test results are machine-readable; keep it separate from coverage reporters. - Pass
--no-watchexplicitly in CI rather than relying onprocess.env.CIdetection. - Decide the provider on purpose: stay on v8 unless you need istanbul's module-scoped instrumentation, and document that the
runnerConfigfile 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
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.
André Ramosavailable for remote roles, UTC−3Get in touch →