Tests
TestBed, harnesses, composants, services, signals.
Vérifié en septembre 2026 · Angular 22.1 · environ 18 min
Un test Angular vérifie ce qu'un composant affiche et ce qu'un service calcule, sans navigateur ni serveur, en quelques millisecondes. Deux outils se partagent le travail : Vitest exécute les tests et fournit describe, it, expect et les doublures vi ; TestBed, d'Angular, construit autour de chaque test l'injecteur, la détection de changements et le DOM dont le code a besoin. Le cours porte sur Angular 22.1 sans Zone.js, tel que ce site est lui-même testé. Les notions générales — structure d'un test, isolement, stubs et mocks — sont celles des cours
TestBed et Vitest
ng test lance le builder @angular/build:unit-test, dont le runner par défaut est Vitest. Faute d'option browsers, les tests tournent dans Node.js sur un DOM émulé, jsdom ou happy-dom selon celui qui est installé. Le builder compile les fichiers *.spec.ts et ce qu'ils importent avec le système de build de l'application, puis passe le résultat à Vitest : il n'y a pas de vitest.config.ts à écrire.
// Deux extraits JSON de ce site.
// angular.json : la cible test, sans autre option
"test": {
"builder": "@angular/build:unit-test"
}
// tsconfig.spec.json : les types de describe, it, expect et vi. A l'execution,
// c'est le builder qui les pose en globales (option globals: true de Vitest).
"compilerOptions": {
"types": ["vitest/globals"]
}TestBed.configureTestingModule déclare les fournisseurs du test, et TestBed.createComponent monte un composant dans le DOM émulé. Un composant standalone apporte ses propres imports : le lister dans imports est permis mais pas nécessaire, et le placer dans declarations est une erreur, qui demande « did you intend to import it instead ». Ce qui reste à configurer, ce sont les dépendances à remplacer.
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Session {
readonly utilisateur = signal<string | null>(null);
}import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Session } from './session';
@Component({
selector: 'app-en-tete',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (session.utilisateur(); as nom) {
<p>Connecté : {{ nom }}</p>
} @else {
<p>Anonyme</p>
}
`,
})
export class EnTete {
protected readonly session = inject(Session);
}import { provideZonelessChangeDetection, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { EnTete } from './en-tete';
import { Session } from './session';
describe('EnTete', () => {
// Un stub : il rend ce que le test a decide, sans aucune logique.
let session: Pick<Session, 'utilisateur'>;
beforeEach(() => {
session = { utilisateur: signal<string | null>('Ada') };
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection(), { provide: Session, useValue: session }],
});
});
it('affiche le nom connecte, puis Anonyme apres la deconnexion', async () => {
const fixture = TestBed.createComponent(EnTete);
await fixture.whenStable();
const hote = fixture.nativeElement as HTMLElement;
expect(hote.textContent).toContain('Connecté : Ada');
session.utilisateur.set(null);
await fixture.whenStable();
expect(hote.textContent).toContain('Anonyme');
});
}); Le fournisseur { provide: Session, useValue: session } remplace le service racine pour ce seul test : c'est l'injecteur, pas le composant, qui décide de ce qu'il reçoit. En Angular 22.1, TestBed est déjà sans zone par défaut, mais le fichier d'initialisation qu'ajoute ng test fournit provideZoneChangeDetection() dès que Zone.js est chargé — et il le charge de lui-même s'il le trouve installé et que la cible de build ne déclare pas de polyfills. provideZonelessChangeDetection(), écrit dans chaque configuration, garde le test sans zone dans tous les cas. Enfin, TestBed est réinitialisé après chaque test : chaque it reçoit un injecteur neuf, donc des services neufs, comme xUnit construit une instance neuve de la classe de test.
Tester un service
Une classe qui ne fait appel à aucun inject() se teste par new, sans TestBed : c'est le plus rapide et le plus lisible, et la section sur les signals en montre un exemple. Dès que la classe injecte quelque chose, new échoue, parce qu'inject() ne fonctionne que dans un contexte d'injection : un constructeur, un initialiseur de champ ou une fabrique exécutés par un injecteur. TestBed.inject fournit cet injecteur, et le test y déclare la valeur des jetons dont le service dépend.
import { Injectable, InjectionToken, inject } from '@angular/core';
export const TAUX_TVA = new InjectionToken<number>('TAUX_TVA');
@Injectable({ providedIn: 'root' })
export class Tarifs {
private readonly taux = inject(TAUX_TVA);
prixTtc(prixHt: number): number {
return Math.round(prixHt * (1 + this.taux) * 100) / 100;
}
}import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TAUX_TVA, Tarifs } from './tarifs';
describe('Tarifs', () => {
it('ne se construit pas par new, faute de contexte d injection', () => {
// NG0203: The `InjectionToken TAUX_TVA` token injection failed. `inject()` function
// must be called from an injection context such as a constructor, a factory function,
// a field initializer, or a function used with `runInInjectionContext`.
expect(() => new Tarifs()).toThrow(/NG0203/);
});
it('applique le taux fourni par le test', () => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection(), { provide: TAUX_TVA, useValue: 0.2 }],
});
expect(TestBed.inject(Tarifs).prixTtc(10)).toBe(12);
});
}); Les fonctions qui injectent — gardes, résolveurs, intercepteurs — n'ont pas d'injecteur pour les construire : elles s'appellent, simplement, et c'est l'appel qui doit avoir lieu dans un contexte d'injection. TestBed.runInInjectionContext exécute la fonction qu'on lui passe dans le contexte de l'injecteur du test. Appelée directement, la même garde lève NG0203 sur son premier inject.
import { inject } from '@angular/core';
import { type CanActivateFn, Router } from '@angular/router';
import { Session } from './session';
export const connecteGuard: CanActivateFn = () =>
inject(Session).utilisateur() !== null || inject(Router).createUrlTree(['/connexion']);import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
type ActivatedRouteSnapshot,
provideRouter,
type RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import { connecteGuard } from './connecte.guard';
import { Session } from './session';
describe('connecteGuard', () => {
// La garde ne lit ni la route ni l'etat : deux objets vides satisfont la signature.
const route = {} as ActivatedRouteSnapshot;
const etat = {} as RouterStateSnapshot;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection(), provideRouter([])],
});
});
it('laisse passer un utilisateur connecte', () => {
TestBed.inject(Session).utilisateur.set('Ada');
expect(TestBed.runInInjectionContext(() => connecteGuard(route, etat))).toBe(true);
});
it('renvoie un visiteur anonyme vers /connexion', () => {
// Session neuve : TestBed a tout reconstruit depuis le test precedent.
const resultat = TestBed.runInInjectionContext(() => connecteGuard(route, etat));
expect(resultat).toBeInstanceOf(UrlTree);
expect(String(resultat)).toBe('/connexion');
});
}); Le second test s'appuie sur la réinitialisation entre deux tests : la session qu'a remplie le premier n'existe plus. Les gardes elles-mêmes sont l'objet du cours
Tester un composant sans Zone.js
Sans Zone.js, un test attend le rendu comme l'application l'attend : par une notification. fixture.componentRef.setInput donne une valeur à une entrée et notifie Angular. Une affectation sur componentInstance ne notifierait rien, et sur une entrée input(), signal en lecture seule, elle ne compile même pas. Une entrée mal nommée lève NG0303 dès l'appel. await fixture.whenStable() attend ensuite que le rendu planifié ait eu lieu ; un clic sur un bouton, qui passe par un écouteur du template, notifie de la même façon.
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
@Component({
selector: 'app-compteur',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<output>{{ valeur() }}</output>
<button type="button" (click)="incrementer()">+{{ pas() }}</button>
`,
})
export class Compteur {
readonly pas = input(1);
protected readonly valeur = signal(0);
protected incrementer(): void {
this.valeur.update((v) => v + this.pas());
}
}import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Compteur } from './compteur';
describe('Compteur', () => {
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
});
it('ajoute le pas recu en entree a chaque clic', async () => {
const fixture = TestBed.createComponent(Compteur);
fixture.componentRef.setInput('pas', 5);
await fixture.whenStable();
const hote = fixture.nativeElement as HTMLElement;
const bouton = hote.querySelector('button')!;
expect(bouton.textContent).toBe('+5');
bouton.click();
bouton.click();
await fixture.whenStable();
expect(hote.querySelector('output')?.textContent).toBe('10');
});
});createComponent ne lance pas la détection de changements de façon synchrone : juste après setInput, rien n'est encore rendu et le bouton n'a pas de texte. Sur un composant déjà affiché, un nouveau setInput laisse de même l'ancien libellé jusqu'à l'attente : le rendu est planifié, pas exécuté. Le await n'est donc pas une précaution, c'est ce qui sépare l'écriture de son effet à l'écran.
fixture.detectChanges() n'est pas déprécié, mais il a perdu sa raison d'être. Sans zone, une fixture se met à jour d'elle-même : l'autodétection est active par défaut, et autoDetectChanges(false) lève une erreur. Appelé à la main, detectChanges() exécute le même rafraîchissement que TestBed.tick() ; il ne rattrape pas un composant qui a oublié de notifier. Pour un composant en stratégie Eager qui modifie un champ ordinaire dans un setTimeout, whenStable() laisse l'ancienne valeur à l'écran, comme l'application, et detectChanges() lève NG0100. Les deux révèlent le défaut ; seul le premier ressemble à ce que voit l'utilisateur.
whenStable() a une limite : il attend ce qu'Angular sait en cours — un rendu planifié, une tâche déclarée à PendingTasks —, rien de plus, et rend la main tout de suite quand rien ne l'est. Une promesse lancée hors d'Angular, comme l'écriture dans le presse-papiers, n'est pas attendue. Le remède est dans le composant : PendingTasks.run, encore en developer preview en 22.1, déclare la promesse à Angular, qui reste instable jusqu'à sa fin, et un simple whenStable() suffit alors, quelle que soit sa durée. jsdom n'ayant pas de navigator.clipboard, le test en fournit un.
import {
ChangeDetectionStrategy,
Component,
PendingTasks,
inject,
input,
signal,
} from '@angular/core';
@Component({
selector: 'app-copie',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<button type="button" (click)="copier()">{{ etiquette() }}</button>`,
})
export class Copie {
readonly texte = input.required<string>();
protected readonly etiquette = signal('Copier');
private readonly taches = inject(PendingTasks);
protected copier(): void {
// Declare l'ecriture a Angular : l'application reste instable tant qu'elle dure.
this.taches.run(async () => {
await navigator.clipboard.writeText(this.texte());
this.etiquette.set('Copié');
});
}
}import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Copie } from './copie';
describe('Copie', () => {
beforeEach(() => {
// jsdom n'a pas de navigator.clipboard : le test en pose un, qui met 20 ms a ecrire.
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: () => new Promise<void>((fin) => setTimeout(fin, 20)) },
});
TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
});
afterEach(() => Reflect.deleteProperty(navigator, 'clipboard'));
it('passe a Copié une fois l ecriture terminee', async () => {
const fixture = TestBed.createComponent(Copie);
fixture.componentRef.setInput('texte', 'npm test');
await fixture.whenStable();
const bouton = (fixture.nativeElement as HTMLElement).querySelector('button')!;
bouton.click();
await fixture.whenStable();
expect(bouton.textContent).toBe('Copié');
});
}); Sans PendingTasks, le test devrait attendre lui-même la promesse de sa doublure. Laisser passer un setTimeout de zéro milliseconde avant whenStable() n'est qu'un pis-aller : cela marche pour une promesse résolue après une tâche, et échoue dès que la doublure met 20 ms. Une doublure déjà résolue, elle, passe avant le rendu et masque le problème.
Le temps : des faux minuteurs, pas fakeAsync
Un composant qui agit après un délai ne se teste pas en attendant ce délai. Le bandeau suivant disparaît trois secondes après son affichage.
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
input,
signal,
} from '@angular/core';
@Component({
selector: 'app-bandeau',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (visible()) {
<p role="status">{{ message() }}</p>
}
`,
})
export class Bandeau {
readonly message = input.required<string>();
protected readonly visible = signal(true);
constructor() {
const minuteur = setTimeout(() => this.visible.set(false), 3000);
inject(DestroyRef).onDestroy(() => clearTimeout(minuteur));
}
} Le test hérité des projets sous Zone.js enveloppe le test dans fakeAsync et fait avancer le temps par tick. Il ne fonctionne plus : fakeAsync est une zone, il exige Zone.js et son module de test, et l'erreur tombe dès la déclaration du test, si bien qu'aucun test du fichier ne s'exécute.
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Bandeau } from './bandeau';
describe('Bandeau', () => {
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
});
it('disparait apres trois secondes', fakeAsync(() => {
const fixture = TestBed.createComponent(Bandeau);
fixture.componentRef.setInput('message', 'Enregistré');
fixture.detectChanges();
tick(3000);
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('p')).toBeNull();
}));
});
// ng test, Angular 22.1 et Vitest 4.1, sans Zone.js : aucun test du fichier ne tourne.
// Error: zone-testing.js is needed for the fakeAsync() test helper but could not be found.
// Please make sure that your environment includes zone.js/testingimport { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Bandeau } from './bandeau';
describe('Bandeau', () => {
beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
});
afterEach(() => vi.useRealTimers());
it('disparait apres trois secondes, pas avant', () => {
const fixture = TestBed.createComponent(Bandeau);
fixture.componentRef.setInput('message', 'Enregistré');
const hote = fixture.nativeElement as HTMLElement;
// Sous faux minuteurs, whenStable() ne rendrait jamais la main : le rendu
// planifie attend lui-meme un setTimeout. TestBed.tick() le fait tout de suite.
TestBed.tick();
expect(hote.querySelector('p')?.textContent).toBe('Enregistré');
vi.advanceTimersByTime(2999);
TestBed.tick();
expect(hote.querySelector('p')).not.toBeNull();
vi.advanceTimersByTime(1);
TestBed.tick();
expect(hote.querySelector('p')).toBeNull();
});
}); Les faux minuteurs de Vitest remplacent setTimeout, setInterval et leurs voisins, et vi.advanceTimersByTime fait avancer une horloge fictive. Ils remplacent aussi ceux dont se sert Angular : son planificateur lance ordinairement le rendu par un setTimeout mis en course avec requestAnimationFrame. Sous faux minuteurs, whenStable() ne se résout donc pas tant que l'horloge fictive n'avance pas, et le test expire au bout des cinq secondes que Vitest accorde par défaut. TestBed.tick(), en exécutant le rendu en attente de façon synchrone, lève le blocage. Le test vérifie aussi la borne basse : à 2999 ms, le bandeau est encore là.
Un moyen de le garder existe depuis mai 2026 : zone.js 0.16.2, du 6 mai, a ajouté le correctif zone.js/plugins/vitest-patch, et la 0.16.3, du 2 septembre, l'a corrigé. Il faut installer zone.js et ajouter zone.js et ce correctif aux polyfills de la cible de build qu'utilise ng test — une configuration dédiée, de préférence —, car @angular/build:unit-test n'a pas d'option polyfills. La documentation de l'API n'a pas suivi : en 22.1, celle de fakeAsync et la page « Component testing scenarios » disent encore qu'il ne fonctionne pas sous Vitest. Le guide de migration, qui mentionne le correctif, recommande de toute façon de passer aux faux minuteurs de Vitest et à async/await.
Signals, computed et effect
Un signal se lit par un appel et computed se recalcule à la lecture, de façon synchrone : leur test n'a besoin ni de TestBed ni d'attente. Une classe qui ne fait que dériver des valeurs se teste par new.
import { computed, signal } from '@angular/core';
export interface Ligne {
readonly article: string;
readonly prix: number;
readonly quantite: number;
}
export class Panier {
private readonly lignes = signal<readonly Ligne[]>([]);
readonly total = computed(() =>
this.lignes().reduce((somme, ligne) => somme + ligne.prix * ligne.quantite, 0),
);
ajouter(ligne: Ligne): void {
this.lignes.update((lignes) => [...lignes, ligne]);
}
}import { Panier } from './panier';
describe('Panier', () => {
it('recalcule le total a chaque ajout', () => {
const panier = new Panier();
expect(panier.total()).toBe(0);
panier.ajouter({ article: 'clavier', prix: 40, quantite: 2 });
panier.ajouter({ article: 'souris', prix: 15, quantite: 1 });
expect(panier.total()).toBe(95);
});
});effect est différent sur deux points, que le cours TestBed, puis provoquer l'exécution des effects en attente. TestBed.tick() le fait de façon synchrone ; TestBed.flushEffects(), qui jouait ce rôle, est déprécié en sa faveur et se contente désormais de l'appeler.
import { Injectable, effect, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Preferences {
readonly theme = signal<'clair' | 'sombre'>('clair');
constructor() {
// Un effect de service racine : il passe avec le prochain rendu, pas a l'ecriture.
effect(() => localStorage.setItem('theme', this.theme()));
}
}import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Preferences } from './preferences';
describe('Preferences', () => {
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
});
it('enregistre le theme une fois les effects executes', () => {
const preferences = TestBed.inject(Preferences);
preferences.theme.set('sombre');
expect(localStorage.getItem('theme')).toBeNull();
TestBed.tick();
expect(localStorage.getItem('theme')).toBe('sombre');
});
}); La première assertion est aussi importante que la seconde : elle fixe que l'écriture dans le signal ne déclenche rien par elle-même. Sans TestBed.tick(), la valeur n'apparaît qu'après le passage d'une tâche, un setTimeout de zéro milliseconde par exemple ; une simple microtâche ne suffit pas. Un effect créé dans un composant passe, lui, pendant le rendu de ce composant : le await fixture.whenStable() habituel le couvre.
HTTP avec HttpTestingController
Un service qui appelle HttpClient se teste sans serveur. provideHttpClientTesting() remplace le transport par un contrôleur qui retient chaque requête sans l'envoyer ; le test l'inspecte, puis lui donne sa réponse par flush. HttpClient étant fourni à la racine depuis Angular 21, provideHttpClient() ne sert ici qu'à reproduire la configuration de l'application, c'est-à-dire ses intercepteurs. Cette configuration est l'objet du cours
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export interface Produit {
readonly id: number;
readonly nom: string;
}
@Injectable({ providedIn: 'root' })
export class Catalogue {
private readonly http = inject(HttpClient);
rechercher(terme: string): Observable<Produit[]> {
return this.http.get<Produit[]>('/api/produits', { params: { q: terme } });
}
}import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Catalogue, type Produit } from './catalogue';
describe('Catalogue', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
// provideHttpClient() reproduit la configuration de l'application (ses intercepteurs) ;
// provideHttpClientTesting() vient apres et remplace le transport.
providers: [provideZonelessChangeDetection(), provideHttpClient(), provideHttpClientTesting()],
});
http = TestBed.inject(HttpTestingController);
});
// Echoue si une requete est restee sans reponse :
// Expected no open requests, found 1: GET /api/produits?q=souris
afterEach(() => http.verify());
it('envoie le terme en parametre et rend les produits recus', () => {
let recus: Produit[] | undefined;
TestBed.inject(Catalogue)
.rechercher('clavier')
.subscribe((produits) => (recus = produits));
const requete = http.expectOne('/api/produits?q=clavier');
expect(requete.request.method).toBe('GET');
expect(recus).toBeUndefined();
requete.flush([{ id: 1, nom: 'Clavier' }]);
expect(recus).toEqual([{ id: 1, nom: 'Clavier' }]);
});
});expectOne compare l'URL paramètres compris : '/api/produits' seul ne trouverait rien, et le message d'échec liste les requêtes réellement reçues. Pour un critère plus souple, expectOne accepte une fonction qui reçoit la requête. Deux détails comptent encore. D'abord l'ordre des fournisseurs : placé avant provideHttpClient(), provideHttpClientTesting() est écrasé, la requête part vers le vrai transport et expectOne ne trouve rien. Et verify(), dans afterEach, fait échouer tout test qui laisse une requête sans réponse, ce qui attrape les appels que le test n'attendait pas.
Harnesses de composant
Les tests précédents lisent le DOM par des sélecteurs : ils dépendent de la structure du template, et chaque test qui utilise le compteur répète ces sélecteurs. Un harness les regroupe dans une classe qui expose ce que l'utilisateur fait et voit — incrémenter, lire la valeur — et masque le reste. Le jour où <output> devient un <span>, seul le harness change. Les harnesses viennent du CDK, @angular/cdk, dont la version suit celle d'Angular : la 22.2.0 est sortie avec Angular 22.2 le 23 septembre 2026, et un projet en 22.1 comme ce site l'ajoute par ng add @angular/[email protected], pour garder les deux alignés. Angular Material livre des harnesses pour ses propres composants.
import { ComponentHarness } from '@angular/cdk/testing';
export class CompteurHarness extends ComponentHarness {
static hostSelector = 'app-compteur';
// Des fonctions, pas des elements : chaque appel relit le DOM courant.
private readonly bouton = this.locatorFor('button');
private readonly affichage = this.locatorFor('output');
async incrementer(): Promise<void> {
await (await this.bouton()).click();
}
async valeur(): Promise<number> {
return Number(await (await this.affichage()).text());
}
} Un harness étend ComponentHarness et déclare dans hostSelector le sélecteur de son composant. locatorFor ne cherche rien tout de suite : il rend une fonction, qui refait la recherche à chaque appel, pour qu'aucun test ne garde un élément qu'un @if aurait remplacé depuis. Les éléments trouvés sont des TestElement, dont toutes les méthodes — click, text, setInputValue — sont asynchrones.
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { ChangeDetectionStrategy, Component, provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Compteur } from './compteur';
import { CompteurHarness } from './compteur.harness';
@Component({
imports: [Compteur],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<app-compteur [pas]="2" /><app-compteur [pas]="10" />`,
})
class Page {}
describe('Compteur, par son harness', () => {
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
});
it('additionne le pas de chaque compteur a chaque clic', async () => {
const fixture = TestBed.createComponent(Page);
const loader = TestbedHarnessEnvironment.loader(fixture);
const [petit, grand] = await loader.getAllHarnesses(CompteurHarness);
await petit.incrementer();
await grand.incrementer();
await grand.incrementer();
expect(await petit.valeur()).toBe(2);
expect(await grand.valeur()).toBe(20);
});
});TestbedHarnessEnvironment.loader(fixture) cherche les harnesses sous la fixture, et getAllHarnesses en rend un par compteur trouvé. Le test ne contient ni whenStable ni sélecteur : le harness lance la détection de changements et attend la stabilité avant chaque lecture et après chaque action. Cette asynchronie a une raison : la même classe sert dans un test de bout en bout piloté par WebDriver, où chaque lecture est un aller-retour vers le navigateur. La documentation d'Angular recommande d'en écrire pour les composants partagés, utilisés en de nombreux endroits, et note qu'ils apportent moins à un composant qui n'apparaît qu'à un seul endroit, dont les tests évoluent avec lui.