~9 min de lecture

Tes tests @defer sont verts par accident : whenStable n'attend pas tes triggers

TL;DR

Depuis Angular 17.1.2, le TestBed exécute les blocs @defer en mode Playthrough : les triggers se déclenchent comme en prod. Sauf que whenStable() n'attend ni on idle ni on timer (des callbacks d'horloge que la stabilité de la fixture ne prend pas en compte), et que on viewport lève une ReferenceError: IntersectionObserver is not defined dans jsdom. Résultat : des assertions qui dépendent de la vitesse de la machine, et des blocs qui ne se rendent jamais. La sortie déterministe : deferBlockBehavior: DeferBlockBehavior.Manual, puis fixture.getDeferBlocks() et render(DeferBlockState.Complete) pour piloter chaque état à la main, y compris le @error que tu ne testes probablement jamais. Et si c'est le trigger d'horloge lui-même que tu veux vérifier : fakeAsync en zone.js, les fake timers du runner en zoneless.

Tu as suivi les bonnes pratiques : ton dashboard charge son graphique lourd derrière un @defer (on idle), le bundle initial a fondu, tout le monde est content. Tu trouveras le détail des triggers dans le guide des blocs @defer ; ici on s'occupe de ce qui se passe quand tu écris le test :

it('affiche le graphique', async () => {
  const fixture = TestBed.createComponent(Dashboard);
  fixture.detectChanges();
  await fixture.whenStable();

  expect(fixture.nativeElement.textContent).toContain('Revenue 2026');
});

Rouge. Le DOM ne contient que ton @placeholder. Tu ajoutes un detectChanges(), toujours rouge. Un await new Promise(r => setTimeout(r, 100)) trouvé sur Stack Overflow, vert. Tu pushes, et trois semaines plus tard le test clignote en CI.

Ce test n'est pas malchanceux. Il est construit sur une course critique (une race condition) que rien dans l'API classique du TestBed ne te laisse gagner proprement. Voyons pourquoi, puis comment reprendre la main.

Ce que le TestBed fait vraiment de tes @defer

Le comportement des blocs @defer en test est piloté par une option du TestBed, deferBlockBehavior, qui a deux valeurs :

  • DeferBlockBehavior.Playthrough : les triggers se comportent comme dans un navigateur. on timer(2s) arme un vrai timer, on interaction écoute de vrais clics, when condition réagit à ta condition.
  • DeferBlockBehavior.Manual : aucun trigger ne se déclenche, jamais. C'est toi qui fais avancer chaque bloc d'état en état, à la main.

Petit point d'histoire qui a son importance si tu tombes sur de vieux tutos : au lancement de la 17.0, le défaut était Manual. Le patch 17.1.2 (31 janvier 2024) a achevé la bascule vers Playthrough, et c'est toujours le défaut en Angular 22. Un article ou une réponse d'avant février 2024 qui affirme "les blocs defer ne se rendent pas en test par défaut" décrit un comportement qui n'existe plus.

Playthrough par défaut, ça sonne bien : les tests reproduisent la prod. Le problème, c'est que "comme dans un navigateur" inclut aussi le temps qui passe et les APIs du navigateur. Deux choses que ton environnement de test gère mal.

Course numéro 1 : whenStable ne suit pas les horloges

Vérifions avec un composant minimal (Angular 22.0.7, Vitest 4, jsdom, zoneless) :

