Karma to Vitest in Angular: The Config That Actually Runs

After years of karma.conf.js, a test.ts bootstrap, and a browser launcher, the Angular v21 Vitest config is almost nothing: one line in angular.json. ng new generates it, the build target supplies the rest, and jsdom runs the tests. Here is what that line gives you, what you add, and the one detail that explains why fakeAsync breaks.

The whole test target is one line

After Karma you expect a config file. There is not one. The entire Vitest test target in angular.json is the builder line: { "builder": "@angular/build:unit-test" }. No vitest.config.ts, no karma.conf.js, no test.ts bootstrap to maintain.

ng new on Angular v21 generates exactly that, plus a tsconfig.spec.json whose only notable line is "types": ["vitest/globals"]. That is what lets describe, it, and expect work without an import. The runner defaults to a jsdom environment. That is the whole setup.

Some docs show an advanced target carrying a providersFile. That is optional, not the default. Start with the one line and add knobs only when a test needs them.

The first spec, on the default configts
// tsconfig.spec.json sets "types": ["vitest/globals"], so describe, it, and
// expect need no import. The unit-test builder runs this in jsdom by default.
describe('AppComponent', () => {
  it('creates', () => {
    const fixture = TestBed.createComponent(AppComponent);
    expect(fixture.componentInstance).toBeTruthy();
  });
});

Where polyfills, styles, and assets come from

The reason the test target can be one line is that it does not redefine your app. It reuses your project's build target (the development configuration) to compile the app under test. So the polyfills, styles, and assets you set under build are already in effect. You do not repeat them under test.

That detail has a consequence worth knowing before you write a single test. Because the build polyfills include zone.js, zone.js is loaded in your tests too. But the Vitest runner never wraps each test in Angular's test zone, so fakeAsync, tick, and waitForAsync throw the ProxyZone error. The config is fine. The timing helpers need a rewrite, which is a separate job.

ConcernWhere it is configured
Test runner, jsdom, tsconfig.spec.jsonThe test target (@angular/build:unit-test)
polyfills (zone.js), styles, assetsThe build target (development), reused automatically
Global providers, coverage, real browserOpt-in options under test (below)

The install: add Vitest, drop Karma

Moving an existing Karma project is mostly dependency surgery. Add Vitest and jsdom, remove the Karma and Jasmine packages, and point the test target at the unit-test builder. Angular 22 ships a migrate-karma-to-vitest schematic that automates the switch; the steps below are what it does under the hood, and what to review when it is done.

The first run is ng test. In CI add --no-watch, since watch mode defaults on in a terminal and off otherwise. Run the simple specs first and confirm they pass before you touch anything that bends time.

From a Karma project to a green Vitest runbash
npm install --save-dev vitest jsdom @vitest/coverage-v8
npm rm karma karma-chrome-launcher karma-jasmine karma-jasmine-html-reporter karma-coverage jasmine-core @types/jasmine
ng test --no-watch

The options worth adding

Keep the target minimal and add only what a test actually needs. These are the ones that come up most, all set under architect.test.options so you do not retype them on every run.

NeedOption under test.options
Global providers for every test (e.g. provideHttpClientTesting)providersFile (its default export is a Provider[])
Setup that runs after polyfills and TestBedsetupFiles
Coveragecoverage: true plus coverageReporters
A real browser instead of jsdombrowsers (needs @vitest/browser-playwright)
Your own Vitest config for pluginsrunnerConfig (Angular loads it but does not support its contents)

The config is the easy part

If the target is one line, why does the migration take an afternoon? Because the config was never the work. The work is the specs that break when the test zone is gone: the fakeAsync and waitForAsync tests that need a rewrite to Vitest fake timers. And before you start, it is worth deciding whether the migration is worth it at all for a given suite.

The config gets you a green runner in one line. The rest of this work is about keeping it green.

Reusable artifact

First green Vitest run, from Karma

  • Add vitest, jsdom, and @vitest/coverage-v8 as devDependencies.
  • Remove the Karma and Jasmine packages (karma*, jasmine-core, @types/jasmine).
  • Set the test target to a single builder line: @angular/build:unit-test.
  • Confirm tsconfig.spec.json has types: ["vitest/globals"], so describe/it/expect need no import.
  • Run ng test --no-watch and get the simple specs green before touching fakeAsync tests.
  • Add providersFile, coverage, or browsers only when a test actually needs them.

FAQ

Do I need a vitest.config.ts file in an Angular project?

No, not for the default setup. The @angular/build:unit-test builder ships working defaults (a jsdom environment and tsconfig.spec.json). You only add a Vitest config through the runnerConfig option when you need custom plugins, and Angular loads that file without supporting its contents.

Where do I put global test providers like provideHttpClientTesting?

In a providersFile: a TypeScript file whose default export is a Provider[]. Point the test target at it with the providersFile option and it applies to every test, the same role the old test.ts played for global setup.

Does the Angular Vitest runner use a real browser?

No, it runs in jsdom by default. Add the browsers option (which needs @vitest/browser-playwright) to run in a real headless browser when a test needs real layout or a browser API jsdom does not implement.

Why is zone.js still loaded if I moved to Vitest?

Because the test build reuses your build target's polyfills, and zone.js is in them. That is also why fakeAsync throws the ProxyZone error under Vitest: zone.js is present, but the runner does not establish the test zone those helpers need.

How do I get coverage with the Vitest runner?

Set coverage: true and coverageReporters (for example text and lcov) under the test options, or pass --coverage. It uses @vitest/coverage-v8, which you add as a devDependency.

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 · 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 →

Guide · 7 min

Choosing an Angular Test Runner in 2026: Vitest, Jest, or Karma

A decision map for the Angular test runner question, by where your suite actually starts, and why 'Cypress vs Vitest' is the wrong question.

Read article →