Aller au contenu
Lecture : 0 %

Tests de composants dans le navigateur

Ce que jsdom ne peut pas vous dire, ce que coûte un vrai navigateur dans la boucle de test, et la répartition que je tiens entre tests Node, tests navigateur et tests de bout en bout.

Par Elias Varen8 min de lecture

Le test passe. Il ouvre la modale, vérifie que le bouton « Confirmer » est dans le document, clique dessus, vérifie l'appel. Vert depuis des mois.

En production, le bouton est sous le bandeau collant du bas de page sur un écran de 667 pixels de haut, et personne ne peut cliquer dessus. Le test n'a pas menti. Il a répondu à une question que vous ne posiez pas, en vérifiant qu'un nœud existe dans un arbre, alors que vous vouliez savoir si un humain peut confirmer sa commande.

jsdom n'est pas un navigateur, mais une implémentation du DOM en JavaScript, sans moteur de rendu. Tant que l'on teste de la logique, la différence est invisible. Dès que l'on teste une interface, elle est partout.

Ce que jsdom ne calcule pas

jsdom construit l'arbre, déclenche les événements, exécute vos scripts. Il ne fait ni mise en page ni peinture, avec des conséquences précises.

  • getBoundingClientRect() renvoie des zéros. Tout ce qui dépend d'une taille ou d'une position (menu qui se replie, infobulle qui bascule, liste virtualisée) tourne sur des valeurs fausses.
  • La feuille de style n'est pas appliquée comme dans un navigateur. Avec Tailwind, vos classes ne produisent rien, si bien qu'un élément en hidden reste « visible » et qu'un élément recouvert reste « cliquable ».
  • IntersectionObserver, ResizeObserver et matchMedia n'existent pas ou sont des coquilles. On les remplace par des faux, et on finit par tester ses faux.
  • Les événements sont synthétiques. Un clic simulé part directement sur le nœud, sans traverser la pile de ce qui se trouve réellement sous le pointeur. Le focus, l'ordre de tabulation, la sélection de texte et le défilement sont approximés.
  • Les API récentes arrivent tard ou jamais (popover, <dialog> en couche supérieure, container queries, :has() dans certains cas).

Le résultat se voit dans le fichier de configuration des tests. Quand le setup contient quarante lignes de faux matchMedia, de faux observateurs et de scrollIntoView vide, l'équipe a écrit un mauvais navigateur à la main. Chaque faux est une hypothèse sur le comportement du vrai, que personne ne revérifie. Et un test jsdom qui échoue sur « élément introuvable » se corrige souvent en ajoutant un faux de plus, ce qui retire du test la partie du composant qu'il était censé exercer.

Le même test, dans un vrai moteur

Le mode navigateur de Vitest garde le lanceur, la syntaxe et le mode watch que vous connaissez, mais exécute le fichier de test dans une page Chromium, Firefox ou WebKit pilotée par Playwright. Le composant est monté dans un vrai document, avec la vraie feuille de style, et les interactions passent par le protocole d'automatisation du navigateur. Un clic y est un déplacement de pointeur suivi d'un appui, à des coordonnées réelles.

