Seit v21 nutzt die Angular CLI Vitest als Standard-Runner für Unit-Tests (Karma ist deprecated). TestBed bleibt die wichtigste API, aber moderne Tests sehen anders aus: Inputs werden als Signals über bindings gesetzt, await fixture.whenStable() ersetzt fakeAsync (die Anwendungen sind zoneless), und Signal-Stores werden ohne DOM getestet.
- v21: Vitest als Standard (
@angular/build:unit-test), stabil. - v21:
TestBedstellt standardmäßig eine Fake-PlatformLocationbereit. - v22:
TestBed.getLastFixture();ChangeDetectorRef.checkNoChangesentfernt (nutzefixture.detectChanges()). - v22.2:
TestBed.createDirective()zum Testen von Direktiven.
Setup
# v21: Neue Projekte nutzen Vitest (Karma ist deprecated)
ng new my-app # → "test": { "builder": "@angular/build:unit-test" } + vitest
ng test # führt die Tests aus (Watch-Modus in Dev)
ng test --no-watch --coverage
# Altes Projekt auf Karma/Jasmine → assistierte Migration
ng generate refactor-jasmine-vitest # experimentelles Schematic aus @schematics/angularKomponenten: Inputs als Signals
import { TestBed } from '@angular/core/testing';
import { inputBinding, signal } from '@angular/core';
import { StarRating } from './star-rating';
describe('StarRating', () => {
it('zeigt die richtige Anzahl Sterne', async () => {
const value = signal(3);
const fixture = TestBed.createComponent(StarRating, {
bindings: [inputBinding('value', value), inputBinding('max', () => 5)], // Inputs als Signals
});
await fixture.whenStable(); // zoneless: auf das Rendern warten
const stars = fixture.nativeElement.querySelectorAll('button');
expect(stars).toHaveLength(5);
expect(fixture.nativeElement.querySelectorAll('button.on')).toHaveLength(3);
value.set(5); // ändere den Input wie jedes Signal
await fixture.whenStable();
expect(fixture.nativeElement.querySelectorAll('button.on')).toHaveLength(5);
});
it('emittiert die Bewertung beim Klick', 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 lassen sich am einfachsten testen: kein DOM, kein Async
describe('TodoStore', () => {
let store: TodoStore;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [TodoStore] });
store = TestBed.inject(TodoStore);
});
it('berechnet, wie viele übrig sind', () => {
store.add('A');
store.add('B');
store.toggle(store.todos()[0].id);
expect(store.remaining()).toBe(1);
});
it('filtert', () => {
store.add('A');
store.setFilter('done');
expect(store.visible()).toEqual([]);
});
});
// Effects laufen asynchron — erzwinge sie mit:
TestBed.tick();- CartStore › startet leer
- CartStore › count() folgt jedem add()
- CartStore › dasselbe Produkt zweimal ist eine Zeile mit qty 2
- CartStore › total() ist Preis × Menge
- CartStore › remove() entfernt nur dieses Produkt
Tests 5 passed (5)
Alle 5 Tests sind grün. Wähle jetzt einen Bug: Die Tests laufen sofort erneut, wie bei ng test im Watch-Modus, und du siehst, welcher ihn fängt.
Dieselben Tests, geschrieben für ng test (Vitest + TestBed):
describe('CartStore', () => {
const TEA = { id: 'tea', price: 3.5 };
const MUG = { id: 'mug', price: 4 };
let cart: CartStore;
// Jeder Test bekommt ein frisches TestBed, also einen frischen CartStore
beforeEach(() => (cart = TestBed.inject(CartStore)));
it('startet leer', () => {
expect(cart.count()).toBe(0);
expect(cart.total()).toBe(0);
});
it('count() folgt jedem add()', () => {
expect(cart.count()).toBe(0); // vor add() gelesen: genau das fängt das direkt veränderte Array
cart.add(TEA);
expect(cart.count()).toBe(1);
cart.add(MUG);
expect(cart.count()).toBe(2);
});
it('dasselbe Produkt zweimal ist eine Zeile mit qty 2', () => {
cart.add(TEA);
cart.add(TEA);
expect(cart.lines()).toHaveLength(1);
expect(cart.lines()[0].qty).toBe(2);
});
it('total() ist Preis × Menge', () => {
cart.add(TEA);
cart.add(TEA);
cart.add(MUG);
expect(cart.total()).toBe(11);
});
it('remove() entfernt nur dieses Produkt', () => {
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()); // kein unerwarteter Request
it('mappt 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 });
});
});Direktiven und Fixtures (v22)
// v22.2: TestBed.createDirective — du testest eine Direktive ohne handgeschriebene Host-Komponente
it('wendet das Highlight beim Hover an', 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() — die zuletzt erzeugte Fixture (nützlich in Helpern)
const fixture = TestBed.getLastFixture<MyComponent>();Timer und Debounce (zoneless)
// Zoneless: kein fakeAsync/tick aus zone.js → Fake-Timer 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, die nicht von der DOM-Struktur abhängen
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: 'Speichern' }));
await save.click();
// Angular Aria hat ebenfalls Harnesses: @angular/aria/tabs/testing etc.Was du testest, und wo
| Was | Wie | Aufwand |
|---|---|---|
| Pure Funktionen, Mapper, Validatoren | einfache Unit-Tests, ohne TestBed | minimal |
| Stores / Services mit Signals | TestBed.inject, ohne DOM | gering |
| Dumb-Komponenten | createComponent + bindings | gering |
| Smart-Komponenten / Seiten | ein Fake-Store in providers + HttpTestingController | mittel |
| Komplette Abläufe | E2E (Playwright / Cypress) | hoch |
Leicht testbare Stores beschreibt State Management mit Signals; einen Test, der ein Memory Leak vor der Produktion fängt, Memory Leaks finden und beheben.
Häufige Stolperfallen
- Die Implementierung testen (dass eine private Methode aufgerufen wurde), statt das Verhalten (was der Nutzer sieht).
await fixture.whenStable()vergessen in zoneless → das DOM ist noch nicht aktualisiert.fakeAsyncohne zone.js funktioniert nicht → Fake-Timer in Vitest.- Ungetestete Effects: Sie laufen asynchron —
TestBed.tick()führt sie aus.