Angular Unit Testing with Vitest: ng test, Setup and Migration [2026]
For years, running ng test meant waiting for Karma to start a Chrome window, watching a Jasmine HTML report flash past, and debugging failures in a browser tab you never asked for. New Angular projects no longer work that way. Since v21 the CLI generates projects that run unit tests with Vitest, through a builder that reuses the same build pipeline as ng serve. Tests start faster, run in Node with an emulated DOM by default, and use an API that most of the JavaScript world already knows.
This is lesson 11.1 of the Angular Tutorial, and the first lesson of Module 11 on testing. It follows lesson 10.8 on hydration mismatches, which closed the debugging module. Here you'll learn how the Vitest setup in a new project is wired, which angular.json options matter, how to choose between DOM emulation and a real browser, how to get coverage in CI, and how to move an existing Karma and Jasmine project across. The rest of the module builds on this setup: signals and effects (11.2), components (11.3), pipes, directives and services (11.4), and end-to-end tests (11.5).
How the pieces fit #
Three layers are involved when you run ng test, and most confusion comes from mixing them up:
| Layer | What it is | What it decides |
|---|---|---|
@angular/build:unit-test |
The Angular CLI builder behind ng test |
How your app code and specs are compiled (esbuild, same as the app build), which files are tests, which providers are global |
| Vitest | The test runner and assertion library | describe, it, expect, vi.fn(), fake timers, watch mode, reporters, coverage |
| Environment | Where the tests execute | jsdom (default) or happy-dom in Node, or a real browser via Vitest browser mode |
The builder compiles your specs with Angular's compiler and hands the output to Vitest. That's the main difference from a plain Vitest project: you don't write a vite.config.ts with an Angular plugin, and you don't run npx vitest directly. You run ng test, and the builder owns the Angular-specific part.
What a new project contains #
A freshly generated v22 project already has everything. The relevant parts:
{
"projects": {
"shop": {
"architect": {
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
{
"devDependencies": {
"jsdom": "...",
"vitest": "..."
}
}
There is no karma.conf.js, no src/test.ts, and no jasmine-core. Specs still live next to the code they test, and by default the builder picks up files matching **/*.spec.ts and **/*.test.ts.
A generated spec looks familiar if you've used Jasmine, because Vitest's core API is deliberately similar:
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { App } from './app';
describe('App', () => {
beforeEach(() => {
TestBed.configureTestingModule({ imports: [App] });
});
it('renders the title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain('shop');
});
});
TestBed is unchanged; Angular's testing utilities work the same with either runner. What changes is everything around them: matchers, spies, timers and configuration.
Running tests day to day #
ng test # build + run, then watch for changes
ng test --no-watch # single run, exits with a status code
ng test --no-watch --no-progress # what you want in CI logs
ng test --coverage # adds a coverage report in coverage/
ng test --include='src/app/cart/**/*.spec.ts' # only some specs
In watch mode, the builder rebuilds what changed and Vitest re-runs the tests, so you get feedback on every save without restarting anything. To focus on one area, use --include or temporarily mark a test with it.only, the Vitest equivalent of Jasmine's fit.
The angular.json options that matter #
Most projects never touch more than a few options on the test target:
| Option | Default | Use it for |
|---|---|---|
include / exclude |
**/*.spec.ts, **/*.test.ts |
Narrowing or widening what counts as a test file |
setupFiles |
none | Code that runs before every spec file: custom matchers, global mocks, polyfills for APIs jsdom lacks |
providersFile |
none | A file whose default export is an array of providers added to every TestBed |
coverage |
false |
Turning coverage on permanently instead of passing --coverage |
browsers |
none (Node + DOM emulation) | Running specs in real browsers through Vitest browser mode |
runnerConfig |
none | Pointing at a Vitest config file for anything the builder doesn't expose |
providersFile is the one teams adopt first. Instead of repeating the same providers in every configureTestingModule call, put them in one place:
// src/test-providers.ts
import { EnvironmentProviders, Provider } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
const testProviders: (Provider | EnvironmentProviders)[] = [
provideHttpClient(),
provideHttpClientTesting(),
];
export default testProviders;
"test": {
"builder": "@angular/build:unit-test",
"options": {
"providersFile": "src/test-providers.ts",
"setupFiles": ["src/test-setup.ts"]
}
}
Keep this file small. Anything a single spec needs should stay in that spec, where a reader can see it.
When you need full Vitest configuration #
The builder exposes the common options, but Vitest has many more: reporters, coverage thresholds, test timeouts, sequence settings. Generate a base config and point the builder at it:
ng generate config vitest
This creates vitest-base.config.ts, which the builder merges with its own settings once you reference it:
"options": {
"runnerConfig": "vitest-base.config.ts"
}
// vitest-base.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
testTimeout: 10_000,
coverage: {
thresholds: { lines: 80, branches: 70 },
},
},
});
Don't redefine things the builder already controls, such as how TypeScript is compiled or which files are included; set those in angular.json so there's one source of truth.
DOM emulation or a real browser #
By default your specs run in Node, and jsdom provides document, window and the DOM APIs Angular needs. That's fast and works in any CI container, but it is an emulation: there's no layout engine, so getBoundingClientRect() returns zeros, IntersectionObserver and ResizeObserver don't exist unless you mock them, and CSS isn't applied.
| Environment | Speed | Fidelity | Choose it when |
|---|---|---|---|
jsdom (default) |
Fast | Good for DOM structure and events | Most component, service and pipe tests |
happy-dom |
Often faster than jsdom |
Similar, with different gaps | You want speed and your tests don't hit its gaps |
| Browser mode (Playwright or WebdriverIO) | Slower to start | A real engine: layout, CSS, real events | Tests that depend on layout, focus, scrolling, Web APIs jsdom lacks |
To switch to happy-dom, install it and uninstall jsdom; the builder uses whichever is present.
For browser mode, install a provider and choose browsers:
npm install --save-dev @vitest/browser-playwright playwright
ng test --browsers=chromium # opens a visible browser
ng test --browsers=chromiumHeadless # for CI
WebdriverIO works the same way with @vitest/browser-webdriverio and webdriverio. A practical split for larger apps is to keep the bulk of the suite on jsdom and run a smaller set of layout-sensitive specs in a headless browser.
Coverage in CI #
ng test --no-watch --no-progress --coverage
The report lands in coverage/. Two habits make coverage useful rather than decorative:
- Gate on thresholds, not a number in a dashboard. Thresholds in
vitest-base.config.tsmake the run fail when coverage drops, so the drop is discussed in the pull request that caused it. - Exclude generated and bootstrap code.
main.ts, route arrays and environment files inflate or deflate the percentage without telling you anything.
Migrating from Karma and Jasmine #
Existing projects keep working: Karma is still supported through its own builder, and nothing forces a migration. When you do migrate, there are two separate jobs: switching the runner and rewriting the Jasmine-specific code in your specs.
1. Switch the runner #
npm install --save-dev vitest jsdom
Change the test target's builder to @angular/build:unit-test. Your app must use the application build system (the esbuild-based builder from lesson 1.5); older webpack-based projects need that migration first.
Settings in karma.conf.js are not migrated automatically. Look for custom reporters, plugins, file patterns and browser launchers, and recreate what you still need with the options above. Then remove Karma:
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter jasmine-core
Delete karma.conf.js and src/test.ts.
2. Refactor the specs #
ng g @schematics/angular:refactor-jasmine-vitest
The schematic rewrites the common Jasmine patterns. It doesn't install packages or delete files, and it doesn't handle every case, so commit before running it and review the diff:
| Jasmine | Vitest |
|---|---|
spyOn(obj, 'm') |
vi.spyOn(obj, 'm') |
jasmine.createSpy() |
vi.fn() |
.and.returnValue(x) |
.mockReturnValue(x) |
fit / fdescribe |
it.only / describe.only |
xit / xdescribe |
it.skip / describe.skip |
jasmine.any(Number) |
expect.any(Number) |
Spies with complex .and.callFake chains, custom Jasmine matchers and tests that relied on Karma running in a real browser are the usual leftovers. Specs that depended on real layout are candidates for browser mode rather than for rewriting.
Zone-based helpers such as fakeAsync and tick from @angular/core/testing depend on zone.js. New projects are zoneless, so prefer Vitest's fake timers (vi.useFakeTimers(), covered in lesson 11.2) when you touch those tests.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
ReferenceError: IntersectionObserver is not defined |
jsdom doesn't implement it |
Mock it in a setupFiles file, or move that spec to browser mode |
Element sizes are always 0 |
No layout engine in DOM emulation | Assert on classes or state instead, or use browser mode |
Changes in vitest.config.ts have no effect |
Vitest isn't reading that file; the builder only uses the file named in runnerConfig |
Use ng generate config vitest and set runnerConfig |
| CI job never finishes | ng test defaults to watch mode |
Add --no-watch |
Migrated spec fails on jasmine.createSpyObj |
Pattern the schematic didn't convert | Replace with an object of vi.fn() mocks |
--browsers fails to launch |
Provider package or browser binaries missing | Install @vitest/browser-playwright and playwright, then npx playwright install |
What's next #
Lesson 11.2 uses this setup to test the reactive core of a modern component: signals, computed(), effects with TestBed.tick(), signal inputs, resource(), and time-based code with Vitest's fake timers. After that, Module 11 covers component tests with TestBed (11.3), pipes, directives and HTTP services (11.4), and end-to-end testing options (11.5).
Try it yourself #
Run ng test --no-watch --coverage in a project, open coverage/index.html, and find the least-covered file that holds real logic. Write one spec for it. If the project still uses Karma, try the migration on a branch and note which specs the schematic couldn't convert.
search_documentationKarma still works, so there’s no deadline, but it’s worth doing on a branch. Your project already uses the application builder, which is the main prerequisite. Steps: (1) install vitest and jsdom, switch the test target to @angular/build:unit-test; (2) run ng g @schematics/angular:refactor-jasmine-vitest and review the diff; (3) port what you still need from karma.conf.js, since it isn’t migrated automatically. Expect three kinds of failures: spies the schematic left alone (createSpyObj, chained callFake), specs using fakeAsync, which needs zone.js and is better rewritten with vi.useFakeTimers(), and the handful of specs that measure layout, which jsdom can’t do. Run those last ones with --browsers=chromiumHeadless instead of rewriting them.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


