📐 Construire une app en JS
46. La pyramide de tests

La pyramide de tests

Faisons l'inventaire de ce que nous exécutons aujourd'hui.

 ✓ core/tests/values.test.js              (15 tests)   ~4 ms
 ✓ core/tests/book.test.js                (14 tests)  ~30 ms
 ✓ core/tests/cancelBooking.test.js        (5 tests)  ~10 ms
 ✓ infra/MemoryBookingRepository.test.js   (9 tests)   ~6 ms
 ✓ infra/SQLBookingRepository.integration  (9 tests)  ~2 s

Cinquante-deux tests. Et pas une seule ligne testée dans l'interface.

Est-ce grave ? Que faut-il ajouter ? Et surtout : jusqu'où ?

La question du bon niveau

La pyramide des tests est un schéma que tout le monde a vu : beaucoup de tests unitaires en bas, quelques tests d'intégration au milieu, très peu de tests de bout en bout au sommet.

Elle est juste, mais elle est mal utilisée, parce qu'elle décrit une conséquence et qu'on la lit comme une consigne. On voit alors des équipes découper artificiellement leur code pour produire des tests unitaires, et tester des fonctions privées qui n'intéressent personne.

La bonne formulation est celle-ci :

Testez au niveau le plus haut où le test reste rapide, stable et lisible.

Appliquons-la à ce que nous avons construit.

Les règles puresStay.overlaps, Occupancy.of, canHost — se testent sans rien monter, en une milliseconde, avec tous leurs cas limites. C'est le meilleur rapport qualité-prix du projet : le chapitre 21 a fait passer la suite de 5 à 22 tests sans que le temps d'exécution ne change.

Les commandes se testent à travers app.run, avec les dépendances en mémoire. Un test comme "un vacancier ne peut pas réserver un logement déjà pris" traverse la commande, les valeurs, le contexte et le repository. Unitaire ? Intégration ? La question n'a aucun intérêt. Il est rapide, il est lisible par l'expert métier, et il échoue pour une raison compréhensible.

Les adaptateurs se testent par contrat (chapitre 38), contre une vraie base.

Ce découpage n'a pas été décidé : il découle de l'architecture. Un domaine pur produit naturellement des tests rapides ; des ports produisent naturellement des tests de contrat. Une base de code où tout est mélangé produit naturellement des tests lents — et c'est le vrai symptôme.

Nous n'avons jamais écrit un seul mock

Prenez conscience de ceci, car c'est inhabituel : cinquante-deux tests, zéro vi.mock, zéro jest.spyOn, zéro stub.

Nous avons des doubles, bien sûr : MemoryBookingRepository, testDateProvider, MemoryNotifications. Mais ce ne sont pas des simulacres, ce sont des implémentations. Elles ont un comportement, un contrat, et pour deux d'entre elles une suite de tests qui le vérifie.

La différence est loin d'être théorique.

// ce que nous ne faisons pas
vi.mock("../infra/SQLBookingRepository", () => ({
  SQLBookingRepository: vi.fn(() => ({ findOverlapping: vi.fn(() => []) })),
}));

Ce mock affirme que findOverlapping retourne un tableau vide. Il l'affirmera encore le jour où la vraie méthode retournera null, ou lèvera une erreur, ou changera de nom. Un mock ne vérifie jamais qu'il ressemble à ce qu'il remplace : il gèle votre compréhension du code, à l'instant où vous l'avez écrit.

Et il connaît le chemin du module, donc la structure des fichiers. Déplacez le fichier, le test casse sans qu'aucun comportement n'ait changé.

Nos doubles, eux, sont injectés (chapitre 12). Ils ne connaissent aucun chemin, aucune structure. Et le chapitre 38 vérifie qu'ils se comportent comme leurs équivalents réels.

Un double injecté est une implémentation. Un module remplacé à chaud est une supposition.

Il reste des cas légitimes pour un mock : vérifier qu'un appel a bien eu lieu avec certains arguments, sur une frontière que vous ne contrôlez pas. Mais remarquez que même là, nous ne l'avons pas fait : MemoryNotifications enregistre ce qu'on lui envoie dans un tableau sent, et le test l'inspecte. C'est un espion, écrit en trois lignes, sans bibliothèque.

Tester des composants

Ce qui nous manque, ce sont les composants. Deux qui méritent des tests, et pas plus.

Le critère n'est pas "tous les composants" : c'est les composants qui contiennent une décision.

Accommodation affiche des propriétés. Le tester, c'est écrire "quand je passe name, il affiche name" — un test qui ne fait que répéter le code, et qui cassera à la première retouche du graphiste sans jamais avoir attrapé un bug.

LoginPage (chapitre 31) décide : elle empêche une double soumission, affiche l'erreur, désactive le bouton. HomePage (chapitre 44) décide : quatre états, quatre écrans.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
 
it("n'envoie qu'une seule fois le formulaire, même si on clique deux fois", async () => {
  const onSubmit = vi.fn(() => new Promise(() => {}));   // ne se résout jamais
  render(<LoginPage onSubmit={onSubmit} pending={false} />);
 
  await userEvent.type(screen.getByLabelText(/adresse email/i), "faketenant@mail.com");
  await userEvent.type(screen.getByLabelText(/mot de passe/i), "secret");
 
  const button = screen.getByRole("button", { name: /se connecter/i });
  await userEvent.click(button);
  await userEvent.click(button);
 
  expect(onSubmit).toHaveBeenCalledTimes(1);
});

Regardez comment ce test désigne les éléments : getByRole, getByLabelText. Pas de querySelector(".login-button"), pas de data-testid.

Ce n'est pas un détail de style. Ces sélecteurs sont ceux qu'utilise une technologie d'assistance. Un test qui trouve le bouton par son rôle et son intitulé vérifie au passage qu'il est accessible — et si getByLabelText échoue, c'est que le <label htmlFor> du chapitre 31 a disparu. Une régression d'accessibilité fait échouer le test, sans qu'on ait écrit un test d'accessibilité.

Un test par .class ou par data-testid casse à chaque retouche de style et ne vérifie rien de tel.

Un test de bout en bout, ou deux

Il reste une chose qu'aucun test précédent ne couvre : le fait que tous ces morceaux soient effectivement branchés ensemble. Le cookie posé par le contrôleur, lu par le navigateur, renvoyé à l'API, décodé par authenticate.

Chacune de ces pièces est testée. Le câblage, non.

// e2e/booking.spec.js
test("un vacancier réserve un logement et le retrouve dans ses réservations", async ({ page }) => {
  await page.goto("/?from=2024-06-02&to=2024-06-04&adults=2&children=0");
 
  await page.getByLabel("Adresse email").fill("faketenant@mail.com");
  await page.getByLabel("Mot de passe").fill("secret");
  await page.getByRole("button", { name: "Se connecter" }).click();
 
  const card = page.getByRole("article", { name: /Villa 6 pièces avec piscine/ });
  await card.getByRole("button", { name: "Réserver" }).click();
 
  await expect(card).toBeHidden();                         // le logement quitte la liste
 
  await page.getByRole("link", { name: "Mes réservations" }).click();
  await expect(page.getByText("Villa 6 pièces avec piscine")).toBeVisible();
  await expect(page.getByText("2 nuits")).toBeVisible();
});

Un seul scénario, et c'est celui qui rapporte de l'argent : chercher, se connecter, réserver, vérifier.

Combien en faut-il ? Aussi peu que possible. Un test de bout en bout est lent (secondes), instable par nature, et il échoue en désignant l'écran plutôt que la cause. Un projet qui en accumule cent finit par les désactiver un par un.

La bonne nouvelle, c'est que notre architecture permet d'en avoir très peu. Les règles métier sont couvertes en millisecondes dans le domaine ; les bout-en-bout n'ont plus qu'à vérifier le câblage. C'est un bénéfice direct de trente chapitres de séparation.

Quatre règles pour qu'ils restent utilisables :

Jamais de sleep. On attend une condition (toBeVisible), jamais une durée. Un sleep(500) est un test qui échouera sur une machine chargée, et un jour perdu à comprendre pourquoi.

Des données de départ maîtrisées. La base est réinitialisée et remplie avant la campagne. Un test qui dépend de ce qu'a laissé le précédent est un test qui échoue selon l'ordre d'exécution.

Le temps est figé. Notre séjour est daté de juin 2024 ; en 2026, il sera dans le passé, et la commande le refusera. Souvenez-vous du chapitre 17 :

Le 1er juin 2024, le test passe. Et soudain, le 3 juin, il ne passe plus !

Nous avons la solution depuis ce chapitre-là : dateProvider est un port. Il suffit que l'environnement de test lance le serveur avec un provider figé — une variable d'environnement lue par la racine de composition, et rien d'autre à changer. C'est le dividende le plus tardif de tout le cours.

Les sélecteurs sont ceux de l'utilisateur. Mêmes règles que pour les composants : rôles et intitulés.

Ce que nous ne testons pas, volontairement

Une liste courte, et assumée.

Ni argon2, ni pg, ni React. Ce sont des bibliothèques testées par leurs auteurs. Le chapitre 30 l'avait dit : vérifier argon2, c'est le travail des auteurs d'argon2.

Ni le HTML des mails. Le chapitre 34 a tracé la frontière : le domaine décide quoi notifier, l'infrastructure décide comment. Un test sur une balise <h1> casse à la première retouche du graphiste.

Ni les fonctions privées. Si toDomain mérite un test propre, c'est qu'elle mérite d'être exportée. Sinon, elle est couverte par le contrat du repository.

Ni le taux de couverture, comme objectif. La couverture est un bon détecteur d'oublis : un fichier à 0 % mérite une question. Elle est un très mauvais objectif : viser 90 % produit des tests écrits pour la métrique, qui appellent le code sans rien vérifier. Un test sans assertion couvre parfaitement des lignes.

Si vous voulez mesurer la qualité de vos tests plutôt que leur quantité, regardez du côté des tests de mutation : l'outil modifie votre code — un < en <= — et vérifie qu'un test proteste. Sur Stay.overlaps, c'est édifiant. C'est exactement le bug du chapitre 21.

La discipline qui compte plus que tout le reste

Si vous ne deviez retenir qu'une règle de ce chapitre :

Chaque bug corrigé commence par un test qui échoue.

Pas "je corrige et j'ajoute un test si j'ai le temps". D'abord le test, qui reproduit le bug et qui est rouge. Puis la correction, qui le passe au vert.

Trois raisons, et la troisième est la plus importante.

D'abord, c'est la seule preuve que vous avez compris le bug. Un test qu'on n'arrive pas à faire échouer signale qu'on s'apprête à corriger autre chose.

Ensuite, cela garantit que le bug ne reviendra pas — et les bugs reviennent, surtout ceux qui touchent aux cas limites.

Enfin, c'est ainsi qu'une suite de tests se construit là où elle sert. Vos tests finissent par se concentrer exactement sur les endroits où votre équipe se trompe : les fuseaux horaires, les bornes d'intervalle, les états concurrents. Aucun plan de test rédigé à l'avance n'atteint cette précision.

Notre application est testée à tous les niveaux utiles. Mettons-la en ligne.