Since v21, the Angular CLI uses Vitest as the default runner for unit tests (Karma is deprecated). TestBed remains the main API, but modern tests look different: inputs set as signals through bindings, await fixture.whenStable() instead of fakeAsync (the applications are zoneless), and signal stores tested without the DOM.
- v21: Vitest by default (
@angular/build:unit-test), stable. - v21:
TestBedprovides a fakePlatformLocationby default. - v22:
TestBed.getLastFixture();ChangeDetectorRef.checkNoChangesremoved (usefixture.detectChanges()). - v22.2:
TestBed.createDirective()for testing directives.
Setup
# v21: new projects use Vitest (Karma is deprecated)
ng new my-app # → "test": { "builder": "@angular/build:unit-test" } + vitest
ng test # runs the tests (watch mode in dev)
ng test --no-watch --coverage
# An old project on Karma/Jasmine → assisted migration
ng generate refactor-jasmine-vitest # experimental schematic from @schematics/angularComponents: inputs as signals
import { TestBed } from '@angular/core/testing';
import { inputBinding, signal } from '@angular/core';
import { StarRating } from './star-rating';
describe('StarRating', () => {
it('shows the right number of stars', async () => {
const value = signal(3);
const fixture = TestBed.createComponent(StarRating, {
bindings: [inputBinding('value', value), inputBinding('max', () => 5)], // inputs as signals
});
await fixture.whenStable(); // zoneless: wait for the render
const stars = fixture.nativeElement.querySelectorAll('button');
expect(stars).toHaveLength(5);
expect(fixture.nativeElement.querySelectorAll('button.on')).toHaveLength(3);
value.set(5); // change the input like any signal
await fixture.whenStable();
expect(fixture.nativeElement.querySelectorAll('button.on')).toHaveLength(5);
});
it('emits the rating on click', async () => {
const onRate = vi.fn();
const fixture = TestBed.createComponent(StarRating, {
bindings: [inputBinding('value', () => 1), outputBinding('rate', onRate)],
});
await fixture.whenStable();
fixture.nativeElement.querySelectorAll('button')[3].click();
expect(onRate).toHaveBeenCalledWith(4);
});
});Stores
// Signal stores are the easiest to test: no DOM, no async
describe('TodoStore', () => {
let store: TodoStore;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [TodoStore] });
store = TestBed.inject(TodoStore);
});
it('computes how many are left', () => {
store.add('A');
store.add('B');
store.toggle(store.todos()[0].id);
expect(store.remaining()).toBe(1);
});
it('filters', () => {
store.add('A');
store.setFilter('done');
expect(store.visible()).toEqual([]);
});
});
// effects run asynchronously — force them with:
TestBed.tick();- CartStore › starts empty
- CartStore › count() follows every add()
- CartStore › the same product twice is one line with qty 2
- CartStore › total() is price × quantity
- CartStore › remove() takes out only that product
Tests 5 passed (5)
All 5 tests pass. Now pick a bug: the tests re-run immediately, as in ng test watch mode, and you see which one catches it.
The same tests, written for ng test (Vitest + TestBed):
describe('CartStore', () => {
const TEA = { id: 'tea', price: 3.5 };
const MUG = { id: 'mug', price: 4 };
let cart: CartStore;
// Every test gets a fresh TestBed, so a fresh CartStore
beforeEach(() => (cart = TestBed.inject(CartStore)));
it('starts empty', () => {
expect(cart.count()).toBe(0);
expect(cart.total()).toBe(0);
});
it('count() follows every add()', () => {
expect(cart.count()).toBe(0); // read before add(): this is what catches the mutated array
cart.add(TEA);
expect(cart.count()).toBe(1);
cart.add(MUG);
expect(cart.count()).toBe(2);
});
it('the same product twice is one line with qty 2', () => {
cart.add(TEA);
cart.add(TEA);
expect(cart.lines()).toHaveLength(1);
expect(cart.lines()[0].qty).toBe(2);
});
it('total() is price × quantity', () => {
cart.add(TEA);
cart.add(TEA);
cart.add(MUG);
expect(cart.total()).toBe(11);
});
it('remove() takes out only that product', () => {
cart.add(TEA);
cart.add(MUG);
cart.remove('tea');
expect(cart.lines().map((l) => l.id)).toEqual(['mug']);
});
});HTTP
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
describe('ProductsApi', () => {
let api: ProductsApi;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
api = TestBed.inject(ProductsApi);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify()); // no unexpected request
it('maps DTO → Product', async () => {
const result = firstValueFrom(api.get(1));
http.expectOne('/api/products/1').flush({ id: 1, name: 'Mouse', price: 99, stock: 0 });
expect(await result).toEqual({ id: 1, name: 'Mouse', price: 99, inStock: false });
});
});Directives and fixtures (v22)
// v22.2: TestBed.createDirective — you test a directive without a hand-written host component
it('applies the highlight on hover', async () => {
const fixture = TestBed.createDirective(Highlight, {
tagName: 'span',
bindings: [inputBinding('appHighlight', () => 'yellow')],
});
fixture.nativeElement.dispatchEvent(new MouseEvent('mouseenter'));
fixture.detectChanges();
expect(fixture.nativeElement.style.backgroundColor).toBe('yellow');
});
// v22: TestBed.getLastFixture() — the last fixture created (useful in helpers)
const fixture = TestBed.getLastFixture<MyComponent>();Timers and debounce (zoneless)
// Zoneless: no fakeAsync/tick from zone.js → fake timers in Vitest
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('debounce', async () => {
const fixture = TestBed.createComponent(Search);
fixture.componentInstance.query.set('ang');
await vi.advanceTimersByTimeAsync(300);
await fixture.whenStable();
// ...
});Harnesses
// Component harnesses (CDK): tests that don’t depend on the DOM structure
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';
const loader = TestbedHarnessEnvironment.loader(fixture);
const save = await loader.getHarness(MatButtonHarness.with({ text: 'Save' }));
await save.click();
// Angular Aria also has harnesses: @angular/aria/tabs/testing etc.What to test, and where
| What | How | Cost |
|---|---|---|
| Pure functions, mappers, validators | simple unit tests, no TestBed | minimal |
| Stores / services with signals | TestBed.inject, no DOM | small |
| Dumb components | createComponent + bindings | small |
| Smart components / pages | a fake store in providers + HttpTestingController | medium |
| Complete flows | e2e (Playwright / Cypress) | large |
Easy-to-test stores are described in State management with signals; a test that catches a memory leak before it reaches production, in Memory leaks: finding and fixing them.
Common pitfalls
- Testing the implementation (that a private method was called) instead of behaviour (what the user sees).
- Forgetting
await fixture.whenStable()in zoneless → the DOM isn’t updated yet. fakeAsyncwithout zone.js doesn’t work → fake timers in Vitest.- Untested effects: they run asynchronously —
TestBed.tick()runs them.