~9 min de lecture

fakeAsync est mort en zoneless : les 4 pièges de son remplaçant Vitest

TL;DR

En zoneless, fakeAsync, tick et flush plantent au runtime : ils exigent les patches zone-testing que ton projet n'embarque plus. Le remplaçant, ce sont les fake timers de ton runner : vi.useFakeTimers() avant le TestBed.createComponent, await vi.advanceTimersByTimeAsync(ms) pour avancer l'horloge, vi.useRealTimers() dans un afterEach. Quatre pièges guettent : des fake timers posés trop tard ratent les timers déjà armés, la variante synchrone gèle les chaînes de promesses, une horloge non restaurée contamine tout le reste du fichier, et le plus contre-intuitif - la passe de rendu d'un signal.set() est elle-même un timer, donc un whenStable() posé après un set() non rendu bloque tant que l'horloge est gelée.

Tu viens de migrer ton app en zoneless, ou tu démarres un projet neuf en Angular 21+ où c'est le défaut. Tu lances la suite. Tous les tests de ta barre de recherche débouncée explosent avec le même message :

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/testing

Premier réflexe : ajouter l'import qui manque. C'est parfois le bon correctif dans un projet resté en Zone.js (on y revient en fin d'article). Mais dans un projet zoneless, cet import n'a rien à réparer : le message te dit que tout ton outillage de test du temps vient de disparaître avec Zone.js. Et le remplaçant ne s'utilise pas du tout pareil.


Pourquoi fakeAsync ne peut plus marcher

fakeAsync n'a jamais été de la magie Angular : c'est de la magie Zone.js. Le helper enveloppe ton test dans une zone spéciale qui intercepte setTimeout, setInterval et les promesses, puis tick(300) fait avancer cette horloge virtuelle. Toute la mécanique repose sur les patches globaux que zone.js/testing installe au chargement.

En zoneless, ces patches n'existent plus. Rien à intercepter : fakeAsync lève l'erreur ci-dessus au runtime. Pas une dépréciation douce avec un warning : un mur.

Le réflexe "test Angular + timers = fakeAsync" date des projets Zone.js. Il ne survit pas au zoneless, et c'est là que la migration fait le plus mal : ton code applicatif passe souvent sans douleur, ta suite de tests casse partout où il y a du temps.

Un point de vocabulaire : ce message n'est pas un test rouge. Un test rouge compare un comportement attendu à un comportement observé ; ici, le test échouera toujours, implémentation juste ou fausse. C'est une erreur de harnais ; elle se corrige dans le harnais, pas dans le code testé.


Le composant qu'on va tester

Une barre de recherche débouncée, le cas d'école : 300 ms de silence avant de propager la valeur.

// search-box.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';

@Component({
  selector: 'app-search-box',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input (input)="type($any($event.target).value)" placeholder="Rechercher" />
    <p>{{ debounced() }}</p>
  `,
})
export class SearchBox {
  private readonly raw$ = new Subject<string>();
  readonly debounced = signal('');

  constructor() {
    this.raw$.pipe(debounceTime(300)).subscribe((v) => this.debounced.set(v));
  }

  type(value: string) {
    this.raw$.next(value);
  }
}

L'ancien test, celui qui plante désormais :

// AVANT - plante au runtime en zoneless
it('propage la valeur après 300ms de silence', fakeAsync(() => {
  const fixture = TestBed.createComponent(SearchBox);
  fixture.detectChanges();

  fixture.componentInstance.type('ngrx');
  tick(300);

  expect(fixture.componentInstance.debounced()).toBe('ngrx');
}));

Le remplaçant : les fake timers du runner

La bonne nouvelle : ton runner sait déjà faire ça. Vitest (le défaut des projets Angular 21+) embarque ses propres fake timers, qui remplacent setTimeout et setInterval par une horloge virtuelle que tu avances à la main.

Et ça suffit pour les opérateurs RxJS temporels : debounceTime, delay, timer ou un retry avec backoff s'appuient tous, via leur scheduler par défaut, sur les timers natifs (setInterval en RxJS 7). Pilote l'horloge du runner et tu pilotes le debounce. Pas besoin de sortir le TestScheduler de RxJS et ses diagrammes ASCII (les marble tests) pour ça.

// APRÈS - le même test, en zoneless
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it, vi } from 'vitest';

describe('SearchBox', () => {
  afterEach(() => vi.useRealTimers());

  it('propage la valeur après 300ms de silence', async () => {
    vi.useFakeTimers();
    const fixture = TestBed.createComponent(SearchBox);
    fixture.detectChanges();

    fixture.componentInstance.type('ng');
    await vi.advanceTimersByTimeAsync(100);
    fixture.componentInstance.type('ngrx');

    await vi.advanceTimersByTimeAsync(299);
    expect(fixture.componentInstance.debounced()).toBe('');

    await vi.advanceTimersByTimeAsync(1);
    expect(fixture.componentInstance.debounced()).toBe('ngrx');
  });
});

Remarque au passage : ce test est plus précis que l'ancien. À 299 ms après la dernière frappe, il vérifie qu'il ne s'est rien passé : ni 'ngrx', ni surtout 'ng', dont l'émission a été annulée par la seconde frappe. Remplace debounceTime(300) par delay(300), qui n'avale rien : ce test échoue, là où le tick(300) sec d'origine passait sur les deux implémentations. Un test de debounce qui ne sépare pas les frappes dans le temps ne teste pas le debounce.

La recette tient en trois lignes : vi.useFakeTimers() avant le premier TestBed.createComponent, await vi.advanceTimersByTimeAsync(ms) avant chaque assertion qui dépend du temps, vi.useRealTimers() dans un afterEach. Chacune a son piège, et le rendu zoneless en cache un quatrième. Dans l'ordre.


Piège 1 : des fake timers posés trop tard ratent les timers déjà armés

Prends un toast qui se ferme seul au bout de 3 secondes :

// toast.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

@Component({
  selector: 'app-toast',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (visible()) {
      <p role="status">Sauvegardé</p>
    }
  `,
})
export class Toast {
  readonly visible = signal(true);

  constructor() {
    setTimeout(() => this.visible.set(false), 3000);
  }
}

Et le test écrit dans le mauvais ordre :

it('disparaît au bout de 3 secondes', async () => {
  const fixture = TestBed.createComponent(Toast); // le timer est armé ICI
  fixture.detectChanges();

  vi.useFakeTimers(); // trop tard
  await vi.advanceTimersByTimeAsync(3000);

  expect(fixture.componentInstance.visible()).toBe(false); // FAIL
});

Le setTimeout du constructeur a été armé sur l'horloge réelle, avant l'installation des fake timers. Ton advanceTimersByTimeAsync(3000) avance une horloge virtuelle sur laquelle aucun timer n'est posé : le toast reste affiché, l'assertion échoue. Vérifié sur un projet Angular 22 zoneless : après l'avance de 3000 ms virtuelles, visible() vaut toujours true.

Et c'est le bon scénario. Le mauvais : une vraie attente traîne ailleurs, le timer réel finit par se déclencher pendant un await, et ton test devient vert ou rouge selon la charge de la machine. Un flaky test est né.

La règle : les fake timers se posent avant le premier TestBed.createComponent. Tout ce qu'un constructeur, un ngOnInit ou un effect arme comme timer doit tomber sur l'horloge virtuelle.


Piège 2 : la variante synchrone gèle tes promesses

Vitest expose deux familles d'avancement : vi.advanceTimersByTime(ms) en synchrone, vi.advanceTimersByTimeAsync(ms) en asynchrone. La différence n'est pas cosmétique.

La variante synchrone déclenche les callbacks de timers, mais ne laisse jamais la main à la boucle d'événements : les microtasks (les .then() de promesses) qui s'intercalent entre deux timers ne s'exécutent pas. Or ton code réel enchaîne les deux en permanence : un debounce déclenche un fetch, qui arme un timer de retry.

Démonstration minimale, une chaîne timer, puis promesse, puis timer :

it('sync vs async', async () => {
  vi.useFakeTimers();
  let done = false;

  setTimeout(() => {
    Promise.resolve().then(() => {
      setTimeout(() => (done = true), 100);
    });
  }, 100);

  vi.advanceTimersByTime(300);
  console.log(done); // false : le second timer n'a jamais été armé

  await vi.runAllTimersAsync();
  console.log(done); // true
});

Avec la variante synchrone, le premier timer se déclenche, mais la promesse intercalée reste en attente : le second setTimeout n'est même pas armé à la fin de l'avance. La variante asynchrone, elle, draine les microtasks entre chaque timer et déroule toute la chaîne.

La règle : toujours les variantes async (advanceTimersByTimeAsync, runAllTimersAsync, runOnlyPendingTimersAsync), donc toujours un await devant. La variante synchrone marche très bien sur les cas simples, puis casse silencieusement le jour où une promesse s'invite dans la chaîne.


Piège 3 : l'horloge fantôme qui contamine la suite

vi.useFakeTimers() ne s'arrête pas à la fin du test : l'horloge virtuelle reste installée pour tous les tests suivants du fichier tant que personne ne la retire. Le test d'après, qui n'a rien demandé, en hérite. S'il attend un findByText de Testing Library, le timeout interne de celui-ci est gelé lui aussi : la promesse ne se règle jamais, et c'est le timeout du runner qui finit par tuer le test - un test qui ne parle même pas de timers, avec une erreur qui pointe sur le mauvais suspect. S'il s'appuie sur whenStable(), c'est plus sournois : suivant ce qui est en vol, la promesse se résout immédiatement alors que le rendu n'a pas eu lieu (faux vert), ou ne se résout jamais (le piège 4 explique pourquoi). Bon courage pour le debug.

La parade est mécanique :

afterEach(() => vi.useRealTimers());

Dans chaque describe qui pose des fake timers, sans condition. Le coût est nul et ça élimine une classe entière de tests flaky.


Piège 4 : la passe de rendu est elle-même un timer

Tout l'asynchrone d'un test zoneless n'est pas affaire de timers. Les effects planifiés, un resource() en vol, un rendu en attente relèvent de la stabilité de l'application, et l'outil pour l'attendre, c'est await fixture.whenStable(). C'est la recommandation du guide de migration, la bonne par défaut, resource() compris. Un await Promise.resolve() n'est pas un substitut : il ne draine qu'un tour de microtasks et n'attend pas la stabilité de l'app ; le jour où deux tours sont nécessaires, le test casse.

Mais sous fake timers, la règle s'inverse, et c'est le point le plus contre-intuitif de cet article : en zoneless, la passe de rendu déclenchée par un signal.set() est elle-même planifiée via un timer. Ton avance déclenche le callback du debounce, le set() planifie le rendu... sur l'horloge gelée : l'app ne redevient jamais stable et le whenStable() posé juste après ne se résout pas. Vérifié sur le SearchBox ci-dessus : après l'avance de 300 ms, le signal vaut 'ngrx', le DOM est vide, et le test meurt en timeout.

Deux sorties, vérifiées :

// 1. flush synchrone du rendu planifié
await vi.advanceTimersByTimeAsync(300);
fixture.detectChanges();

// 2. ou une avance de plus, qui libère la passe de rendu
await vi.advanceTimersByTimeAsync(300);
await vi.runOnlyPendingTimersAsync();
await fixture.whenStable(); // se résout immédiatement

C'est pour ça que cet article appelle fixture.detectChanges() là où le guide de migration et la doctrine Page Model préfèrent whenStable() : leurs conseils valent à horloge réelle. Si ton Page Model termine chaque action par un whenStable(), ne le réutilise pas tel quel sous fake timers : c'est la combinaison qui bloque.

Dernier contresens à évacuer : whenStable() n'avance pas les timers. Un debounce ou un setTimeout en attente ne rend pas l'app instable ; sans avance d'horloge, whenStable() se résout immédiatement et ton assertion lit l'état d'avant.


Et si ton projet est encore en Zone.js ?

Alors fakeAsync et tick restent légitimes, à une condition : que ton setup installe les patches zone pour ton runner. Sous Karma/Jasmine, import 'zone.js/testing' suffit. Sous Vitest, non : il faut @analogjs/vitest-angular/setup-zone et les describe/it globaux - les typings d'Angular annotent fakeAsync d'un « cannot be used with the Vitest test runner » : ce setup est le pont. Pas besoin de migrer tes tests avant de migrer ton runtime.

Une seule interdiction absolue : ne jamais mélanger fakeAsync et vi.useFakeTimers() dans un même test. Le mélange ne se signale jamais comme tel : selon l'ordre d'installation des deux patches, tick() et flush() marchent encore ou deviennent des no-ops silencieux, sans qu'aucune erreur ne le dénonce. Quand ça casse, l'échec ressemble à un bug du code testé. Un seul régime d'horloge par test, sans exception.

Et pour savoir dans quel régime tu es, ne regarde ni les specs voisines (un repo en migration charrie d'anciens fakeAsync cassés), ni le package.json (un projet en migration garde souvent zone.js alors que ses tests tournent sans les patches). Le contrôle fiable tient en une ligne : écris un test fakeAsync vide et lance-le. Vert : tu es dans le monde fakeAsync. Erreur : il est mort chez toi, quoi qu'il reste dans ton package.json.


Récap actionnable

Situation Zone.js Zoneless
Debounce, setTimeout, retry fakeAsync + tick(ms) vi.useFakeTimers() + await vi.advanceTimersByTimeAsync(ms)
Drainer tous les timers flush() await vi.runAllTimersAsync()
Effects, resource(), rendu await fixture.whenStable() idem : await fixture.whenStable()
Horloge absolue (Date.now()) vi.setSystemTime(...) idem : vi.setSystemTime(...)

La colonne zoneless suppose Vitest ; la colonne Zone.js suppose des patches zone installés pour ton runner (d'office sous Karma/Jasmine, via @analogjs/vitest-angular/setup-zone sous Vitest). Un projet sous Karma n'a pas les vi.* ; la dernière ligne ne s'y applique pas telle quelle. Et la ligne whenStable() suppose l'horloge réelle : sous fake timers, déclenche d'abord le rendu (cf. piège 4).

Ta checklist zoneless :

  1. vi.useFakeTimers() avant le premier TestBed.createComponent, jamais après.
  2. Toujours les variantes async de l'avancement d'horloge, toujours avec await.
  3. afterEach(() => vi.useRealTimers()) dans chaque describe concerné, sans exception.
  4. Après une avance, déclenche le rendu (detectChanges(), ou une avance de plus) avant tout whenStable() : sous horloge gelée, la passe de rendu est elle-même un timer.
  5. Une erreur "zone-testing.js is needed" n'est pas un test rouge : c'est ton harnais qui te parle.

La vraie leçon n'a rien d'Angular : vi.useFakeTimers s'utilise pareil dans un backend Node ou un monorepo React. Ce que la migration t'a coûté en réflexes, elle te le rembourse en outillage standard, celui de tous tes prochains projets TypeScript.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.