Cours Angular · Module 11: Tests

Tester le temps et les signals

8 min de lecture
Dans cette leçon5

Certaines techniques du samouraï frappent en différé: le coup part maintenant, il touche une seconde plus tard. Comment vérifier cela sans attendre pour de vrai? Cette leçon te livre la ruse la plus précieuse des tests asynchrones: dans un test, c'est toi qui commandes le temps. Tu arrêtes l'horloge, tu l'avances d'autant que tu veux, tu observes l'effet - le tout en une fraction de seconde.

fakeAsync et tick - arrêter et avancer l'horloge

fakeAsync crée une zone où le temps reste figé tant que tu ne le pousses pas toi-même. tick(ms) le fait avancer à la main du nombre de millisecondes demandé. Un test de délai dure donc un clin d'oeil au lieu de plusieurs secondes.

1import { fakeAsync, tick, flush } from '@angular/core/testing';
2
3it('should update after delay', fakeAsync(() => {
4  const component = new AsyncComponent();
5
6  component.loadData();  // programme un setTimeout de 1000ms
7  expect(component.data).toBeNull();
8
9  tick(500);             // avance le temps de 500ms
10  expect(component.data).toBeNull();   // trop tot
11
12  tick(500);             // encore 500ms - 1000 au total
13  expect(component.data).toBeTruthy(); // pret maintenant
14}));

Suis la logique du temps: juste après loadData, les données sont vides parce que le timer n'a pas encore expiré. Après le premier tick(500), elles le sont toujours - une demi-seconde seulement s'est écoulée. C'est le second tick(500) qui complète la seconde et déclenche le timer. Retiens bien ce que fait un tick(1000): il simule le passage de 1000ms de temps virtuel. Il n'attend pas une seconde en temps réel, il n'exécute pas 1000 itérations de boucle, et il ne limite pas non plus la durée du test à une seconde. C'est une horloge que tu avances toi-même, rien de plus.

Compter les millisecondes devient vite fastidieux quand un composant programme plusieurs timers à la suite. Dans ce cas tu ne veux justement pas indiquer de durée: tu veux que tout ce qui est en attente s'exécute d'un coup.

1import { flushMicrotasks } from '@angular/core/testing';
2
3it('should handle multiple timers', fakeAsync(() => {
4  const component = new AsyncComponent();
5
6  component.loadMultiple();  // plusieurs setTimeout imbriques
7
8  flush();  // execute tous les timers en attente
9
10  expect(component.allLoaded).toBeTrue();
11}));
12
13it('should handle promises', fakeAsync(() => {
14  const component = new AsyncComponent();
15
16  component.loadWithPromise();
17
18  flushMicrotasks();  // vide la file des microtaches
19
20  expect(component.promiseResult).toBeDefined();
21}));

Voilà la nuance à garder: tick(300) avance d'une durée précise, alors que flush() sert quand on veut exécuter tous les timers en attente sans préciser de durée. Ce ne sont donc pas deux noms pour la même fonction, et cela n'a rien à voir avec les tests E2E. Quant aux Promises, ni l'un ni l'autre ne les concerne: c'est flushMicrotasks qui vide la file des microtâches.

Le cas le plus fréquent dans une vraie application, c'est le debounce d'un champ de recherche: le composant attend 300ms de silence avant de lancer la requête. Le test se déroule alors toujours dans le même ordre - on pose un spy, on déclenche l'action, on avance le temps, on vérifie.

1it('should debounce search input', fakeAsync(() => {
2  const spy = spyOn(component, 'performSearch');
3
4  component.onSearchChange('samurai');
5  tick(300);
6  expect(spy).toHaveBeenCalledWith('samurai');
7}));

L'ordre de ces quatre lignes n'est pas négociable. Le spy doit être posé avant l'action, sinon il ne voit rien passer. onSearchChange('samurai') déclenche le debounce, tick(300) fait écouler le délai, et c'est seulement ensuite que expect(spy).toHaveBeenCalledWith('samurai') a un sens. Si tu vérifiais avant le tick, le spy serait encore vierge et le test échouerait - à juste titre.

waitForAsync - attendre la vraie asynchronicité

fakeAsync fait semblant. Mais il t'arrivera de tester quelque chose de réellement asynchrone, une requête par exemple, et de vouloir attendre que tout se stabilise. C'est le rôle de waitForAsync associé à whenStable.

1import { waitForAsync } from '@angular/core/testing';
2
3it('should fetch data asynchronously', waitForAsync(() => {
4  const fixture = TestBed.createComponent(AsyncHttpComponent);
5  const component = fixture.componentInstance;
6
7  component.fetchData();
8
9  fixture.whenStable().then(() => {
10    fixture.detectChanges();
11    expect(component.data).toBeTruthy();
12  });
13}));

La différence tient à l'intention: fakeAsync dit "avance l'horloge", tandis que waitForAsync dit "attends que toutes les tâches de fond soient terminées" - exactement ce que promet whenStable. Garde la règle simple en tête: un setTimeout dont tu connais la durée appelle fakeAsync, des tâches de fond dont tu ignores la durée appellent waitForAsync.

Tester les signals - la partie la plus simple d'Angular

Après les subtilités du temps, tu vas respirer: les signals se testent de façon triviale. Un signal est une boîte qui contient une valeur - tu la remplis, tu la lis, sans la moindre asynchronicité.