@Component({
  template: `
    @defer (on timer(10ms)) {
      <p>timer content</p>
    } @placeholder {
      <p>timer placeholder</p>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class TimerHost {}

it('whenStable seul ne suffit pas', async () => {
  const fixture = TestBed.createComponent(TimerHost);
  fixture.detectChanges();
  await fixture.whenStable();
  fixture.detectChanges();

  // "timer placeholder" : les 10ms sont censées être passées, et pourtant rien
  console.log(fixture.nativeElement.textContent);
});

Après whenStable(), le DOM affiche encore le placeholder. Le même test avec un await new Promise(r => setTimeout(r, 100)) avant le whenStable affiche le contenu. Même constat avec on idle : whenStable seul laisse le placeholder, une vraie attente fait apparaître le contenu.

Autrement dit : le déclenchement de on timer et on idle repose sur des callbacks d'horloge que la stabilité de la fixture ne prend pas en compte. Ton assertion arrive soit avant, soit après le rendu différé, selon ce que tu as attendu entre-temps et la vitesse de la machine. C'est la définition d'un test flaky, et le setTimeout(100) magique ne fait que déplacer le seuil de la course.

Nuance importante : when maCondition() n'a pas ce problème. Vérification faite, passer un signal à true puis detectChanges() + whenStable() rend le contenu de façon fiable, sans attente réelle. Une fois le trigger parti, le chargement du bloc est bien attendu par whenStable ; ce que la stabilité ne suit pas, c'est l'horloge qui précède le déclenchement. Le critère qui sépare les deux familles : un trigger est fiable quand c'est ton test qui le provoque, avec un déclenchement évalué pendant la change detection (when, on immediate) ou un événement DOM que tu dispatches toi-même (on interaction, on hover). Il crée une course quand il attend une horloge de l'environnement (on timer, on idle).

Course numéro 2 : on viewport n'existe pas dans jsdom

Le trigger le plus courant en vrai, c'est on viewport : charge quand le bloc devient visible. Il est implémenté avec IntersectionObserver. Or jsdom, l'environnement par défaut de la plupart des configs Vitest pour Angular, ne fournit pas cette API. En mode Playthrough, voilà ce qui sort (Angular 22.0.7) :

ERROR ReferenceError: IntersectionObserver is not defined
    at createIntersectionObserver (.../core/fesm2022/_debug_node-chunk.mjs:2165:3)

Et attention : ce n'est pas un échec de test. L'erreur est avalée par le gestionnaire d'erreurs et part dans la sortie du runner, ton it continue, le placeholder reste affiché. Ici, même le setTimeout(100) magique de l'intro ne verdit rien : le trigger ne part jamais. Si ton assertion porte sur le placeholder, le test est vert avec une ReferenceError dans les logs. Si elle porte sur le contenu, il est rouge sans que le message d'erreur pointe vers la vraie cause.

Tu peux stubber IntersectionObserver globalement ; c'est d'ailleurs ce que recommande le guide de config Vitest cité plus haut, et c'est le bon geste quand c'est ton propre code qui observe le viewport. Mais pour un @defer, tu serais en train de mocker une API navigateur pour faire semblant de dérouler un comportement de prod dans un DOM simulé. Il y a plus simple, et c'est prévu par le framework.

La solution : piloter les états à la main

Le TestBed expose une API dédiée : tu coupes les triggers avec DeferBlockBehavior.Manual, puis tu fais transitionner chaque bloc vers l'état que tu veux tester avec DeferBlockState. Quatre états : Placeholder, Loading, Complete, Error. Reprenons le Dashboard, cette fois derrière on viewport : en Manual, le trigger n'a de toute façon plus d'importance, aucun ne se déclenche.

import {
  DeferBlockBehavior,
  DeferBlockState,
  TestBed,
} from '@angular/core/testing';

@Component({
  template: `
    @defer (on viewport) {
      <app-revenue-chart />
    } @placeholder {
      <p>Scroll pour voir le graphique</p>
    } @loading {
      <p>Chargement...</p>
    } @error {
      <p>Impossible de charger le graphique</p>
    }
  `,
  imports: [RevenueChart],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class Dashboard {}

beforeEach(() => {
  TestBed.configureTestingModule({
    deferBlockBehavior: DeferBlockBehavior.Manual,
  });
});

it('affiche le graphique une fois chargé', async () => {
  const fixture = TestBed.createComponent(Dashboard);
  fixture.detectChanges();

  const [chartBlock] = await fixture.getDeferBlocks();
  await chartBlock.render(DeferBlockState.Complete);

  expect(fixture.nativeElement.textContent).toContain('Revenue 2026');
});

fixture.getDeferBlocks() retourne un DeferBlockFixture par bloc @defer de premier niveau actuellement instancié dans la vue :

  • une branche @if non rendue n'y expose pas le sien ;
  • un bloc imbriqué dans un autre @defer n'y figure pas : il s'obtient par parentBlock.getDeferBlocks(), une fois le parent rendu en Complete ;
  • un bloc situé dans le template d'un composant enfant, lui, y figure.

Quant à render(...), seul l'appel avec DeferBlockState.Complete déclenche réellement le chargement des dépendances du bloc avant de rendre le contenu : ton RevenueChart est bien instancié, pas simulé. Les autres états s'affichent sans rien charger, ce qui est exactement ce que tu veux pour un @error.

Zéro timer, zéro IntersectionObserver, zéro course : le test dit explicitement quel état du bloc il teste, et le résultat ne dépend plus de ce qui a été attendu ni de la machine qui fait tourner la suite.

Et surtout, tu peux enfin tester les états que Playthrough rend quasi inaccessibles :

it('affiche le fallback si le chunk ne charge pas', async () => {
  const fixture = TestBed.createComponent(Dashboard);
  fixture.detectChanges();

  const [chartBlock] = await fixture.getDeferBlocks();
  await chartBlock.render(DeferBlockState.Error);

  expect(fixture.nativeElement.textContent)
    .toContain('Impossible de charger le graphique');
});

Sois honnête : ton @error actuel, il est testé comment ? En Playthrough, il faudrait faire échouer le chargement d'un chunk pendant le test. Avec render(DeferBlockState.Error), c'est deux lignes, et RevenueChart n'est jamais construit. Même chose pour @loading, coincé entre placeholder et contenu pendant quelques millisecondes en conditions réelles.

Les trois pièges du mode Manual

Manual coupe TOUS les triggers, when compris. C'est le miroir de la nuance vue plus haut. Ce test-là ne passera jamais :

// deferBlockBehavior: DeferBlockBehavior.Manual
it('affiche le contenu quand ready passe à true', async () => {
  const fixture = TestBed.createComponent(WhenHost);
  fixture.detectChanges();

  fixture.componentInstance.ready.set(true);
  fixture.detectChanges();
  await fixture.whenStable();

  // toujours "when placeholder" : en Manual, même when est ignoré
  expect(fixture.nativeElement.textContent).toContain('when content');
});

Passer le signal à true ne fait rien : en Manual, la seule façon de faire avancer un bloc, c'est render(...). Si tu veux vérifier que ta condition déclenche bien le rendu, ce test-là se fait en Playthrough.

Les transitions ne vont que vers l'avant. Après un render(DeferBlockState.Complete), un render(DeferBlockState.Placeholder) ne fait rien : pas d'erreur, et le contenu reste affiché. Une réserve : render lève une erreur si le bloc n'a pas de branche pour l'état demandé (un render(DeferBlockState.Loading) sans @loading dans le template échoue avec un message explicite), et cette vérification passe avant celle de l'état courant. Ne cherche pas à "rembobiner" un bloc dans un même test ; crée une nouvelle fixture par scénario.

Le comportement se choisit test par test. deferBlockBehavior se passe à configureTestingModule, qui repart de zéro à chaque it : c'est le beforeEach qui étend le choix à tout le describe, et un test sans configuration retombe sur le défaut Playthrough. Rien ne t'empêche donc d'avoir un describe en Manual pour balayer les quatre états et un autre en Playthrough pour le test de câblage du trigger.

Alors, Playthrough ou Manual ?

Les deux, mais pas pour la même chose.

Playthrough teste le câblage (le trigger déclenche-t-il bien le chargement ?), avec les triggers que ton test provoque directement :

// Playthrough (le défaut) : le clic déclenche réellement le chargement
it('charge le graphique au clic', async () => {
  const fixture = TestBed.createComponent(InteractionHost);
  fixture.detectChanges();

  fixture.nativeElement.querySelector('button').click();
  await fixture.whenStable();
  fixture.detectChanges();

  expect(fixture.nativeElement.textContent).toContain('interaction content');
});

Manual teste les états : qu'affiche chaque branche du bloc une fois atteinte ? C'est lui qui rend accessibles @loading et @error, et qui élimine les courses.

Reste le cas où tu tiens à vérifier le trigger lui-même, un on timer ou un on idle. Le remède dépend de ton régime de zone. En zone.js, fakeAsync + tick font l'affaire, comme pour n'importe quel timer. En zoneless, le défaut depuis Angular 21 et le régime de tous les exemples de cet article, zone.js/testing n'est plus chargé, donc fakeAsync n'est pas disponible ; ce sont les fake timers du runner qui prennent le relais :

it('rend le contenu après 2s simulées', async () => {
  vi.useFakeTimers();
  const fixture = TestBed.createComponent(TimerHost); // @defer (on timer(2000ms))
  fixture.detectChanges();

  await vi.advanceTimersByTimeAsync(2500);
  vi.useRealTimers();
  await fixture.whenStable();
  fixture.detectChanges();

  expect(fixture.nativeElement.textContent).toContain('timer content');
});

Ça marche aussi pour on idle, parce que jsdom n'a pas requestIdleCallback et qu'Angular retombe alors sur un setTimeout, que les fake timers contrôlent. on viewport reste le seul hors d'atteinte, et pour une autre raison : ce n'est pas une horloge à avancer, c'est une API qui n'existe pas.

Récap actionnable

  • Depuis Angular 17.1.2, le défaut du TestBed est Playthrough : tes blocs @defer se déclenchent en test comme en prod. Les tutos d'avant février 2024 qui disent l'inverse sont périmés.
  • whenStable() n'attend ni on idle ni on timer. Si ton test contient un setTimeout magique avant une assertion sur du contenu différé, il est flaky par construction. Pour rendre ces deux triggers déterministes : fakeAsync + tick en zone.js, les fake timers du runner (vi.useFakeTimers + advanceTimersByTimeAsync) en zoneless.
  • on viewport + jsdom = ReferenceError: IntersectionObserver is not defined, avalée par le gestionnaire d'erreurs. Vérifie la sortie de tes tests : elle y est peut-être déjà.
  • Pour tester le contenu et les états d'un bloc : DeferBlockBehavior.Manual + fixture.getDeferBlocks() + render(DeferBlockState.Complete), .Loading ou .Error selon l'état visé. Déterministe, et seul Complete charge réellement les dépendances.
  • Pour tester qu'un trigger se déclenche : Playthrough avec when ou on interaction, que ton test contrôle directement.
  • En Manual, when est ignoré comme les autres triggers, et les transitions d'état ne se rembobinent pas : une fixture par scénario.

Ton @defer a réduit ton bundle. Tes tests, eux, doivent prouver autre chose : que chaque état visible par l'utilisateur (placeholder, loading, contenu, erreur) affiche bien ce que tu crois qu'il affiche. Quatre états, quatre tests, zéro course.

📧 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.