tsx
import { expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import "../../app/globals.css";
import { ConfirmDialog } from "./confirm-dialog";

test("la confirmation reste atteignable sur un petit écran", async () => {
  const onConfirm = vi.fn();
  const screen = await render(<ConfirmDialog open onConfirm={onConfirm} />);

  const confirm = screen.getByRole("button", { name: "Confirmer" });
  await expect.element(confirm).toBeVisible();
  await confirm.click();

  expect(onConfirm).toHaveBeenCalledOnce();
});

Trois détails comptent plus que la syntaxe.

D'abord, l'import de la feuille de style globale. Sans lui, on revient à jsdom avec un navigateur autour. Avec lui, toBeVisible() répond à la vraie question, et click() échoue si un autre élément intercepte le pointeur. C'est exactement le bug du bandeau collant : le test attend que le bouton soit actionnable, n'y arrive pas, et indique quel élément le recouvre.

Ensuite, les assertions sont asynchrones et relancées. expect.element(...) interroge le DOM jusqu'à ce que la condition soit vraie ou que le timeout expire. On n'écrit plus de waitFor autour de chaque ligne, et les avertissements act disparaissent, parce que le rendu se fait dans la boucle d'événements d'un vrai navigateur et non dans une simulation à qui il faut dire quand vider ses files.

Enfin, la taille de la fenêtre fait partie du test. Elle se règle dans la configuration ou par test. Un composant responsive se teste à deux largeurs, et la largeur devient une donnée d'entrée au même titre que les props.

Démarrage, image de CI, instabilité : le prix du vrai moteur

Rien de tout cela n'est gratuit, et mieux vaut connaître la liste avant de la découvrir.

Le démarrage. Lancer un navigateur et servir les modules prend quelques secondes, là où un test Node démarre presque aussitôt. Sur une suite complète, l'écart par test est faible ; sur un fichier isolé en mode watch, on le sent. La boucle rouge-vert d'une fonction pure doit rester dans Node.

L'intégration continue. Il faut les binaires des navigateurs dans l'image, donc une image plus lourde et un cache à entretenir. La version du navigateur devient une dépendance qu'il faut épingler, sinon une mise à jour silencieuse change le rendu d'une police et casse trois tests un lundi matin.

L'instabilité, d'une autre nature. Un vrai moteur a des animations, des polices qui arrivent en retard, des transitions de 150 ms, et un clic pendant une transition peut tomber à côté. Les assertions relancées absorbent l'essentiel. Le reste se règle en désactivant les animations dans le contexte de test et en embarquant les polices localement, un travail à faire une fois, mais à faire.

L'isolation des modules. Remplacer un module par un faux fonctionne, avec plus de contraintes qu'en Node, parce que le navigateur charge de vrais modules ES. Si votre suite repose sur des dizaines de vi.mock profonds, la migration sera pénible. J'y vois un signal : un composant qui exige qu'on remplace six modules pour être monté a un problème de conception que jsdom permettait d'ignorer.

Les Server Components. Un composant serveur asynchrone ne se monte pas dans une page de test. Le mode navigateur couvre les composants client et les composants de présentation ; le reste se teste par ses fonctions (chargement de données, transformation) ou de bout en bout.

Le parallélisme. Chaque fichier s'exécute dans son propre contexte de page. La mémoire consommée par les workers monte vite, et sur un runner modeste il faut baisser le nombre de workers, ce qui fait fondre le gain de vitesse attendu.

Ce qui reste en Node, ce qui monte en bout en bout

Je ne remplace pas jsdom partout, seulement là où il ment.

Ce que vous vérifiezOù ça tournePourquoi
Fonction pure, réducteur, formatage, schéma de validationNode, sans DOMAucune interface en jeu, démarrage instantané
Hook sans mise en page (état, dérivation)Node, DOM simulé toléréLe DOM n'est qu'un support de montage
Composant avec focus, superposition, taille, défilement, visibilitéNavigateurLa réponse dépend du moteur de rendu
Composant qui s'appuie sur une API récente de la plateformeNavigateurjsdom ne l'a pas
Parcours à plusieurs pages, cookies, redirections, rendu serveurBout en bout sur l'application lancéeLe composant isolé n'existe pas à cette échelle

La frontière entre la deuxième et la troisième ligne est celle qui demande du jugement. Mon critère est le suivant : si, pour faire passer le test, il faut écrire un faux d'une API du navigateur, le test a sa place dans le navigateur.

Dans notre monorepo, les primitives d'interface partagées (modale, sélecteur, notification) sont testées dans le navigateur, parce que leur valeur tient entièrement au focus, aux couches et au clavier. Les pages, elles, sont couvertes par quelques parcours de bout en bout. Entre les deux, la plupart des composants métier n'ont pas de test de composant du tout. Ils affichent des données, et c'est la fonction qui prépare ces données que je teste.

Garder jsdom, ou basculer un dossier

jsdom, ou aucun DOM, reste le bon choix dans plusieurs situations :

  • la suite teste surtout de la logique, et les composants sont de l'affichage sans interaction fine ;
  • la CI tourne sur des runners où installer un navigateur est une négociation ;
  • une suite de bout en bout rapide et fiable couvre déjà les interactions critiques, et un deuxième étage de navigateur ferait doublon ;
  • personne dans l'équipe ne prendra en charge l'instabilité des premières semaines.

À l'inverse, un dossier mérite de passer dans le navigateur quand au moins deux de ces lignes s'appliquent :

  • le fichier de setup contient des faux d'API navigateur que quelqu'un a dû débugger ;
  • un bug d'interface est passé en production alors que le test du composant était vert ;
  • les tests contiennent des waitFor et des act que plus personne ne sait justifier ;
  • le composant utilise le focus, une couche supérieure, une mesure de taille ou une API que jsdom n'implémente pas ;
  • l'équipe passe plus de temps à comprendre pourquoi le test échoue qu'à comprendre pourquoi le composant échoue.

Reste une question à poser test par test : si ce test passe, que sait-on de plus sur ce que voit l'utilisateur ? Quand la réponse est « rien », changer de moteur d'exécution ne corrigera pas le test, qu'il faut alors réécrire ou supprimer.

Tests de composants dans le navigateur · Deepstack