1import { signal, computed } from '@angular/core';
2
3it('should update and derive signal values', () => {
4  const count = signal(0);
5
6  count.set(5);
7  expect(count()).toBe(5);          // lecture: count()
8
9  const doubled = computed(() => count() * 2);
10  expect(doubled()).toBe(10);       // calcule automatiquement
11
12  count.set(3);
13  expect(doubled()).toBe(6);        // recalcule tout seul
14});

Tout tient en deux gestes: .set() pour écrire, et la paire de parenthèses pour lire. Ces parenthèses sont capitales: on lit un signal en l'appelant, donc l'assertion s'écrit expect(count()).toBe(5). Un signal n'expose ni propriété .value ni méthode .get(), et expect(count).toBe(5) comparerait la fonction elle-même au lieu de sa valeur. À côté de .set(), qui remplace la valeur, tu disposes aussi de .update(v => v + 1) lorsque la nouvelle valeur se déduit de l'ancienne. computed est encore plus agréable: tu ne le mets jamais à jour à la main, il se recalcule à partir des signals dont il dépend. Le test le prouve - tu changes count, et doubled a déjà la bonne valeur. Aucun tick, aucune attente.

Dans un composant, une étape s'ajoute: après avoir changé un signal, tu appelles detectChanges pour que le template se rafraîchisse.

1it('should update template when signal changes', () => {
2  component.count.set(42);
3  fixture.detectChanges();  // rafraichit la vue
4  expect(fixture.nativeElement.querySelector('.count').textContent).toContain('42');
5});
6
7it('should react to input signal changes', () => {
8  fixture.componentRef.setInput('name', 'Musashi');  // pour un signal input() on utilise setInput
9  fixture.detectChanges();
10  expect(fixture.nativeElement.textContent).toContain('Musashi');
11});

Retiens les deux formes, elles ne sont pas interchangeables. Un signal ordinaire se modifie par count.set(42) - le nom, le point, set, puis la valeur entre parenthèses. Un signal input(), lui, se définit par fixture.componentRef.setInput('name', 'Musashi'), car c'est le parent qui le fournit, jamais le composant lui-même. Après chaque changement, detectChanges recopie la valeur dans le template, et c'est seulement à ce moment-là que tu peux en vérifier le contenu.

Tester les effects

effect exécute du code à chaque changement des signals qu'il lit. Pour le tester, il te faut un contexte d'injection et un déclenchement manuel des effets via TestBed.flushEffects.

1it('should trigger effect on signal change', () => {
2  let effectCount = 0;
3
4  TestBed.runInInjectionContext(() => {
5    const count = signal(0);
6    effect(() => { count(); effectCount++; });  // effect lit count
7
8    TestBed.flushEffects();
9    expect(effectCount).toBe(1);   // premier passage
10
11    count.set(1);
12    TestBed.flushEffects();
13    expect(effectCount).toBe(2);   // effect a reagi au changement
14  });
15});

Un effect ne part pas à l'instant même - Angular les regroupe et les exécute à son propre rythme, d'où le TestBed.flushEffects() qui force la chose dans le test. Le compteur effectCount prouve l'essentiel: l'effect s'est exécuté une fois au démarrage, puis une seconde fois après le changement du signal. Si le nombre n'avait pas bougé, cela voudrait dire que l'effect ne suit pas count.

Bonus - tester les Observables avec les marbles (optionnel)

Il reste un outil de connaisseur, que tu peux garder pour plus tard: le marble testing de RxJS. Un Observable y est décrit par une frise de caractères, où chaque tiret vaut une unité de temps virtuel et chaque lettre une valeur émise. Le TestScheduler joue cette frise et compare ce qui sort avec ce que tu attendais.

1import { TestScheduler } from 'rxjs/testing';
2
3it('should filter even numbers', () => {
4  const scheduler = new TestScheduler((actual, expected) => {
5    expect(actual).toEqual(expected);
6  });
7
8  scheduler.run(({ cold, expectObservable }) => {
9    const source = cold('a-b-c-d-e|', { a: 1, b: 2, c: 3, d: 4, e: 5 });
10    const expected =    '--b---d--|';
11
12    const result = source.pipe(filter(n => n % 2 === 0));
13
14    expectObservable(result).toBe(expected, { b: 2, d: 4 });
15  });
16});

Lis la frise comme une portée de musique: a-b-c-d-e| émet cinq valeurs puis se termine sur le |, et --b---d--| annonce que seuls 2 et 4 survivront au filter. Le principe reste celui de fakeAsync: le temps est virtuel et entièrement sous ton contrôle. Réserve cette technique aux flux RxJS complexes - pour un simple setTimeout, fakeAsync et tick restent bien plus lisibles.

Retiens de cette leçon que dans un test, tu commandes le temps: fakeAsync avec tick quand le délai est connu, flush quand tu veux vider tous les timers en attente, waitForAsync quand des tâches de fond doivent d'abord se stabiliser. Et les signals sont l'étape facile de la route: tu définis avec .set(), tu appelles detectChanges, tu vérifies avec count().

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Que fait la fonction tick(1000) à l'intérieur de fakeAsync ?

  2. 2. Quand utilise-t-on flush() plutôt que tick() dans fakeAsync ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Complète le test asynchrone pour un composant avec debounce.

  • Tri vertical

    Ordonne les étapes d'un test de debounce dans fakeAsync (de haut en bas) :

  • Tri horizontal

    Ordonne l'appel qui définit la valeur d'un signal input dans un test (une seule ligne) :

  • Clique dans l'ordre

    Ordonne la ligne qui définit une nouvelle valeur de signal : count.set(42)

Articles utiles