Testing Angular 2026 — Jest, Vitest, Playwright, harnesses
TL;DR En 2026, l'écosystème de test Angular a profondément changé. Karma est officiellement déprécié depuis Angular 17 et retiré progressivement. Jest (via
jest-preset-angular) est devenu le runner unitaire de facto, avec une expérience CLI rapide et un écosystème mature. Vitest monte en puissance grâce à sa vitesse et son intégration ESM native (preview officielle Angular). Côté e2e, Protractor est mort depuis Angular 12, Cypress reste populaire mais Playwright a pris la première place en 2026 pour son support multi-navigateurs, sa rapidité, et son meilleur debugging. Cette note couvre les stacks recommandées, les harnesses Angular Material/CDK, Testing Library pour les tests user-centric, et les patterns spécifiques aux signals, standalone components, et TestBed moderne.
🧠 Mental model — ASCII + analogie
Le testing Angular suit la pyramide classique mais avec des outils renouvelés. À la base, des tests unitaires rapides (Jest/Vitest) pour la logique pure. Au milieu, des tests d'intégration avec TestBed qui vérifient l'interaction composant ↔ template ↔ services. Au sommet, des tests end-to-end (Playwright) qui simulent un utilisateur réel dans un navigateur.
┌──────────────────────────────────────────────────────────┐
│ Pyramide de tests Angular 2026 │
└──────────────────────────────────────────────────────────┘
▲
E2E (lent, cher, fragile)
┌─────────┐
│Playwright│ ~5-10%
│ Cypress │ quelques scénarios critiques
└─────────┘
┌───────────────┐
│ Intégration │ ~20-30%
│ TestBed │ composants + DOM
│ harnesses │ interactions clés
└───────────────┘
┌────────────────────────┐
│ Unitaires │ ~60-70%
│ Jest / Vitest │ services, pipes, utils
│ pas de TestBed │ signals, computed, méthodes
└────────────────────────┘L'analogie : un test unitaire vérifie qu'un moteur fonctionne à l'arrêt. Un test d'intégration vérifie que la voiture entière roule sur un dyno. Un test e2e vérifie que la voiture passe le contrôle technique sur route. Les trois sont nécessaires, mais leur coût et leur flakiness augmentent à chaque étage — d'où la pyramide. Une erreur classique : trop d'e2e et trop peu d'unitaires, l'inverse de ce qu'il faudrait.
Comment un·e staff engineer raisonne sur les tests
La pyramide est un point de départ, pas un dogme. Le vrai cadre de décision repose sur trois axes :
- Coût de détection vs coût d'échappement. Un test n'a de valeur que s'il attrape un bug avant la prod et que le bug coûterait cher en prod. Un test qui couvre un getter trivial a un coût d'échappement nul → inutile. Un test e2e du checkout couvre 5 % du CA → priorité absolue malgré son coût.
- Surface de couplage à l'implémentation. Plus un test connaît les détails internes (méthodes privées, structure DOM, ordre des appels), plus il casse au refactor sans qu'un bug existe (« faux positif »). On veut tester le comportement observable (ce que voit l'utilisateur ou le consommateur de l'API), pas la mécanique. C'est tout le sens de Testing Library et des harnesses.
- Déterminisme. Un test flaky est pire qu'un test absent : il érode la confiance dans toute la suite (« oh c'est juste le test flaky, relance »). Un staff engineer traque le non-déterminisme (temps réel, ordre, réseau, état partagé) plus agressivement qu'il ne chasse la couverture.
Le modèle mental moderne d'Angular a basculé : avec les signals, l'unité de test n'est plus « le composant + sa change detection » mais « le graphe de signals ». Un computed est une fonction pure de ses dépendances : on le teste comme une fonction. La CD (zone.js ou scheduler zoneless) n'est qu'un détail de quand le DOM se synchronise. Cette séparation — logique réactive synchrone vs rendu asynchrone — est la clé pour écrire des tests rapides et non-flaky en 2026.
Le trade-off central « Trophy vs Pyramid ». Kent C. Dodds a popularisé le Testing Trophy : moins d'unitaires triviaux, beaucoup de tests d'intégration (le « sweet spot » confiance/coût), une fine couche e2e. En Angular 2026, ce modèle gagne : un test TestBed bien écrit (composant réel + vrais services + HTTP mocké) attrape la majorité des bugs de régression avec un coût modéré. La pyramide reste juste pour la logique algorithmique lourde ; le trophy l'emporte pour le code UI/orchestration.
| Niveau | Vitesse | Confiance | Couplage impl. | Flakiness | Quand l'écrire |
|---|---|---|---|---|---|
| Unit (sans TestBed) | ⚡⚡⚡ µs | faible/moyenne | faible | ~0 | logique pure, validators, computed, utils |
| Intégration (TestBed + harness) | ⚡⚡ ms | élevée | moyen | faible | composants, forms, interactions DOM |
| Component testing (Playwright CT) | ⚡ ms-s | élevée | faible | faible-moyen | composants visuels réels dans un vrai navigateur |
| E2E (Playwright) | 🐢 s | maximale | faible | moyen-élevé | parcours critiques bout-en-bout, multi-onglets |
🛠️ Code minimal (ts + html)
Configuration Jest pour Angular 17+.
# installation
npm install --save-dev jest @types/jest jest-preset-angular ts-jest// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'jest-preset-angular',
// ⚠️ Piège fréquent : c'est `setupFilesAfterEnv`, PAS `setupFilesAfterEach`.
// Le mauvais nom est silencieusement ignoré par Jest → ton setup ne tourne jamais
// et tu obtiens des erreurs cryptiques type « zone.js not loaded ».
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@app/(.*)$': '<rootDir>/src/app/$1',
},
testMatch: ['**/*.spec.ts'],
collectCoverageFrom: ['src/app/**/*.{ts,html}', '!src/app/**/*.spec.ts'],
coverageReporters: ['text', 'html', 'lcov'],
};
export default config;// jest.setup.ts
import 'jest-preset-angular/setup-jest';
import { TextEncoder, TextDecoder } from 'util';
// jsdom n'expose pas TextEncoder/TextDecoder par défaut ; certains modules
// (rendu SSR, crypto, web-streams) en ont besoin. Polyfill avant tout import Angular.
Object.assign(global, { TextEncoder, TextDecoder });Note version. Depuis
jest-preset-angularv14, le projet pousse une config ESM (setupZoneTestEnvviajest-preset-angular/setup-env/zone). Pour un projet zoneless (Angular 18+,provideZonelessChangeDetection()), utilisez plutôtjest-preset-angular/setup-env/zonelessqui n'importe pas zone.js — sinon vous chargez zone.js inutilement en test et masquez les bugs de CD que la prod zoneless révélera.
Le chemin officiel (Angular 20+) : le builder @angular/build:unit-test sur Vitest. Depuis octobre 2025, ng new configure Vitest comme runner par défaut (Karma/Jasmine retirés du schematic, option testRunner: 'vitest' | 'karma'). Contrairement à jest-preset-angular qui est une intégration tierce, c'est désormais une cible CLI first-party : elle réutilise le build system esbuild/Vite de l'app (pas un second pipeline ts-jest), donc la compilation des tests est cohérente avec la prod et incrémentale.
// angular.json — bascule vers le builder officiel Vitest
{
"projects": {
"app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"runner": "vitest",
"buildTarget": "::development", // réutilise la config de build de l'app
"tsConfig": "tsconfig.spec.json"
}
}
}
}
}
}// vitest-base.config.ts — référencé par le builder ; fusionné avec ses plugins
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom', // ou 'happy-dom' (plus rapide, DOM partiel)
globals: true, // describe/it/expect globaux, façon Jest
setupFiles: ['src/test-setup.ts'],
// ⚠️ Pas besoin d'importer zone.js si l'app est zoneless : le setup officiel
// initialise le TestBed selon la config de l'app (canari zoneless gratuit).
},
});Quand choisir quoi (mental model staff). Vitest officiel = nouveau projet ou migration assumée : un seul pipeline de build, watch quasi instantané, ESM natif.
jest-preset-angular= monorepo enterprise existant où l'écosystème Jest (reporters,jest.mockpartout, snapshot serializers maison) coûte trop cher à migrer. Les deux partagent la même APITestBed; le choix porte sur le runner et le pipeline de build, pas sur la façon d'écrire les tests. Migrer Karma→Vitest se fait viang generate @angular/cli:vitestou le guide officiel de migration.
Test unitaire d'un service-signals.
// counter.service.ts
import { computed, Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterService {
private readonly _count = signal(0);
readonly count = this._count.asReadonly();
readonly isEven = computed(() => this._count() % 2 === 0);
increment(): void { this._count.update((c) => c + 1); }
reset(): void { this._count.set(0); }
}
// counter.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CounterService } from './counter.service';
describe('CounterService', () => {
let service: CounterService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CounterService);
});
it('démarre à 0', () => {
expect(service.count()).toBe(0);
expect(service.isEven()).toBe(true);
});
it('incrémente', () => {
service.increment();
expect(service.count()).toBe(1);
expect(service.isEven()).toBe(false);
});
it('reset', () => {
service.increment();
service.reset();
expect(service.count()).toBe(0);
});
});Test d'un composant standalone avec TestBed.
// greet.component.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-greet',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p data-testid="greeting">Hello {{ name() }} !</p>`,
})
export class GreetComponent {
readonly name = input.required<string>();
}
// greet.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GreetComponent } from './greet.component';
import { By } from '@angular/platform-browser';
describe('GreetComponent', () => {
let fixture: ComponentFixture<GreetComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [GreetComponent] }).compileComponents();
fixture = TestBed.createComponent(GreetComponent);
fixture.componentRef.setInput('name', 'Alice');
fixture.detectChanges();
});
it('affiche le nom', () => {
const p = fixture.debugElement.query(By.css('[data-testid=greeting]'));
expect(p.nativeElement.textContent).toContain('Alice');
});
});Test avec Testing Library Angular (user-centric).
// login.component.spec.ts
import { render, screen, fireEvent } from '@testing-library/angular';
import { LoginComponent } from './login.component';
describe('LoginComponent (user-centric)', () => {
it('soumet le formulaire avec email et mot de passe', async () => {
const submit = jest.fn();
await render(LoginComponent, {
componentProperties: { submitted: { emit: submit } as any },
});
await fireEvent.input(screen.getByLabelText(/email/i), { target: { value: '[email protected]' } });
await fireEvent.input(screen.getByLabelText(/mot de passe/i), { target: { value: 'secret' } });
await fireEvent.click(screen.getByRole('button', { name: /se connecter/i }));
expect(submit).toHaveBeenCalledWith({ email: '[email protected]', password: 'secret' });
});
});Test avec harness Material (boutons, inputs, dialogs).
// confirm-dialog.spec.ts
import { TestBed } from '@angular/core/testing';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';
import { ConfirmDialogComponent } from './confirm-dialog.component';
describe('ConfirmDialog', () => {
let loader: HarnessLoader;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [ConfirmDialogComponent] }).compileComponents();
const fixture = TestBed.createComponent(ConfirmDialogComponent);
fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('expose un bouton confirmer cliquable', async () => {
const button = await loader.getHarness(MatButtonHarness.with({ text: 'Confirmer' }));
expect(await button.isDisabled()).toBe(false);
await button.click();
});
});Test d'un service avec HTTP mocks via HttpTestingController.
// users.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { UsersService } from './users.service';
describe('UsersService', () => {
let service: UsersService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(UsersService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('charge la liste depuis /api/users', () => {
let result: User[] = [];
service.getAll().subscribe((users) => (result = users));
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush([{ id: '1', name: 'Alice' }]);
expect(result.length).toBe(1);
expect(result[0].name).toBe('Alice');
});
it('gère une erreur 500', () => {
let errorCaught: string | null = null;
service.getAll().subscribe({ error: (err) => (errorCaught = err.message) });
const req = httpMock.expectOne('/api/users');
req.flush('Server error', { status: 500, statusText: 'Server Error' });
expect(errorCaught).toContain('500');
});
});Test marble pour un opérateur RxJS personnalisé.
import { TestScheduler } from 'rxjs/testing';
import { debounceTime, map } from 'rxjs/operators';
describe('debounceTime + map', () => {
let scheduler: TestScheduler;
beforeEach(() => {
scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});
it('émet la dernière valeur après le délai', () => {
scheduler.run(({ cold, expectObservable }) => {
const source = cold('-a-b-c----|');
// c émis en frame 5 ; debounceTime(3) le ré-émet 3 frames plus tard (frame 8),
// car la source ne complète qu'en frame 10. Complétion propagée en frame 10.
const expected = ' --------c-|';
const result = source.pipe(debounceTime(3), map((v) => v));
expectObservable(result).toBe(expected);
});
});
});Test e2e Playwright.
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login', () => {
test('un utilisateur peut se connecter', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Mot de passe').fill('secret');
await page.getByRole('button', { name: 'Se connecter' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText(/Bienvenue, Alice/)).toBeVisible();
});
});🎯 Patterns courants
Tests purs sans TestBed quand possible. Pour un service, un pipe, une fonction utilitaire, on instancie directement la classe : const service = new CounterService(). Pas besoin de TestBed, c'est 10x plus rapide. TestBed n'est utile que quand on a besoin d'injection (HttpClient, Router, services injectés).
Standalone TestBed. Depuis Angular 17, on importe directement le composant standalone dans TestBed.configureTestingModule({ imports: [MyComponent] }). Plus de declarations, plus de NgModule de test. C'est plus rapide à compiler et plus proche du runtime.
fixture.componentRef.setInput(). Pour passer une valeur à un input() signal, on doit utiliser setInput(), pas fixture.componentInstance.name = .... La nouvelle API d'inputs signals exige ce chemin.
Test des computed signals. Un computed se teste en provoquant les mutations qui changent ses dépendances, puis en lisant sa valeur : service.increment(); expect(service.isEven()).toBe(false). Pas besoin de TestBed.tick() pour les signals — la propagation est synchrone lors de la lecture.
Test des effects. Un effect() est asynchrone : il ne s'exécute pas à la création mais à la prochaine flush du scheduler. Trois façons de forcer la flush en test, par ordre de préférence :
import { Component, effect, signal, Injector, runInInjectionContext } from '@angular/core';
import { TestBed } from '@angular/core/testing';
it('un effect réagit au changement de signal', () => {
TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
const log: number[] = [];
const count = signal(0);
TestBed.runInInjectionContext(() => {
effect(() => log.push(count())); // effect a besoin d'un injection context
});
TestBed.tick(); // Angular 20+ : flush déterministe des effects
// alternatives : await TestBed.inject(ApplicationRef).whenStable()
// ou fixture.detectChanges() si l'effect est dans un composant
expect(log).toEqual([0]); // run initial
count.set(5);
TestBed.tick();
expect(log).toEqual([0, 5]);
});TestBed.tick() (stable depuis Angular 20) déclenche un cycle de change detection synchrone qui flush les effects en attente — c'est l'API idiomatique en zoneless, plus précise que fixture.detectChanges() quand l'effect n'est pas lié à un composant. Piège : un effect() créé hors injection context lève NG0203 ; toujours l'envelopper dans runInInjectionContext ou le passer { injector }.
Harnesses pour Material/CDK. Plutôt que de chercher des sélecteurs CSS internes (qui changent), on utilise les harnesses officielles : MatButtonHarness, MatInputHarness, MatDialogHarness. Elles isolent du markup interne et survivent aux refactors de Material.
Testing Library pour les tests user-centric. Au lieu de tester l'implémentation (fixture.componentInstance.foo), on teste ce que voit l'utilisateur : screen.getByLabelText('Email'), screen.getByRole('button'). Plus résilient aux refactors, plus proche du comportement réel.
fakeAsync et tick() pour les timers. Pour tester du code avec setTimeout, setInterval, RxJS delay, on utilise fakeAsync et tick(ms). Cela permet de contrôler le temps sans attendre réellement. Pour les Observables, alternative : marble testing avec TestScheduler.
Marble testing pour les flows RxJS complexes. TestScheduler permet d'écrire des séquences temporelles ASCII : '-a-b-|' pour un Observable qui émet a, puis b, puis complète. Utile pour tester des opérateurs comme debounceTime, switchMap, retry.
Mocker les routes avec RouterTestingHarness. Pour tester un composant qui dépend de ActivatedRoute, on utilise RouterTestingHarness (Angular 15+) qui simule la navigation et fournit l'ActivatedRoute correspondant. Plus propre que d'injecter un mock manuel.
import { RouterTestingHarness } from '@angular/router/testing';
import { provideRouter } from '@angular/router';
it('lit l\'id depuis la route', async () => {
TestBed.configureTestingModule({
providers: [provideRouter([{ path: 'users/:id', component: UserDetailComponent }])],
});
const harness = await RouterTestingHarness.create();
const component = await harness.navigateByUrl('/users/42', UserDetailComponent);
expect(component.userId()).toBe('42');
});Tester un guard fonctionnel. Les guards en 2026 sont des fonctions (plus de classes). Test direct.
const guard = (route: ActivatedRouteSnapshot) => {
const auth = inject(AuthService);
return auth.isLogged() ? true : router.createUrlTree(['/login']);
};
it('redirige si non authentifié', () => {
TestBed.configureTestingModule({
providers: [{ provide: AuthService, useValue: { isLogged: () => false } }],
});
TestBed.runInInjectionContext(() => {
const result = guard({} as any);
expect(result).not.toBe(true);
});
});Visual regression testing avec Playwright. Au-delà du fonctionnel, Playwright capture des screenshots et compare aux baselines. Utile pour détecter des régressions visuelles non couvertes par les tests unitaires.
test('homepage screenshot matches baseline', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', { maxDiffPixels: 100 });
});Coverage : viser la qualité plus que le quantitatif. Un objectif raisonnable en 2026 est ~80% sur les services et logique métier, ~60-70% sur les composants (les templates sont souvent peu testables sans valeur). Ne pas viser 100% — cela force des tests artificiels qui se cassent à chaque refactor.
🔄 Versions — Angular 16 → 20
Angular 16 (mi-2023) : Karma encore par défaut mais déprécation annoncée. jest-preset-angular mature, recommandé par la communauté. Testing Library Angular populaire. Harnesses Material stables.
Angular 17 (fin 2023) : standalone testing devient le défaut. TestBed simplifié pour les standalone components. Annonce officielle de la déprécation de Karma. Cypress 13+ supporte les composants Angular standalone.
Angular 18 (mi-2024) : Karma officiellement retiré du ng new. Jest devient l'option recommandée dans la CLI. Apparition de l'expérimentation Vitest pour Angular. Playwright 1.40+ devient l'outil e2e dominant.
Angular 19 (fin 2024) : Karma totalement supprimé. Choix par défaut au ng new : Jest ou Vitest. Documentation officielle exclut Karma. Les harnesses CDK gagnent un support natif pour les composants signals.
Angular 20 (mi-2025) : Vitest en preview officielle dans la CLI. Vitest gagne en popularité grâce à sa rapidité (utilise Vite et esbuild). Jest reste majoritaire en parts de marché. Playwright Component Testing devient une alternative crédible aux tests d'intégration TestBed.
Trajectoire 2026 : la stack moderne est Jest (ou Vitest) + Testing Library Angular + Playwright. Karma a disparu des nouveaux projets. Vitest gagne du terrain mais Jest domine encore l'écosystème enterprise. Pour les e2e, Playwright > Cypress en raison du support multi-navigateurs (Chromium, Firefox, WebKit), de la rapidité et du debugging.
⚠️ Pitfalls — 6-10
1. fixture.detectChanges() oublié. En zoneful, sans detectChanges(), les bindings ne sont pas mis à jour et les assertions échouent. En zoneless, c'est encore pire car aucune CD automatique. Toujours appeler fixture.detectChanges() après une mutation, ou utiliser fixture.autoDetectChanges() pour la durée du test.
2. Mock incomplet de services. Mocker HttpClient avec jest.fn() mais oublier que le service appelle httpClient.get(...).pipe(...) casse les tests. Utiliser HttpTestingController officiel ou les helpers de @ngneat/spectator qui gèrent les chaînes RxJS.
3. Tests qui passent en CI mais échouent en local (ou inverse). Symptôme classique de timing. Causes : setTimeout non fakeAsync, Promises non await, animations Material non désactivées (provideNoopAnimations() en test). Toujours désactiver les animations en environnement de test.
4. Snapshot testing sur du HTML. Tentant mais fragile : une classe CSS qui change casse 200 snapshots. Préférer des assertions ciblées sur le contenu textuel ou les attributs. Réserver snapshot aux structures stables (configuration, contrats d'API).
5. Confusion entre ChangeDetectionStrategy.OnPush et tests. Un composant OnPush ne se met à jour que quand ses inputs changent (en référence) ou qu'un signal qu'il consomme émet. En test, modifier une propriété interne sans signal ne déclenche pas la CD. Utiliser setInput ou changer le signal.
6. inject() hors contexte de TestBed. Tenter d'utiliser inject() dans un fichier .spec.ts sans être dans un TestBed.runInInjectionContext() ou dans la construction d'un service donne une erreur. Toujours encapsuler.
7. Tests e2e fragiles à cause des sélecteurs. Utiliser page.locator('.btn-primary') est fragile : si on renomme la classe, le test casse. Préférer getByRole, getByLabel, getByTestId. C'est l'API recommandée par Playwright et Testing Library.
8. Tests qui mockent trop. Un test unitaire qui mocke 90% des dépendances finit par tester le mock, pas le code. Considérer des tests d'intégration avec de vrais services pour les flows critiques.
9. Coverage sans valeur. Atteindre 100% de coverage en testant les getters/setters triviaux n'apporte rien. Cibler les branches conditionnelles, les cas limites, les flows d'erreur — pas les lignes mortes.
10. Pas de cleanup entre tests. Un test qui pollue le localStorage, les cookies, ou un singleton, fait échouer le test suivant. Utiliser beforeEach pour reset l'état, et préférer des stores scoped en test si possible.
🧩 Tester en zoneless (Angular 18+) — le vrai changement de 2026
C'est le sujet que la doc officielle survole et qui piège les seniors venus du monde zone.js. Avec provideZonelessChangeDetection(), il n'y a plus de zone.js qui « patch » les APIs async (setTimeout, Promise, addEventListener). Conséquences en test :
fakeAsync/tick()reposent sur le patching de zone.js. En zoneless strict,fakeAsyncn'a plus de zone à manipuler. Pour les timers, deux options : (a) garderfakeAsyncviaProxyZoneque TestBed configure encore en test même en zoneless app, ou (b) basculer sur les fake timers du runner (jest.useFakeTimers()/vi.useFakeTimers()) +await fixture.whenStable().- La CD n'est plus déclenchée par « un événement quelconque ». Elle est déclenchée par : un signal lu dans le template qui change, un
markForCheck(), unAsyncPipe, ou un event binding. En test, après avoir muté un signal, il fautawait fixture.whenStable()(qui attend le scheduler) plutôt que de supposer quedetectChanges()suffit. fixture.detectChanges()reste synchrone et force un cycle, mais le pattern idiomatique zoneless estfixture.whenStable()après une interaction, car le rendu peut être coalescé sur un microtask.
// counter.component.spec.ts — pattern zoneless
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { CounterComponent } from './counter.component';
describe('CounterComponent (zoneless)', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [CounterComponent],
providers: [provideZonelessChangeDetection()], // ⬅️ reproduit la prod
}));
it('met à jour le DOM après un clic', async () => {
const fixture = TestBed.createComponent(CounterComponent);
await fixture.whenStable(); // rendu initial coalescé
fixture.nativeElement.querySelector('button').click();
await fixture.whenStable(); // ⬅️ PAS juste detectChanges()
expect(fixture.nativeElement.querySelector('[data-testid=count]').textContent)
.toContain('1');
});
});Pourquoi un staff engineer met provideZonelessChangeDetection() dans la config de test même si l'app n'est pas encore zoneless ? Parce que c'est un canari : un composant qui passe en test zoneful mais casse en zoneless a un bug latent (il s'appuie sur un cycle de CD « gratuit » déclenché par un event sans rapport). Le tester en zoneless le révèle avant la migration prod. À l'inverse, tester whenStable() partout rend la suite robuste aux deux modes.
| Préoccupation | Zoneful (zone.js) | Zoneless |
|---|---|---|
| Timers en test | fakeAsync + tick() | fake timers du runner ou ProxyZone |
| Sync DOM après mutation | detectChanges() | await whenStable() |
| CD déclenchée par | n'importe quel async patché | signal/markForCheck/event/AsyncPipe |
| Risque de flakiness | CD « fantôme » masque les bugs | échecs déterministes (souhaitable) |
🔬 Flakiness, observabilité & scale CI — la vue production des tests
Une suite de tests est un système de production : elle a une latence (temps CI), un taux d'erreur (flakiness) et un coût (minutes runner). On l'opère comme tel.
1. Diagnostiquer la flakiness, pas la masquer. retries: 2 en CI cache le symptôme mais pas la cause. La bonne discipline : (a) chaque retry réussi émet un signal observé (Playwright trace, annotation), (b) on quarantaine un test flaky (tag @flaky, exclu du blocking gate, tracké dans un dashboard) plutôt que de le supprimer ou de l'ignorer silencieusement, (c) on traque la cause racine — quasi toujours l'un de : temps réel non maîtrisé, ordre des tests (état partagé), réseau réel non mocké, animations, ou course de rendu.
// playwright.config.ts — observabilité de la flakiness
export default defineConfig({
retries: process.env.CI ? 2 : 0,
reporter: [
['list'],
['html', { open: 'never' }],
['json', { outputFile: 'results.json' }], // ⬅️ ingéré par un dashboard flaky
['blob'], // shards mergeables
],
use: { trace: 'on-first-retry', video: 'retain-on-failure' },
});2. Scaler le temps CI : sharding. Au-delà de ~5 min, on shard. Playwright : --shard=1/4 sur 4 runners parallèles puis merge-reports des blobs. Jest : --shard=1/4 (natif depuis Jest 28). La latence devient total/N + overhead ; le coût total reste constant. Règle staff : le gate de PR doit rester sous 10 min p95, sinon les devs contournent (push sans attendre).
3. Détecter les régressions de perf des tests. Une suite qui passe de 2 à 9 min sur 3 mois est une dette invisible. On instrumente : jest --json expose la durée par test ; on alerte sur les outliers. Souvent la cause = compileComponents() répété ou TestBed mal réutilisé.
4. Hermétisme = déterminisme. Aucun test ne doit toucher : l'horloge réelle (fake timers), le réseau réel (HttpTestingController/MSW/page.route), le hasard (seed Math.random), le fuseau/locale (figer TZ et locale en CI). Un test non hermétique passe sur ta machine et casse à 23h UTC.
// désactiver les animations globalement (cause #1 de flakiness Material)
TestBed.configureTestingModule({ providers: [provideNoopAnimations()] });5. Mutation testing — la métrique qui dit la vérité. Le coverage mesure les lignes exécutées, pas les lignes vérifiées. Un test sans expect a 100 % de coverage et 0 valeur. Stryker mute le code (inverse un < en >, supprime un return) et vérifie qu'un test échoue : le mutation score mesure la vraie qualité d'assertion. Sur le code métier critique (pricing, droits, validators), viser un mutation score > 80 % vaut bien mieux qu'un line coverage à 95 %.
🤖 Tester une UI d'agent IA streamée (signals + SSE + AbortController)
Ce learner sert/consomme des agents IA depuis Angular. Une UI de chat agent stream des tokens, affiche une timeline d'outils, et expose un bouton Stop. C'est notoirement dur à tester (asynchrone, flux, annulation). Voici les patterns seniors.
Le composant cible : un buffer de messages append-only alimenté par un ReadableStream (fetch + getReader()), des updates coalescés en requestAnimationFrame sous zoneless, et une trace d'outils en union discriminée.
// agent-chat.types.ts
export type ToolStep =
| { kind: 'pending'; id: string; name: string }
| { kind: 'running'; id: string; name: string; args: unknown }
| { kind: 'streaming'; id: string; name: string; partial: string }
| { kind: 'done'; id: string; name: string; result: unknown }
| { kind: 'error'; id: string; name: string; message: string };Tester le streaming sans vrai serveur : on mocke le ReadableStream. L'astuce est de stubber fetch pour renvoyer une Response dont le body est un stream qu'on alimente token par token. On contrôle ainsi le timing exact et les coupures.
// stream-test-utils.ts
export function sseStreamFrom(chunks: string[], opts: { abortAfter?: number } = {}) {
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (opts.abortAfter !== undefined && i >= opts.abortAfter) {
controller.error(new DOMException('Aborted', 'AbortError')); // simule un Stop serveur
return;
}
if (i >= chunks.length) { controller.close(); return; }
controller.enqueue(new TextEncoder().encode(chunks[i++]));
},
});
}
// agent-chat.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { AgentChatComponent } from './agent-chat.component';
import { sseStreamFrom } from './stream-test-utils';
describe('AgentChatComponent (streaming IA)', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [AgentChatComponent],
providers: [provideZonelessChangeDetection()],
}));
it('rend les tokens au fil de l\'eau (append-only)', async () => {
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(sseStreamFrom(['Bon', 'jour', ' Alice']), {
headers: { 'Content-Type': 'text/event-stream' },
}),
);
const fixture = TestBed.createComponent(AgentChatComponent);
fixture.componentInstance.send('salut');
await fixture.whenStable(); // attend le drain du stream + rAF coalescé
const text = fixture.nativeElement.querySelector('[data-testid=assistant-msg]').textContent;
expect(text).toBe('Bonjour Alice'); // buffer append-only correctement concaténé
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('le bouton Stop annule le flux ET le serveur (AbortController)', async () => {
const abortSpy = jest.fn();
jest.spyOn(global, 'fetch').mockImplementation((_url, init) => {
(init?.signal as AbortSignal)?.addEventListener('abort', abortSpy);
return Promise.resolve(new Response(sseStreamFrom(['un', 'deux', 'trois'], { abortAfter: 1 })));
});
const fixture = TestBed.createComponent(AgentChatComponent);
fixture.componentInstance.send('long prompt');
await fixture.whenStable();
fixture.nativeElement.querySelector('[data-testid=stop]').click();
await fixture.whenStable();
expect(abortSpy).toHaveBeenCalled(); // le signal client a bien fire
expect(fixture.componentInstance.streaming()).toBe(false); // état revenu à idle
// ⬅️ assertion clé : on ne laisse PAS un message « streaming » orphelin dans le buffer
});
});Ce qu'un staff engineer vérifie en plus (les vrais bugs d'UI agent) :
- Partial-output au Stop : un tool en
streaminginterrompu doit transitionner verserroroudone(partial), jamais resterpendingà vie. Asserter l'état terminal de chaqueToolStep. - Coalescing rAF : sous un flux de 500 tokens/s, on ne doit pas déclencher 500 CD. Tester que le nombre de re-rendus est borné (spy sur un compteur de render ou
ChangeDetectorRef). - Backpressure / désabonnement : quitter le composant pendant le stream doit
abort()(sinon fuite + coût LLM continu). TesterngOnDestroy→abortappelé. - Markdown XSS : le markdown renvoyé par le LLM est non-fiable. Tester qu'un
<img onerror=...>ou<script>est neutralisé parDomSanitizeravant rendu. C'est un test de sécurité, pas cosmétique.
it('neutralise le markdown malveillant du LLM (XSS)', async () => {
const fixture = renderWithStream(['<img src=x onerror="alert(1)">']);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('img[onerror]')).toBeNull();
});Côté serveur (NestJS), le miroir de ces tests : on teste que l'endpoint SSE flush bien chunk par chunk, que
req.on('close')propage unAbortController.abort()jusqu'au SDK Anthropic (client.messages.stream({ ... }, { signal })avecclaude-sonnet-4-6/claude-opus-4-8), et qu'un job BullMQ d'agent est idempotent pargenerationId. Ces tests vivent côté Nest mais partagent le même contrat SSE que l'UI Angular — d'où l'intérêt d'un test e2e Playwright qui exerce les deux bouts en conditions réelles.
🧪 Testing
Cette section dans une note testing serait redondante. Voir les exemples ci-dessus pour les patterns spécifiques par outil. Quelques recommandations transverses pour 2026 :
// utilitaires de test partagés
import { signal } from '@angular/core';
export function createMockSignalService<T>(state: T) {
const sig = signal(state);
return {
state: sig.asReadonly(),
set: (next: T) => sig.set(next),
update: (fn: (s: T) => T) => sig.update(fn),
};
}// helper pour TestBed standalone
export async function setupComponent<T>(
component: new (...args: any[]) => T,
inputs: Partial<Record<keyof T, unknown>> = {},
) {
await TestBed.configureTestingModule({ imports: [component as any] }).compileComponents();
const fixture = TestBed.createComponent(component);
Object.entries(inputs).forEach(([key, value]) => {
fixture.componentRef.setInput(key, value);
});
fixture.detectChanges();
return fixture;
}Configuration Playwright minimale.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
},
});🎬 Cas d'usage concrets
Scénario 1 — Tests SaaS RH, formulaire ATS de création candidature
Contexte : l'équipe ATS du SaaS RH a un formulaire de création candidature complexe (15 champs, sections conditionnelles, upload CV, validation async d'unicité email côté serveur, sauvegarde brouillon auto toutes les 10s). Trois couches de tests : unitaires (Jest) sur le validator async emailUniqueValidator qui appelle l'API et debounce 400ms — on mocke HttpClient et on contrôle le temps avec fakeAsync + tick(400). Intégration (TestBed + harnesses Material) sur le composant CandidatureFormComponent : on renseigne les champs via MatInputHarness, on déclenche la validation async, on vérifie qu'un erreur apparaît si email déjà pris. End-to-end (Playwright) sur le parcours complet : login → ouverture page poste → clic "Postuler" → remplissage formulaire → upload PDF de 2 Mo → vérification que la candidature apparaît dans le pipeline du recruteur dans un autre onglet. La règle qu'on observe : 80% des bugs sont attrapés par les tests d'intégration TestBed bien écrits, pas par l'unitaire qui mocke tout.
Scénario 2 — Tests e-commerce, checkout Playwright
Contexte : le retailer mode a un funnel checkout critique (5% du CA à risque par bug). Stratégie : Playwright en mode --workers=4 lance 4 navigateurs en parallèle (Chromium, Firefox, WebKit, mobile Chrome) et joue le scénario complet (panier → adresse → livraison → paiement Stripe en mode test → confirmation). Les tests utilisent page.getByRole('button', { name: 'Payer' }) plutôt que des sélecteurs CSS — résilient aux refactors. Le step paiement utilise une carte de test Stripe (4242 4242 4242 4242) dans une iframe accédée via page.frameLocator('iframe[name^="__privateStripeFrame"]'). Pour les variations (codes promo, points fidélité, livraison express), un fichier checkout.spec.ts paramétré itère sur un tableau de cas. Les tests sont déclenchés sur chaque PR via GitHub Actions (3min total grâce au parallélisme) et bloquent le merge si KO. Captures + traces (--trace=on-first-retry) accessibles via Playwright Report pour debugger les échecs flaky.
Scénario 3 — Tests cabinet juridique, routing et guards
Contexte : application cabinet juridique avec routes nested (/dossiers/:id/pieces/:pieceId), guards (auth, droits par cabinet, lock pendant édition), resolvers (préchargement dossier). Stratégie : tests d'intégration TestBed sur les guards isolés (canActivate du RolesGuard avec un Router réel et SessionService mocké), tests sur les resolvers avec HttpTestingController pour vérifier qu'ils appellent les bonnes URLs. Pour le composant route DossierDetailComponent : RouterTestingHarness (Angular 15+) qui navigue vraiment vers /dossiers/123 et vérifie que le composant se monte avec le bon input id. Tests end-to-end Playwright sur les flux sensibles : "un avocat A ne peut pas voir un dossier du cabinet B", "deux avocats ne peuvent pas éditer la même pièce simultanément (lock UI)". On combine page.context() pour ouvrir deux contextes navigateur indépendants (deux utilisateurs connectés) dans le même test.
🛠️ Exemple end-to-end
Use case : tests à 3 niveaux du formulaire de création candidature ATS (unit Jest + integration TestBed + e2e Playwright).
// email-unique.validator.spec.ts (unit avec Jest)
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { FormControl } from '@angular/forms';
import { emailUniqueValidator } from './email-unique.validator';
import { CandidaturesApi } from './candidatures.api';
describe('emailUniqueValidator', () => {
let api: CandidaturesApi;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), CandidaturesApi],
});
api = TestBed.inject(CandidaturesApi);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('renvoie null pour un email libre', fakeAsync(() => {
const ctrl = new FormControl('', { asyncValidators: [emailUniqueValidator(api)] });
ctrl.setValue('[email protected]');
tick(400);
http.expectOne(`/api/candidatures/check-email?email=libre%40example.com`).flush({ taken: false });
expect(ctrl.errors).toBeNull();
}));
it('renvoie taken pour un email déjà pris', fakeAsync(() => {
const ctrl = new FormControl('', { asyncValidators: [emailUniqueValidator(api)] });
ctrl.setValue('[email protected]');
tick(400);
http.expectOne(/check-email/).flush({ taken: true });
expect(ctrl.errors).toEqual({ taken: true });
}));
});// candidature-form.component.spec.ts (integration TestBed + harnesses)
import { TestBed } from '@angular/core/testing';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatInputHarness } from '@angular/material/input/testing';
import { MatButtonHarness } from '@angular/material/button/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { CandidatureFormComponent } from './candidature-form.component';
describe('CandidatureFormComponent', () => {
let loader: HarnessLoader;
let http: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CandidatureFormComponent, NoopAnimationsModule],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
});
it('soumet une candidature valide', async () => {
const fixture = TestBed.createComponent(CandidatureFormComponent);
fixture.componentRef.setInput('posteId', 'p-123');
fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
const nom = await loader.getHarness(MatInputHarness.with({ selector: '[data-testid="nom"]' }));
const email = await loader.getHarness(MatInputHarness.with({ selector: '[data-testid="email"]' }));
await nom.setValue('Alice Martin');
await email.setValue('[email protected]');
fixture.detectChanges();
http.expectOne(/check-email/).flush({ taken: false });
const submit = await loader.getHarness(MatButtonHarness.with({ text: /Envoyer/ }));
await submit.click();
const req = http.expectOne('/api/candidatures');
expect(req.request.body).toMatchObject({ posteId: 'p-123', nom: 'Alice Martin' });
req.flush({ id: 'c-1', stage: 'new' });
});
});// candidature-flow.spec.ts (e2e Playwright)
import { test, expect } from '@playwright/test';
test.describe('Création candidature', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Mot de passe').fill('test-pass');
await page.getByRole('button', { name: 'Se connecter' }).click();
await expect(page).toHaveURL(/\/postes/);
});
test('candidate sur un poste et apparaît dans le pipeline', async ({ page, browser }) => {
await page.goto('/postes/p-123');
await page.getByRole('button', { name: 'Postuler' }).click();
await page.getByLabel('Nom complet').fill('Bob Dupont');
await page.getByLabel('Email').fill(`bob+${Date.now()}@example.com`);
await page.setInputFiles('input[type="file"]', 'tests/fixtures/cv.pdf');
await page.getByRole('button', { name: 'Envoyer' }).click();
await expect(page.getByText('Candidature envoyée')).toBeVisible();
const recruteur = await browser.newContext({ storageState: 'tests/auth/recruteur.json' });
const recruteurPage = await recruteur.newPage();
await recruteurPage.goto('/ats/pipeline?poste=p-123');
await expect(recruteurPage.getByText('Bob Dupont')).toBeVisible();
});
});Trois niveaux complémentaires : Jest valide la logique pure rapidement, TestBed + harnesses valident l'intégration template/forms, Playwright valide le parcours utilisateur réel cross-onglets.
🔁 Quand utiliser / éviter
| Outil | Utiliser pour | Éviter pour |
|---|---|---|
| Jest | Unit tests, services, pipes, logique pure, projets enterprise | Tests nécessitant ESM strict ou worker threads ultra-rapides |
| Vitest | Nouveaux projets, équipes Vite, besoin de vitesse maximale | Projets enterprise très matures (écosystème encore plus jeune) |
| TestBed | Tests de composants avec interactions DOM et injection | Tests purs (services, fonctions) — overhead inutile |
| Testing Library | Tests user-centric, accessibilité, comportement | Tests d'implémentation détaillés (méthodes privées) |
| Harnesses Material | Composants Angular Material/CDK | Composants custom (préférer Testing Library) |
| fakeAsync | Timers, debounce, async simple | Streams RxJS complexes (marble testing) |
| Marble testing | Pipelines RxJS, opérateurs temporels | Logique synchrone (overhead inutile) |
| Playwright | E2E critiques, multi-navigateurs, visual testing | Tester des détails d'implémentation Angular |
| Cypress | Équipes habituées à Cypress, plugins existants | Nouveaux projets multi-navigateurs (Playwright meilleur) |
🏋️ Exercices
Progression : implémenter → rendre production-grade → casser puis réparer. Chaque exercice monte d'un cran.
1. Le validator async testé au temps maîtrisé
Objectif : tester un emailUniqueValidator qui debounce 400 ms et appelle l'API, en contrôlant le temps — zéro setTimeout réel.
Indice/Solution : fakeAsync + new FormControl('', { asyncValidators: [v(api)] }), setValue, tick(400), puis HttpTestingController.expectOne(/check-email/).flush({ taken }). Vérifier ctrl.pending === false après tick. Variante zoneless : remplacer fakeAsync par jest.useFakeTimers() + await ctrl.statusChanges filtré sur un statut non-PENDING.
2. Harness + HTTP : le formulaire qui se soumet vraiment
Objectif : tester CandidatureFormComponent de bout en bout côté intégration : remplir via MatInputHarness, déclencher la validation async, soumettre, asserter le body POST.
Indice/Solution : TestbedHarnessEnvironment.loader(fixture), provideNoopAnimations(), provideHttpClientTesting(). Piège : il faut flush la requête check-email avant que le bouton submit soit enabled. Asserter req.request.body avec toMatchObject.
3. Migration zoneless : casser puis réparer
Objectif : prendre un composant testé en zoneful qui passe, ajouter provideZonelessChangeDetection() à la config de test, observer l'échec, puis le réparer.
Indice/Solution : le test casse parce qu'il s'appuyait sur une CD « fantôme » déclenchée par un event sans lien. Réparer en remplaçant les fixture.detectChanges() post-interaction par await fixture.whenStable(), et vérifier que le composant utilise bien des signals ou markForCheck() plutôt qu'une mutation de propriété nue. C'est exactement le bug qu'on veut attraper avant la prod.
4. Mutation testing : le coverage qui ment
Objectif : écrire un test qui atteint 100 % de coverage sur une fonction de pricing mais survit à une mutation Stryker (donc ne vérifie rien), puis le durcir.
Indice/Solution : un test qui appelle computePrice(...) sans expect sur le résultat a 100 % coverage et mutation score 0. Lancer npx stryker run, voir les mutants « survived » (ex. * muté en /), ajouter des assertions sur les valeurs limites (qty 0, remise > 100 %) jusqu'à tuer les mutants.
5. Streaming agent IA : le test du Stop
Objectif : tester qu'un clic sur Stop pendant un flux LLM streamé annule le client (AbortController) et ne laisse aucun ToolStep orphelin en pending/streaming.
Indice/Solution : stubber fetch avec un ReadableStream (sseStreamFrom ci-dessus), brancher un spy sur signal.addEventListener('abort'), cliquer Stop, await whenStable(). Asserter : abortSpy appelé, streaming() === false, et que chaque step a kind ∈ {done, error}. Bonus : vérifier que ngOnDestroy abort aussi (fuite + coût LLM).
6. Flakiness à la demande : reproduire puis éliminer
Objectif : écrire délibérément un test flaky (dépendant de l'horloge réelle ou de l'ordre), le faire échouer ~1 fois sur 5, puis le rendre 100 % déterministe.
Indice/Solution : source typique = Date.now() réel, Math.random, ou état partagé entre it(). Reproduire avec jest --runInBand vs parallèle, ou en bouclant for i in {1..50}; do npx jest mon.spec; done. Éliminer : fake timers, seed du random, isolation d'état en beforeEach. Mesurer le taux d'échec avant/après pour prouver le fix.
🎤 En entretien
Q : Pourquoi fixture.detectChanges() ne suffit-il plus en zoneless, et que met-on à la place ? R : Sans zone.js, le rendu est piloté par un scheduler qui coalesce les mises à jour sur un microtask ; detectChanges() force un cycle synchrone mais ne reflète pas le timing réel de l'app. On utilise await fixture.whenStable() après une interaction pour attendre que le scheduler ait drainé — ce qui rend le test fidèle à la prod zoneless et déterministe.
Q : Coverage à 95 % — pourquoi un staff engineer n'est-il pas rassuré ? R : Le line coverage mesure ce qui est exécuté, pas ce qui est vérifié : un test sans assertion couvre tout et ne garantit rien. La métrique de vérité est le mutation score (Stryker) : il mute le code et vérifie qu'un test échoue. On préfère 80 % de mutation score sur le code métier critique à 95 % de line coverage sur du trivial.
Q : Comment tester une UI qui stream des tokens LLM, et le bouton Stop ? R : On stubbe fetch pour renvoyer une Response dont le body est un ReadableStream qu'on alimente token par token — on contrôle ainsi timing et coupures sans serveur. Pour Stop : un spy sur signal.addEventListener('abort') prouve l'annulation client, et on asserte qu'aucun ToolStep ne reste pending/streaming (pas d'état orphelin) et que ngOnDestroy abort aussi pour éviter fuite et coût LLM continu.
Q : Test flaky qui passe au retry — on garde le retry et on avance ? R : Non. Le retry masque le symptôme et érode la confiance dans la suite. La discipline : quarantaine du test (hors gate bloquant, tracké), observabilité (trace/JSON ingéré dans un dashboard), puis root-cause — quasi toujours temps réel, ordre, réseau réel, animations ou course de rendu. On répare la cause, on ne vit pas avec retries comme pansement permanent.
Q : Pyramide ou Trophy ? Où mets-tu le poids de tes tests sur une app Angular ? R : Trophy pour le code UI/orchestration, pyramide pour la logique algorithmique. Concrètement : peu d'unitaires triviaux, beaucoup de tests d'intégration TestBed (composant réel + vrais services + HTTP mocké via HttpTestingController) qui attrapent la majorité des régressions à coût modéré, et une fine couche e2e Playwright sur les parcours qui touchent au CA. Le critère de décision n'est pas un quota mais le produit coût de détection × coût d'échappement : on teste lourd là où un bug coûte cher en prod, léger ailleurs.
Q : jest-preset-angular ou le builder Vitest officiel — comment tu tranches en 2026 ? R : Le choix porte sur le pipeline de build, pas sur l'écriture des tests (même API TestBed). Nouveau projet ou migration assumée → @angular/build:unit-test sur Vitest : un seul pipeline esbuild/Vite cohérent avec la prod, watch quasi instantané, ESM natif, c'est le défaut ng new depuis octobre 2025. Monorepo enterprise mature avec gros investissement Jest (reporters, serializers, jest.mock partout) → rester sur jest-preset-angular tant que le coût de migration dépasse le gain de vitesse. Dans les deux cas, on teste en provideZonelessChangeDetection() pour rester fidèle à la prod.
🔗 Liens
- Documentation Angular Testing : https://angular.dev/guide/testing
- jest-preset-angular : https://github.com/thymikee/jest-preset-angular
- Testing Library Angular : https://testing-library.com/docs/angular-testing-library/intro
- Playwright : https://playwright.dev
- CDK Testing Harnesses : https://material.angular.io/cdk/testing/overview
- Vitest : https://vitest.dev
- Marble testing RxJS : https://rxjs.dev/guide/testing/marble-testing
- Article Angular Roadmap testing (blog.angular.dev) — déprécation Karma
- Stryker (mutation testing JS/TS) : https://stryker-mutator.io
- Zoneless Angular (guide officiel) : https://angular.dev/guide/zoneless
- Playwright sharding & merge-reports : https://playwright.dev/docs/test-sharding
- Testing Trophy (Kent C. Dodds) : https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications