Depuis v21, l’Angular CLI utilise Vitest comme runner par défaut pour les tests unitaires (Karma est déprécié). TestBed reste l’API principale, mais les tests modernes sont différents : inputs définis comme signals via bindings, await fixture.whenStable() à la place de fakeAsync (les applications sont zoneless), et stores de signals testés sans le DOM.
- v21 : Vitest par défaut (
@angular/build:unit-test), stable. - v21 :
TestBedfournit par défaut un fauxPlatformLocation. - v22 :
TestBed.getLastFixture();ChangeDetectorRef.checkNoChangessupprimé (utilisezfixture.detectChanges()). - v22.2 :
TestBed.createDirective()pour tester les directives.
Configuration
# v21: les nouveaux projets utilisent Vitest (Karma est déprécié)
ng new my-app # → "test": { "builder": "@angular/build:unit-test" } + vitest
ng test # exécute les tests (mode watch en dev)
ng test --no-watch --coverage
# Ancien projet sous Karma/Jasmine → migration assistée
ng generate refactor-jasmine-vitest # schematic expérimental de @schematics/angularComposants : inputs sous forme de signals
import { TestBed } from '@angular/core/testing';
import { inputBinding, signal } from '@angular/core';
import { StarRating } from './star-rating';
describe('StarRating', () => {
it('affiche le bon nombre d’étoiles', async () => {
const value = signal(3);
const fixture = TestBed.createComponent(StarRating, {
bindings: [inputBinding('value', value), inputBinding('max', () => 5)], // inputs sous forme de signals
});
await fixture.whenStable(); // zoneless : attendre le rendu
const stars = fixture.nativeElement.querySelectorAll('button');
expect(stars).toHaveLength(5);
expect(fixture.nativeElement.querySelectorAll('button.on')).toHaveLength(3);
value.set(5); // modifie l’input comme n’importe quel signal
await fixture.whenStable();
expect(fixture.nativeElement.querySelectorAll('button.on')).toHaveLength(5);
});
it('émet la note au clic', 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
// Les stores de signals sont les plus simples à tester : pas de DOM, pas d’async
describe('TodoStore', () => {
let store: TodoStore;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [TodoStore] });
store = TestBed.inject(TodoStore);
});
it('calcule combien il en reste', () => {
store.add('A');
store.add('B');
store.toggle(store.todos()[0].id);
expect(store.remaining()).toBe(1);
});
it('filtre', () => {
store.add('A');
store.setFilter('done');
expect(store.visible()).toEqual([]);
});
});
// les effects s’exécutent de façon asynchrone — forcez-les avec :
TestBed.tick();- CartStore › démarre vide
- CartStore › count() suit chaque add()
- CartStore › le même produit deux fois donne une ligne avec qty 2
- CartStore › total() vaut prix × quantité
- CartStore › remove() retire seulement ce produit
Tests 5 passed (5)
Les 5 tests passent. Choisissez maintenant un bug : les tests se relancent aussitôt, comme ng test en mode watch, et vous voyez lequel le détecte.
Les mêmes tests, écrits pour ng test (Vitest + TestBed) :
describe('CartStore', () => {
const TEA = { id: 'tea', price: 3.5 };
const MUG = { id: 'mug', price: 4 };
let cart: CartStore;
// Chaque test a un TestBed neuf, donc un CartStore neuf
beforeEach(() => (cart = TestBed.inject(CartStore)));
it('démarre vide', () => {
expect(cart.count()).toBe(0);
expect(cart.total()).toBe(0);
});
it('count() suit chaque add()', () => {
expect(cart.count()).toBe(0); // lu avant add() : c’est ce qui détecte le tableau modifié sur place
cart.add(TEA);
expect(cart.count()).toBe(1);
cart.add(MUG);
expect(cart.count()).toBe(2);
});
it('le même produit deux fois donne une ligne avec qty 2', () => {
cart.add(TEA);
cart.add(TEA);
expect(cart.lines()).toHaveLength(1);
expect(cart.lines()[0].qty).toBe(2);
});
it('total() vaut prix × quantité', () => {
cart.add(TEA);
cart.add(TEA);
cart.add(MUG);
expect(cart.total()).toBe(11);
});
it('remove() retire seulement ce produit', () => {
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()); // aucune requête inattendue
it('convertit le 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 et fixtures (v22)
// v22.2: TestBed.createDirective — vous testez une directive sans composant hôte écrit à la main
it('applique le surlignage au survol', 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() — la dernière fixture créée (utile dans les helpers)
const fixture = TestBed.getLastFixture<MyComponent>();Timers et debounce (zoneless)
// Zoneless: pas de fakeAsync/tick de zone.js → fake timers dans 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): des tests qui ne dépendent pas de la structure du DOM
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 propose aussi des harnesses : @angular/aria/tabs/testing etc.Quoi tester, et où
| Quoi | Comment | Coût |
|---|---|---|
| Fonctions pures, mappers, validators | tests unitaires simples, sans TestBed | minimal |
| Stores / services avec signals | TestBed.inject, sans DOM | faible |
| Composants de présentation | createComponent + bindings | faible |
| Composants conteneurs / pages | un faux store dans providers + HttpTestingController | moyen |
| Parcours complets | e2e (Playwright / Cypress) | élevé |
Les stores faciles à tester sont décrits dans State Management avec les Signals ; un test qui attrape une fuite mémoire avant la production, dans Fuites mémoire : les trouver et les corriger.
Pièges fréquents
- Tester l’implémentation (qu’une méthode privée a été appelée) au lieu du comportement (ce que voit l’utilisateur).
- Oublier
await fixture.whenStable()en zoneless → le DOM n’est pas encore à jour. fakeAsyncsans zone.js ne fonctionne pas → fake timers dans Vitest.- Effects non testés : ils s’exécutent de façon asynchrone —
TestBed.tick()les exécute.