Module 5 · Qualité

Tester le web : Vitest & Testing Library

Bonne nouvelle : tu sais déjà tester. Jest, Testing Library, msw, des flows Maestro — ce module n'est pas un apprentissage mais une traduction : l'outillage web et l'outillage mobile sont les mêmes idées sous d'autres noms. Ce qu'on travaille ici, c'est le jugement : quoi tester, à quel niveau, et comment distinguer un bon test d'un test qui coûtera cher. Beaucoup de tests techniques de stage livrent un squelette de tests à compléter, et ta façon de les lire compte autant que ton code.

💡 L'idée en une phrase

Un test, ce n'est pas « du code qui vérifie du code ». C'est une phrase en français, rendue exécutable : « quand l'utilisateur remplit le formulaire et clique sur Réserver, il voit une confirmation ». Une phrase qui parle du comportement survit aux refontes ; une phrase qui parle des rouages internes casse au premier coup de balai.

Pourquoi on teste, sans dogme

Deux discours extrêmes circulent, faux tous les deux. « Tout doit être testé, 100 % de couverture » produit des suites gigantesques et fragiles que personne n'ose toucher. « Les tests sont une perte de temps, on verra quand le produit sera stabilisé » produit des applications où chaque correction en casse deux autres. La vérité utile tient dans une question de coût : un test vaut la peine quand il coûte moins cher à écrire et à maintenir que le bug qu'il empêche.

Encore faut-il savoir ce qu'un test t'achète — pas seulement « attraper des bugs », mais trois choses, dont la première est la plus sous-estimée.

1. Un contrat exécutable. « Le bouton est désactivé pendant l'envoi » ne décrit pas ton code : ça décrit une promesse faite à l'utilisateur. Le jour où quelqu'un — toi dans six mois, un collègue, un agent qui refactorise — casse cette promesse, la machine le lui dit immédiatement, avant la production.

2. Une documentation qui ne ment pas. Un README vieillit en silence, un commentaire devient faux sans prévenir. Un test, lui, échoue dès qu'il décrit un comportement que le code n'a plus : c'est la seule documentation du métier vérifiée en continu. Sur un projet inconnu, lire les noms des tests renseigne souvent mieux que lire le code — on y revient dans la dernière section.

3. La permission de changer d'avis. Le vrai bénéfice quotidien. Sans tests, chaque refonte est un pari : tu n'oses pas toucher au code qui marche, donc le code pourrit. Avec des tests de comportement, tu réécris un composant de fond en comble et tu sais en dix secondes si le produit se comporte pareil. Les tests ne figent pas le code — ils le libèrent.

La pyramide, et pourquoi le frontend la déforme

Trois étages. À la base, les tests unitaires : une fonction, isolée, sans DOM ni réseau — rapides, précis, bon marché. Au milieu, l'intégration : plusieurs pièces qui collaborent, un composant avec son état, ses enfants, son appel réseau simulé. Au sommet, le bout en bout (end-to-end) : vraie application, vrai navigateur, vrai serveur — lent, coûteux, mais seul à prouver que l'ensemble fonctionne. La forme pyramidale (beaucoup d'unitaire, très peu de bout en bout) vient du backend, où la valeur tient dans des fonctions de calcul qui s'isolent bien.

Le frontend a déplacé le centre de gravité vers le milieu, parce qu'en interface les bugs ne vivent presque jamais dans une fonction isolée : ils vivent dans le câblage. Le champ n'est pas relié à son étiquette ; le bouton n'est pas désactivé au bon moment ; l'erreur du serveur est reçue mais jamais affichée. Un test unitaire sur chacune de ces pièces passerait au vert, et l'écran serait quand même cassé. D'où l'image du trophée : une base d'unitaires pour la logique pure, un gros ventre d'intégration pour les écrans, une poignée de bout en bout pour les parcours qui rapportent de l'argent.

🔗 Pont — tu as déjà cette conversation en mobile

Sur Halterofit, tu as exactement cette répartition, et tu l'as construite pour les mêmes raisons. Jest couvre les fonctions pures et les écrans rendus en mémoire ; Maestro couvre un ou deux parcours héros sur un vrai appareil, avec la vraie base SQLite — parce que certaines choses (le protocole de synchronisation, les colonnes internes de WatermelonDB) sont structurellement invérifiables en environnement simulé. C'est le même raisonnement ici, mot pour mot : Vitest + jsdom, c'est ton Jest ; Playwright ou Cypress, c'est ton Maestro ; et « ce que jsdom ne sait pas faire » (la vraie mise en page, le défilement, le rendu réel des polices) remplace « ce que LokiJS ne sait pas faire ».

Retiens la traduction : tu ne changes pas de méthode, tu changes de vocabulaire. Le jugement que tu as déjà — « ce test-là ne prouve rien, il faut le vrai moteur » — se transpose tel quel, et c'est une chose que peu de candidats juniors savent formuler.

L'anatomie d'un fichier de test

Avant les outils, le vocabulaire. Tout fichier de test JavaScript moderne — Jest, Vitest, Node lui-même — est construit avec les trois mêmes briques.

  • describe(nom, fn) — un regroupement, purement organisationnel : un titre au-dessus d'un paquet de tests dans le rapport, en général le nom de ce qu'on teste.
  • it(nom, fn)un test. Un cas, une phrase, un verdict. test(nom, fn) est un synonyme exact ; le choix est affaire de goût d'équipe. Le nom it vient d'une tradition de lecture à voix haute : le nom du test doit former une phrase complète décrivant un comportement.
  • expect(valeur).unMatcher(attendu)une assertion. expect emballe la valeur observée, et le matcher (toBe, toEqual, toHaveBeenCalled…) décrit la comparaison à faire.

Arrange, Act, Assert

Un bon test a trois temps, toujours dans le même ordre. Arrange — le décor : données d'entrée, rendu du composant, doublures (les faux objets qui remplacent le réseau ou l'horloge, détaillés plus bas). Actune chose : on appelle, on clique, on tape ; trois actions = probablement trois tests. Assert — on vérifie que l'observé correspond à la promesse. Ce n'est pas une règle de syntaxe mais de lisibilité : quand les trois temps sont mélangés, le test devient un script dont l'échec ne t'apprend plus rien — « quelque chose » a cassé, quelque part dans douze étapes.

lib/validation/rendez-vous.test.ts
import { describe, it, expect } from 'vitest';
import { validateAppointment } from './rendez-vous';

describe('validateAppointment', () => {
  it('refuse un email sans arobase', () => {
    // ARRANGE — un jeu de valeurs valide, sauf le email qu'on veut éprouver.
    const values = {
      nom: 'Patrick',
      email: 'patrick.exemple.ca',
      date: '2030-01-12',
      service: 'physio',
    };

    // ACT — une seule action : on appelle la fonction.
    const errors = validateAppointment(values);

    // ASSERT — on vérifie la promesse, et RIEN d'autre.
    expect(errors.email).toBeDefined();
    expect(errors.nom).toBeUndefined(); // les autres fields restent valides
  });
});

Le validateur pur du module Formulaires 2 — validation & erreurs, testé directement. Aucun composant, aucun DOM, aucun réseau : ce test tourne en une fraction de milliseconde.

Le nom du test est la moitié du travail

Le nom d'un test n'est pas une étiquette administrative : c'est ce que tu liras le jour où il échouera, souvent dans un rapport d'intégration continue — le serveur qui rejoue toute la suite à chaque envoi de code —, à froid, sans le code sous les yeux. Compare :

Nom faibleNom qui travaille pour toi
it('test 3')it('affiche une erreur quand le email est invalide')
it('fonctionne')it('envoie les values saisies quand le formulaire est soumis')
it('validateAppointment')it('refuse une date passée')
it('bouton')it('désactive le bouton pendant l’envoi')
it('cas limite')it('accepte un nom avec un trait d’union')

La règle mécanique : un bon nom contient un verbe au présent (ce que le système fait) et une condition (quand). « Affiche X quand Y ». « Refuse X si Y ». Il ne mentionne ni le nom de la fonction interne, ni la technique employée. Un test nommé it('appelle setState avec le bon objet') te dit qu'il teste l'implémentation, avant même que tu lises son corps.

🧭 Le truc du rapport d'échec

Pour juger un nom, imagine-le seul, en rouge, dans une console d'intégration continue : « ✗ AppointmentForm > test 3 » ne t'apprend rien. « ✗ AppointmentForm > affiche un message d'échec quand l'API répond 500 » te donne le bug, le fichier à ouvrir et souvent la cause — dix minutes épargnées, à multiplier par toutes les fois où il échouera.

🧠 Quiz éclair

Ce test est vert, et il fait monter le pourcentage de couverture du fichier. Que prouve-t-il, exactement ?

it('valide le email', async () => {
  render(<AppointmentForm />);
  await userEvent.type(screen.getByLabelText('Courriel'), 'patrick.ca');
  await userEvent.click(screen.getByRole('button', { name: /réserver/i }));
});

Qu'aucune de ces trois lignes ne lève d'exception — et rien d'autre. Il n'y a pas un seul expect : le test a un Arrange, un Act, et pas d'Assert. Son nom promet une validation que son corps ne vérifie jamais, donc le jour où le validateur cessera de rejeter 'patrick.ca', il restera vert. C'est aussi la démonstration la plus courte de ce que vaut la couverture : elle compte les lignes exécutées, pas les comportements vérifiés. Il manque la promesse, une ligne : expect(await screen.findByRole('alert')).toHaveTextContent(/email/i).

Vitest en pratique : juste assez pour reconnaître un projet configuré

Vitest est un lanceur de tests (test runner) : le programme qui trouve tes fichiers de test, les exécute et te dit ce qui passe — le rôle exact de Jest dans Halterofit. Il est bâti sur Vite, donc il partage la configuration du projet : TypeScript, JSX, alias d'import (@/composants/…), fichiers CSS, tout est compris d'emblée. Et il ne recompile que ce qui a changé, d'où sa rapidité.

Le point important : l'API est volontairement compatible avec Jest. describe, it, expect, beforeEach, les matchers, le mode veille : identiques. Seule différence visible, l'objet utilitaire s'appelle vi au lieu de jest (vi.fn(), vi.mock(), vi.spyOn()). Ton acquis mobile se transpose par un chercher-remplacer mental — et si ton stage utilise Jest, tu ne changes rien.

jsdom : un navigateur en carton, et c'est très bien

Node.js, tout seul, n'a pas de DOM : pas de document, pas de window, pas d'<input>. jsdom est une réimplémentation du DOM en pur JavaScript, en mémoire dans le processus Node : ton test rend un composant, jsdom fabrique un arbre d'éléments consultable, Testing Library l'interroge. Rien n'est affiché à l'écran ; c'est une simulation, avec des limites qui définissent ce que ce niveau de test peut prouver.

  • Aucune mise en page réelle. jsdom ne calcule ni positions ni tailles : tout élément mesure zéro. Donc pas de test de visibilité à l'écran, de responsive ou de chevauchement — ce sont des tests de navigateur réel.
  • Le CSS n'est pas vraiment appliqué. jsdom lit les styles en ligne mais ne résout ni les feuilles de style ni les classes. Vérifier une couleur ou un display: none venu d'une classe est une illusion.
  • Des API manquantes. IntersectionObserver, matchMedia, scrollTo, le canvas ne sont pas implémentés — d'où les doublures qu'on ajoute au fichier de mise en place.

Même logique que sur Halterofit, où LokiJS remplace SQLite en test et où le protocole de synchronisation échappe structurellement à Jest. Une simulation te fait gagner en vitesse ce qu'elle te coûte en fidélité ; le métier consiste à savoir laquelle des deux te manque.

vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],          // sait compiler le JSX de tes composants
  test: {
    environment: 'jsdom',      // ← LE réglage clé : un faux DOM pour chaque test
    globals: true,             // describe / it / expect sans les importer
    setupFiles: ['./vitest.setup.ts'],  // fichier joué AVANT chaque fichier de test
  },
});

Quatre lignes qui comptent. Si tu vois environment: 'jsdom', tu sais que le projet teste des composants ; si tu vois 'node', il ne teste que de la logique.

vitest.setup.ts
// Ajoute les matchers orientés DOM : toBeInTheDocument, toBeDisabled,
// toHaveTextContent, toHaveValue… Sans cet import, ils n'existent pas.
import '@testing-library/jest-dom/vitest';

import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// Démonte les composants rendus après chaque test : sinon le DOM s'accumule
// et une requête « getBy » trouve DEUX boutons — l'ancien et le nouveau.
afterEach(() => {
  cleanup();
});

Le fichier de mise en place, c'est « ce qui est vrai pour tous les tests ». C'est aussi là que se démarre le serveur msw, qu'on verra plus bas.

Tu n'as pas à savoir écrire ces fichiers de mémoire, seulement à les reconnaître : dans un dépôt livré pour un test technique, ils te disent en dix secondes quel niveau de test on attend de toi. Et si tu vois @testing-library/jest-dom avec Vitest, ne t'étonne pas : la bibliothèque de matchers a gardé son nom historique.

La philosophie de Testing Library : tester comme l'utilisateur

On arrive au cœur du module. Testing Library n'est pas un lanceur de tests, c'est une bibliothèque de requêtes : elle sert à retrouver des éléments dans le DOM rendu. Sa doctrine tient en une phrase :

💡 Le principe directeur

« Plus tes tests ressemblent à la façon dont ton logiciel est utilisé, plus ils te donnent confiance. » Traduction pratique : dans un test, tu dois chercher les éléments comme un humain les cherche — « le champ étiqueté Courriel », « le bouton qui dit Réserver » — et jamais comme un développeur les retrouve dans son code — « le troisième <div> avec la classe form__row ».

Ce n'est pas une préférence esthétique, c'est une stratégie de résistance au changement. Les classes CSS bougent à chaque retouche de design, la structure des <div> dès qu'on ajoute un conteneur, le nom d'un état interne au premier refactor. En revanche, un bouton qui dit Réserver et un champ étiqueté Courriel ne bougent presque jamais — parce que c'est ça, le produit.

L'ordre de priorité des requêtes

Testing Library classe explicitement ses requêtes du meilleur au pire. Ce classement est publié dans la documentation officielle, et le connaître est un signal de sérieux en entrevue.

RangRequêteCe qu'elle chercheQuand
1getByRoleLe rôle d'accessibilité + le nom accessibleLe défaut. Boutons, liens, champs, titres, cases.
2getByLabelTextUn champ par son étiquette visibleLe choix naturel pour les formulaires.
3getByPlaceholderTextLe texte d'exemple d'un champSeulement si le champ n'a vraiment pas d'étiquette.
4getByTextDu texte affichéLes contenus non interactifs : messages, paragraphes.
5getByDisplayValueLa valeur courante d'un champVérifier un formulaire pré-rempli.
6getByTestIdUn attribut data-testidDernier recours. Quand rien d'autre n'identifie l'élément.

Le rôle est la notion centrale : ce que l'élément est pour une technologie d'assistance — <button>button, <a href>link, <input type="text">textbox, <select>combobox, <h1>heading. Le second argument, { name: … }, n'est pas l'attribut HTML name : c'est le nom accessible, le texte qu'un lecteur d'écran annoncerait.

// Quelques requêtes typiques sur l'écran de réservation.
screen.getByRole('heading', { name: /prendre rendez-vous/i, level: 1 });
screen.getByRole('textbox', { name: 'Nom complet' });   // l'input relié à son label
screen.getByRole('combobox', { name: 'Service' });      // le <select>
screen.getByRole('button', { name: /réserver/i });      // regex = insensible à la casse
screen.getByRole('alert');                              // le bloc d'erreur, role="alert"

Une expression régulière comme /réserver/i évite de casser si le libellé devient « Réserver mon rendez-vous ». Une chaîne exacte, elle, exige une correspondance complète.

Pourquoi c'est un argument d'entrevue très fort

Le point que peu de juniors savent formuler, et qui fait mouche : un test qui passe par le rôle teste aussi l'accessibilité. Raisonne à l'envers — pour que getByRole('textbox', { name: 'Courriel' }) trouve quelque chose, il faut que le champ ait un nom accessible, donc une étiquette (<label htmlFor>, aria-label, aria-labelledby). Si le développeur a écrit un <div> cliquable au lieu d'un <button>, ou un texte flottant au lieu d'une étiquette, la requête échoue — et elle a raison, cette page est réellement inutilisable au lecteur d'écran.

Ta suite devient donc un audit d'accessibilité continu et gratuit : aucun outil ajouté, juste la bonne requête. À l'inverse, getByTestId('email-input') fonctionne parfaitement sur une page catastrophique — data-testid existe pour les robots, pas pour les humains.

🔗 Pont — l'arbre d'accessibilité, revisité

Dans le module HTML sémantique & accessibilité, on a vu que le navigateur ne construit pas seulement le DOM : il en dérive un second arbre, l'arbre d'accessibilité, où chaque nœud porte un rôle et un nom accessible. C'est cet arbre-là que lisent les lecteurs d'écran, et c'est exactement celui que Testing Library interroge quand tu écris getByRole. Ce n'est pas une analogie : c'est littéralement la même structure, calculée selon les mêmes règles.

Ça éclaire d'un coup une grande partie de ce module. Pourquoi le rôle avant tout ? Parce que c'est le seul point de vue partagé par l'utilisateur voyant, l'utilisateur aveugle, et le test. Pourquoi <label htmlFor> compte-t-il tant en formulaire ? Parce qu'il est ce qui donne un nom au champ dans cet arbre, donc ce qui rend le champ trouvable — par un humain comme par ton test. Et quand getByRole('button', { name: /réserver/i }) échoue en te listant tous les rôles disponibles, tu n'es pas en train de déboguer un test : tu es en train de lire l'arbre d'accessibilité de ta page. Garde ce message d'erreur, il est très instructif.

📅 Dans RendezVous

Le formulaire de la clinique a été écrit dès le module Formulaires 1 avec un <label htmlFor> par champ — choix fait pour l'accessibilité et la zone tactile, qui se rembourse ici une seconde fois : chaque champ est directement adressable par son étiquette.

// Le formulaire tel qu'il existe : label + input reliés par for / id.
//   <label for="email">Courriel</label>
//   <input id="email" name="email" type="email" />

// Donc le test le retrouve exactement comme l'utilisateur le lit :
const email = screen.getByLabelText('Courriel');
const service  = screen.getByLabelText('Service');   // le <select>
const notes    = screen.getByLabelText(/précisions/i);

Note le contraste : le champ s'appelle email dans le code mais « Courriel » pour l'utilisateur — et le test utilise le mot de l'utilisateur. Le jour où on renomme la clé interne, le test ne bronche pas : il n'a jamais rien su de cette clé. C'est ça, un test découplé de l'implémentation.

✍️ Exercice de lecture

Un candidat livre ce test avec son exercice technique. Il passe au vert sur sa machine et en intégration continue.

it('affiche une erreur de email', () => {
  const { container } = render(<AppointmentForm />);

  const bouton = container.querySelector('.btn.btn--primary');
  fireEvent.click(bouton);

  const erreur = container.querySelector('.form__error--email');
  expect(erreur).not.toBeNull();
  expect(erreur.textContent).toContain('Courriel invalide');
});

Questions : (1) Cite trois événements parfaitement anodins qui feraient échouer ce test alors que l'application fonctionne toujours très bien. (2) Cite un bug réel que ce test ne verrait pas. (3) Comment le réécrirais-tu ?

Voir le corrigé

(1) (a) Un redesign : .btn--primary devient .button-primary — le test casse, le comportement n'a pas bougé. (b) Un module CSS qui génère des noms hachés (.btn_a7f3e) : plus aucune classe stable, tous les tests de ce type tombent d'un coup. (c) Un ajout de classe utilitaire qui change la composition du sélecteur. Dans les trois cas, tu passes ta matinée à réparer des tests qui n'ont détecté aucun bug — c'est ce coût de maintenance qui rend les gens hostiles aux tests.

(2) L'inaccessibilité. Si le « bouton » est en réalité un <div class="btn btn--primary">, le test le trouve et le clique très bien — querySelector se fiche de ce qu'est l'élément. Mais un utilisateur au clavier ne peut pas l'atteindre, un lecteur d'écran ne l'annonce pas comme un bouton, et Entrée ne le déclenche pas. Même chose si le message d'erreur n'a pas de role="alert" : invisible pour l'assistance, et le test est content.

(3) On passe par ce que voit l'utilisateur :

it('affiche une erreur quand le email est invalide', async () => {
  render(<AppointmentForm />);

  await userEvent.click(screen.getByRole('button', { name: /réserver/i }));

  expect(await screen.findByText(/email/i)).toBeInTheDocument();
});

Plus court, insensible au design, et il exige un vrai <button> pour passer : trois bénéfices pour un seul changement de requête. C'est ce raisonnement qu'on attend si un évaluateur te demande de commenter un test existant.

🧠 Quiz éclair

Le test échoue avec « Unable to find a label with the text of: Courriel ». Le champ est pourtant bien à l'écran, avec le mot « Courriel » juste au-dessus. Qu'est-ce que cet échec t'apprend sur la page ?

// le composant
<div className="field">
  <span className="field__label">Courriel</span>
  <input type="email" placeholder="nom@exemple.ca" />
</div>

// le test
screen.getByLabelText('Courriel');

Que le champ n'a aucun nom accessible. Un <span> n'est pas une étiquette : rien ne le relie au champ — ni htmlFor vers un id, ni imbrication. Visuellement le mot est là, dans l'arbre d'accessibilité il flotte à côté d'un contrôle anonyme, et un lecteur d'écran annonce « zone de texte », point. L'échec n'est donc pas un problème de test : c'est un défaut réel que la requête vient de révéler. Et le placeholder n'est pas un repli — c'est un exemple, il disparaît dès la première frappe, et getByPlaceholderText n'est qu'en troisième position précisément parce que s'en contenter revient à accepter un champ anonyme. Le correctif : <label htmlFor="email">Courriel</label> et <input id="email"> — après quoi le test passe sans qu'on y touche.

getBy, queryBy, findBy : trois familles, trois usages

Ces trois préfixes se combinent avec tous les suffixes de la table précédente (getByRole, queryByRole, findByRole…). Ils cherchent la même chose ; ce qui change, c'est leur comportement quand l'élément n'est pas là — et c'est la source d'échec numéro un des débutants en Testing Library.

FamilleSi trouvéSi absentAttend ?À utiliser pour
getBy…l'élément lance une erreurnon Prouver une présence immédiate (contenu déjà rendu).
queryBy…l'élément renvoie nullnon Prouver une absence.
findBy…une promesse rejette après un délai (≈ 1 s)oui Prouver une apparition différée (après un fetch, un état).

Une quatrième famille existe au pluriel — getAllBy…, queryAllBy…, findAllBy… — qui renvoie un tableau (les lignes d'une liste, les créneaux disponibles). Au passage, les versions au singulier échouent aussi quand elles trouvent plusieurs correspondances : « Found multiple elements » signale souvent un vrai problème d'interface — deux boutons qui disent la même chose sans se distinguer, ce qui embrouille aussi l'utilisateur au lecteur d'écran.

Le piège classique : getBy sur ce qui n'existe pas encore

Tu cliques sur « Réserver » ; le composant lance un fetch, attend la réponse, puis affiche « Rendez-vous confirmé ». Or un fetch, même simulé, même instantané, est asynchrone : sa résolution est mise en file d'attente et ne se produit qu'après la fin du code en cours. À la ligne suivant le clic, React n'a donc rien reçu, rien mis à jour, rien re-rendu.

// ❌ Échoue : « Unable to find an element with the text… »
await userEvent.click(screen.getByRole('button', { name: /réserver/i }));
expect(screen.getByText(/rendez-vous confirmé/i)).toBeInTheDocument();

// ✅ findBy ATTEND que l'élément apparaisse (il réessaie jusqu'à ~1 seconde).
await userEvent.click(screen.getByRole('button', { name: /réserver/i }));
expect(await screen.findByText(/rendez-vous confirmé/i)).toBeInTheDocument();

// ✅ queryBy pour prouver une ABSENCE : il rend null au lieu de planter.
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

Retiens l'équation : findBywaitFor + getBy. Il réessaie la requête à intervalles courts jusqu'à la trouver ou jusqu'au délai maximum.

Pourquoi ne pas toujours utiliser findBy ? Parce qu'un getBy qui échoue échoue tout de suite, tandis qu'un findBy coûte une seconde par occurrence. Surtout, getBy exprime une intention plus forte : « cet élément doit déjà être là ».

⚠️ Piège fréquent — queryBy pour prouver une absence

L'erreur miroir de la précédente, et plus vicieuse : elle ne produit pas d'échec, elle produit un test toujours vert. Avec expect(screen.getByRole('alert')).not.toBeInTheDocument() tu n'atteins jamais l'assertion — getByRole lance une erreur avant, au moins tu le vois.

Le vrai danger est ailleurs : expect(screen.queryByText('Erreur')).not.toBeInTheDocument() passe aussi bien quand il n'y a pas d'erreur… que quand tu as une faute de frappe dans le texte, que quand le composant n'a rien rendu, que quand tu as oublié le render(). Une assertion négative est satisfaite par le néant. La parade : vérifie qu'un test négatif échoue si tu casses volontairement le code. Un test qui ne passe jamais au rouge ne teste rien.

✍️ Exercice de lecture

Ce test échoue avec le message « Unable to find an element with the text: /rendez-vous confirmé/i ». Pourtant, quand on ouvre l'application dans le navigateur et qu'on réserve, la confirmation s'affiche parfaitement.

it('confirme la réservation', async () => {
  render(<PageRendezVous />);

  await userEvent.type(screen.getByLabelText('Courriel'), 'a@b.ca');
  await userEvent.click(screen.getByRole('button', { name: /réserver/i }));

  expect(screen.getByText(/rendez-vous confirmé/i)).toBeInTheDocument();
});

Questions : (1) Pourquoi échoue-t-il, alors que le click est bien attendu avec await ? (2) Quelle famille de requête faut-il ? (3) Le correctif await new Promise((r) => setTimeout(r, 500)) avant l'assertion marcherait-il, et pourquoi est-ce quand même une mauvaise idée ?

Voir le corrigé

(1) Le await du clic attend seulement que l'événement soit traité et que React ait fini le rendu déclenché directement par ce clic. Or le gestionnaire lance un fetch et rend d'abord l'état « envoi en cours » ; la confirmation n'apparaîtra qu'après la résolution de la promesse réseau. Au moment où getByText s'exécute, le DOM affiche encore « Réservation en cours… », et getBy ne réessaie pas.

(2) findByText, qui réessaie jusqu'à trouver ou expirer : expect(await screen.findByText(/rendez-vous confirmé/i)).toBeInTheDocument(). Note le await à l'intérieur du expect : l'oublier passe une promesse au matcher, et l'assertion devient absurde tout en ayant l'air correcte.

(3) Ça marcherait… la plupart du temps, et c'est le problème. Une attente fixe est un pari sur la vitesse de la machine : trop courte, le test devient instable ; trop longue, tu ajoutes une demi-seconde morte à chaque test. findBy repart dès que l'élément apparaît. Attends une condition, jamais une durée — vrai en test comme en automatisation en général.

🧠 Quiz éclair

Ce test passe au vert. Cite trois situations où il passerait aussi, alors que l'application est cassée.

it('n’affiche pas d’erreur quand le email est valide', () => {
  render(<AppointmentForm />);
  expect(screen.queryByText('Erreur')).not.toBeInTheDocument();
});

Trois, au moins. Un : l'erreur s'affiche pour de bon, mais l'écran dit « Erreur : email invalide » — une chaîne exacte compare le texte complet du nœud, donc elle ne correspond pas, la requête rend null, et l'assertion est ravie. Deux : le composant n'a rien rendu du tout — un return null anticipé, une exception attrapée plus haut. Aucun texte, donc aucune erreur trouvée. Trois : le render() a été oublié, ou le nettoyage du test précédent a vidé le document : on interroge une page blanche.

Et le vice de fond, au-delà des trois : rien ici ne remplit le champ ni ne soumet, donc le test n'atteint jamais l'état qu'il prétend décrire. Une assertion négative est satisfaite par le néant. Deux parades : casser volontairement le code pour vérifier que le test devient rouge, et préférer prouver la présence de l'état heureux (await screen.findByText(/rendez-vous confirmé/i)) plutôt que l'absence du malheureux.

Simuler l'utilisateur : userEvent plutôt que fireEvent

Deux façons de déclencher une interaction, et le choix suit la même logique que celui des requêtes. fireEvent.click(element) émet un événement DOM brut, isolé — l'équivalent d'appeler directement le gestionnaire. userEvent.click(element) simule ce que fait un vrai humain avec une vraie souris : vérifier que l'élément n'est ni désactivé ni caché, déplacer le pointeur, appuyer, donner le focus, relâcher, puis seulement émettre le click. De même, userEvent.type() envoie pour chaque caractère une séquence keydown / keypress / input / keyup, en respectant la position du curseur.

La différence n'est pas cosmétique. Un menu qui s'ouvre au survol, un champ qui valide à la perte de focus, un composant qui écoute keydown plutôt que change : fireEvent donne un résultat qui ne correspond à rien de réel. Pire, sur un bouton désactivé, fireEvent.click déclenche joyeusement le gestionnaire — ton test valide un chemin impossible. userEvent refuse, comme le navigateur.

Conséquence pratique : toutes les méthodes de userEvent sont asynchrones, parce qu'elles enchaînent plusieurs événements et laissent React traiter les rendus intermédiaires. Il faut donc les await — l'oubli est l'autre grande source de tests instables : la suite s'exécute pendant que la frappe est encore en cours.

import userEvent from '@testing-library/user-event';

// Forme recommandée : on crée une « session » utilisateur en début de test.
const user = userEvent.setup();

await user.type(screen.getByLabelText('Nom complet'), 'Patrick');
await user.selectOptions(screen.getByLabelText('Service'), 'physio');
await user.clear(screen.getByLabelText('Courriel'));      // vide le champ
await user.tab();                                          // passe au champ suivant
await user.click(screen.getByRole('button', { name: /réserver/i }));

userEvent.setup() installe une session cohérente (position du pointeur, presse-papiers, état des touches modificatrices). Appeler userEvent.click(…) directement fonctionne aussi et fait un setup() implicite ; les deux formes se croisent dans la nature.

user.tab() est sous-utilisé : il simule Tab et déplace le focus au prochain élément focalisable. C'est ta façon de tester la navigation au clavier — qu'un modal piège le focus, qu'un champ valide en le quittant, que l'ordre de tabulation est cohérent. Un test de parcours clavier dans un exercice technique est un signal de maturité rare chez un junior.

📖 La formule

Le test d'intégration qui parle

it('envoie les values saisies quand le formulaire est soumis', async () => {
  render(<PageRendezVous />);
  await userEvent.selectOptions(screen.getByLabelText('Service'), 'Échographie');
  await userEvent.type(screen.getByLabelText('Courriel'), 'a@b.ca');
  await userEvent.click(screen.getByRole('button', { name: /réserver/i }));
  expect(await screen.findByText(/rendez-vous confirmé/i)).toBeInTheDocument();
});

Relis ces six lignes et cherche un détail d'implémentation : il n'y en a aucun. Pas un nom d'état, pas une classe CSS, pas un nom de fonction interne — seulement des étiquettes, un libellé de bouton, et un message visible. Ce test survivrait à une réécriture complète du composant : passage de useState à useReducer, découpage en cinq sous-composants, migration vers React Hook Form ou vers une Server Action. Tant que la clinique se réserve de la même façon, il reste vert.

📅 Dans RendezVous

La formule déployée en test complet, ses trois temps bien séparés — le genre de test qu'on te demandera d'écrire ou de compléter dans un exercice de stage.

app/rendez-vous/PageRendezVous.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PageRendezVous } from './PageRendezVous';

describe('PageRendezVous', () => {
  it('confirme la réservation quand le formulaire est valide', async () => {
    // ARRANGE
    const user = userEvent.setup();
    render(<PageRendezVous />);

    // ACT — le parcours complet, dans l'ordre où un humain le ferait.
    await user.type(screen.getByLabelText('Nom complet'), 'Patrick Patenaude');
    await user.type(screen.getByLabelText('Courriel'), 'patrick@exemple.ca');
    await user.selectOptions(screen.getByLabelText('Service'), 'echo');
    await user.click(screen.getByRole('button', { name: /réserver/i }));

    // ASSERT — ce que l'utilisateur voit, et rien d'autre.
    expect(await screen.findByText(/rendez-vous confirmé/i)).toBeInTheDocument();
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  });

  it('désactive le bouton pendant l’envoi', async () => {
    render(<PageRendezVous />);
    // ... remplissage ...
    await userEvent.click(screen.getByRole('button', { name: /réserver/i }));
    expect(screen.getByRole('button', { name: /en cours/i })).toBeDisabled();
  });
});

Le second test vérifie la garde anti double-envoi posée dans le module Formulaires 1 — l'état isSubmitting qui désactive le bouton. Cas d'école de ce qu'un test d'intégration attrape et qu'un unitaire manquerait : la règle existe peut-être dans le code, mais atteint-elle vraiment l'attribut disabled du bouton affiché ? Et le bug qu'il empêche — deux rendez-vous pour un clic impatient — est un vrai bug de production.

Les doublures : espions, mocks et faux réseau

Un test doit être déterministe : même entrée, même résultat. Or une interface parle à des choses imprévisibles — un serveur, l'horloge, le routeur. On les remplace par des doublures (test doubles). Quatre familles utiles, de la plus légère à la plus lourde.

1. vi.fn() — l'espion

vi.fn() crée une fonction vide qui enregistre tous ses appels : combien de fois, avec quels arguments, dans quel ordre. L'outil parfait quand ton composant reçoit une fonction en prop et que tu veux vérifier qu'il l'appelle, sans exécuter la vraie logique derrière.

it('transmet les values saisies à onSubmit', async () => {
  const onSubmit = vi.fn();                     // un espion : ne fait rien, note tout
  render(<AppointmentForm onSubmit={onSubmit} />);

  await userEvent.type(screen.getByLabelText('Nom complet'), 'Patrick');
  await userEvent.click(screen.getByRole('button', { name: /réserver/i }));

  expect(onSubmit).toHaveBeenCalledTimes(1);    // exactement une fois : pas de double envoi
  expect(onSubmit).toHaveBeenCalledWith(
    expect.objectContaining({ nom: 'Patrick' }), // on ne vérifie QUE ce qui compte
  );
});

expect.objectContaining évite d'énumérer tout l'objet : le test ne casse pas si on ajoute un champ « notes » plus tard. Un test trop précis est un test qui casse pour rien.

Le matcher toHaveBeenCalledTimes(1) est souvent plus utile que toHaveBeenCalled() : il attrape la classe de bugs « appelé deux fois » — double-soumission, effet qui se déclenche à chaque rendu, gestionnaire attaché en double.

2. vi.mock() — remplacer un module entier

Quand la dépendance n'est pas passée en prop mais importée par le composant, il faut intercepter l'import lui-même. C'est le rôle de vi.mock() : « quand quelqu'un importe ce module, donne-lui ça à la place ». Le cas le plus fréquent en Next.js est le routeur.

// Remonté automatiquement en haut du fichier : le mock existe avant les imports.
const push = vi.fn();

vi.mock('next/navigation', () => ({
  useRouter: () => ({ push, replace: vi.fn(), refresh: vi.fn() }),
  useSearchParams: () => new URLSearchParams(),
}));

it('redirige vers la confirmation après une réservation', async () => {
  render(<PageRendezVous />);
  // ... parcours ...
  expect(push).toHaveBeenCalledWith('/rendez-vous/confirmation');
});

Sans ce mock, useRouter() plante en test : il n'y a pas de routeur Next.js en dehors d'une vraie application. C'est de la plomberie obligatoire, pas un test en soi.

Regarde quand même ce que l'assertion vérifie : un push vers /rendez-vous/confirmation après une réservation réussie. C'est la seconde moitié de Le lien qui reste un lien, du module Routing : segments, layouts, navigationLink pour ce que l'utilisateur clique, useRouter() pour les navigations que le code déclenche. D'où une lecture utile en revue : un test qui simule useRouter signale une navigation impérative, et vaut la peine qu'on vérifie qu'elle en est vraiment une — si l'utilisateur cliquait simplement sur « voir la confirmation », il fallait un lien.

Avertissement de dosage : vi.mock() est puissant, donc dangereux. Chaque module simulé est un morceau de réel que ton test cesse de vérifier. Si tu simules le composant enfant, la validation et le client HTTP, ton test vérifie essentiellement tes propres mocks. Règle de pouce : on simule ce qui est hors du logiciel (réseau, horloge, framework), pas ce qui est ton logiciel.

3. vi.spyOn() — surveiller sans remplacer

vi.spyOn(objet, 'méthode') pose un espion sur une méthode existante — par défaut sans changer son comportement, avec mockImplementation pour la neutraliser. L'usage le plus courant dans les tests techniques est console.log : beaucoup d'énoncés disent « à la soumission, affiche les valeurs dans la console ». Si c'est la consigne, c'est la promesse — donc c'est ce qu'on teste.

it('journalise les values soumises', async () => {
  // mockImplementation(() => {}) : on intercepte ET on rend l'appel silencieux,
  // sinon la sortie du test est polluée par les logs de l'application.
  const espion = vi.spyOn(console, 'log').mockImplementation(() => {});

  render(<AppointmentForm />);
  await userEvent.type(screen.getByLabelText('Nom complet'), 'Patrick');
  await userEvent.click(screen.getByRole('button', { name: /réserver/i }));

  expect(espion).toHaveBeenCalledWith(
    expect.objectContaining({ nom: 'Patrick' }),
  );

  espion.mockRestore(); // ← indispensable : on rend sa vraie méthode à console
});

Le mockRestore() n'est pas facultatif. Un espion laissé en place fuit dans les tests suivants du même fichier, et tu passeras un long moment à comprendre pourquoi un test isolé passe mais échoue dans la suite complète.

Le même espion sert aussi en négatif, et c'est un usage élégant : expect(vi.spyOn(console, 'error')).not.toHaveBeenCalled() transforme les avertissements React — « a component is changing a controlled input to be uncontrolled », une clé manquante dans une liste — en échecs de test. Tu ne peux plus les ignorer.

4. msw — intercepter le réseau, pas ton code

Tu connais déjà l'outil. msw (Mock Service Worker) n'écrase pas ta fonction fetch : il intercepte les requêtes au niveau du réseau et répond à leur place. La différence est capitale — ton code appelle vraiment fetch, avec sa vraie URL, ses vrais en-têtes, son vrai corps JSON, et reçoit une vraie Response. Aucune ligne de production n'est modifiée pour le test.

import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  // Le comportement NOMINAL : la clinique accepte la réservation.
  http.post('/api/appointments', () =>
    HttpResponse.json({ id: 'rdv_42' }, { status: 201 }),
  ),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // on efface les surcharges d'un test à l'autre
afterAll(() => server.close());

it('affiche un message d’échec quand l’API répond 500', async () => {
  // Surcharge LOCALE : juste pour ce test, le serveur tombe en panne.
  server.use(
    http.post('/api/appointments', () => new HttpResponse(null, { status: 500 })),
  );

  render(<PageRendezVous />);
  // ... parcours de réservation ...
  expect(await screen.findByRole('alert')).toHaveTextContent(/impossible de réserver/i);
});

Le server.use() local est la technique la plus rentable de tout msw : le chemin heureux est défini une fois pour toutes, et chaque test qui veut un échec le déclare sur une ligne.

Ce dernier test vaut de l'or, parce que le chemin d'erreur est celui que personne ne teste à la main. C'est exactement là que se cache l'oubli du res.ok vu dans le module Formulaires 1 — l'application affiche fièrement « Rendez-vous confirmé » alors que le serveur a renvoyé 500. Un test msw de trois lignes attrape ce bug pour toujours.

🔗 Pont — msw, des deux côtés de la barrière

C'est le même outil, la même API, souvent la même version que ce que tu utilises dans Halterofit. Seule la porte d'entrée change : msw/node en test (Node intercepte au niveau de sa couche HTTP) contre le service worker du navigateur en développement. Ton acquis mobile est directement réutilisable, y compris les réflexes fins : définir les gestionnaires nominaux dans le fichier de mise en place, surcharger localement pour les cas d'erreur, et resetHandlers() entre les tests pour qu'aucun test ne contamine le suivant.

Plus largement, c'est le troisième pont de ce module et il dit la même chose que les deux autres : tes compétences de test sont transversales. Jest → Vitest, @testing-library/react-native@testing-library/react, msw → msw, Maestro → Playwright. Quand un recruteur te demande « as-tu de l'expérience en test frontend web ? », la réponse honnête et forte n'est pas « non, seulement en mobile » : c'est « oui — j'utilise Testing Library et msw au quotidien en React Native, et l'outillage web est le même à un préfixe près ».

Ce qui vaut la peine d'être testé

Savoir écrire un test est la partie facile ; savoir lesquels écrire est le vrai métier — et c'est là qu'un junior se distingue, parce que la réponse naïve (« tout ») produit une suite lente, fragile, qui finit désactivée.

La règle du risque

On n'alloue pas l'effort par type de fonction, mais par coût du bug : si ce morceau de code casse silencieusement, qu'est-ce qui se passe ? La hiérarchie qui en découle est assez universelle :

Niveau de risqueExemples dans RendezVousEffort
1. Perte ou corruption de données Une réservation envoyée deux fois ; un formulaire vidé alors que l'envoi a échoué ; une erreur serveur affichée comme un succès. Tests systématiques, y compris les chemins d'erreur.
2. Règles métier validateAppointment : refuser une date passée, un email malformé, un service absent. Tests unitaires denses, un par règle et par cas limite.
3. Câblage L'erreur du validateur atteint-elle l'écran ? Le bouton est-il vraiment désactivé ? La valeur saisie arrive-t-elle dans le corps de la requête ? Un ou deux tests d'intégration par écran.
4. Plomberie Un composant de présentation qui affiche la prop qu'il reçoit ; un import ; un fichier de constantes. Rien. Le typage et la lecture suffisent.

Tu reconnais cette échelle : c'est celle de la stratégie de test d'Halterofit, au vocabulaire près. Elle est universelle parce qu'elle ne dit rien de la technologie — seulement « le test doit être proportionné à ce que le bug coûte ».

Ne teste pas le framework

Corollaire souvent violé. React et Next.js sont testés par leurs auteurs ; ton travail n'est pas de revérifier que useState met à jour une valeur ou que <input value> affiche ce qu'on lui donne. Les tests suivants ont l'air sérieux et ne valent rien :

  • « le composant se rend sans planter » — vrai de presque tout composant ;
  • « taper dans le champ met l'état à jour » — c'est le contrat de React, pas le tien ;
  • « le composant affiche le texte de sa prop » — c'est du JSX, pas une règle métier ;
  • « la liste rend N éléments pour N données » — sauf si le filtrage ou le tri est justement la logique à prouver.

Le test de fumée « ça se rend » a une seule justification : un garde grossier sur un écran complexe. Assumé comme tel, c'est honnête ; vendu comme de la couverture, c'est de l'illusion — et la couverture de code est une mesure trompeuse : elle mesure les lignes exécutées, pas les comportements vérifiés. Une suite sans une seule assertion peut afficher 90 %.

Le meilleur rapport valeur / effort : extraire, puis tester la fonction pure

Le geste le plus rentable du module ne parle pas de tests, il parle d'architecture. Une logique enfouie dans un composant est chère à tester (il faut rendre, cliquer, attendre, interroger le DOM pour une règle de trois lignes) ; extraite dans une fonction pure, elle se teste en une milliseconde avec vingt cas limites. C'est la raison d'être du validateur pur du module Formulaires 2 : validateAppointment(values) ne connaît ni React, ni le DOM, ni le réseau — on lui donne un objet, elle rend un dictionnaire d'erreurs.

// Un seul test, table de cas : chaque ligne est une règle du cahier des charges.
const CAS = [
  { nom: 'nom vide',        values: { ...VALIDE, nom: '   ' },              champ: 'nom' },
  { nom: 'email sans @', values: { ...VALIDE, email: 'patrick.ca' },  champ: 'email' },
  { nom: 'date passée',     values: { ...VALIDE, date: '2020-01-01' },      champ: 'date' },
  { nom: 'service absent',  values: { ...VALIDE, service: '' },             champ: 'service' },
] as const;

it.each(CAS)('signale une erreur pour : $nom', ({ values, champ }) => {
  expect(validateAppointment(values)[champ]).toBeDefined();
});

it('ne signale aucune erreur pour un formulaire valide', () => {
  expect(validateAppointment(VALIDE)).toEqual({});
});

it.each génère un test par ligne du tableau, avec un nom lisible dans le rapport. Ajouter une règle métier, c'est ajouter une ligne — et le rapport d'échec te dit laquelle.

Corollaire : une fois la règle couverte à l'unité, ne la re-teste pas à travers l'interface. Un seul test d'intégration suffit à prouver le câblage — que l'erreur du validateur atteint l'écran — sans repasser les quinze cas limites. On appelle ça « ne pas re-tester à travers les couches » : c'est la différence entre une suite qui tourne en huit secondes et une qui tourne en quatre minutes.

✍️ Exercice de lecture

Une base de code contient ces deux tests, dans deux fichiers différents. Ils vérifient la même règle métier : le email doit contenir une arobase.

// (A) — lib/validation/rendez-vous.test.ts
it('refuse un email sans arobase', () => {
  expect(validateAppointment({ ...VALIDE, email: 'patrick.ca' }).email).toBeDefined();
});

// (B) — app/rendez-vous/PageRendezVous.test.tsx
it('affiche une erreur quand le email est invalide', async () => {
  render(<PageRendezVous />);
  await userEvent.type(screen.getByLabelText('Courriel'), 'patrick.ca');
  await userEvent.click(screen.getByRole('button', { name: /réserver/i }));
  expect(await screen.findByRole('alert')).toHaveTextContent(/email/i);
});

Questions : (1) Est-ce un doublon à supprimer ? (2) Si on devait n'en garder qu'un, lequel et pourquoi ? (3) Comment répartirais-tu l'effort si la règle du email avait huit cas limites au lieu d'un ?

Voir le corrigé

(1) Non — ils ne testent pas la même chose, même s'ils parlent de la même règle. (A) teste la règle : « cette entrée produit cette erreur ». (B) teste le câblage : « l'erreur atteint l'écran, dans un élément annoncé comme une alerte, avec un texte pertinent ». Deux promesses distinctes, qui cassent pour des raisons distinctes — on peut parfaitement avoir une règle impeccable dont le message n'est jamais affiché, et c'est même un bug très fréquent.

(2) S'il faut trancher : (B). Il couvre indirectement une partie de (A) — si le validateur ne rejetait pas 'patrick.ca', aucune alerte n'apparaîtrait — alors que (A) ne dit rien de l'écran. Un test d'intégration attrape plus de catégories de bugs par test écrit : c'est l'argument du « trophée ». Mais c'est un défaut, pas une règle : si la logique métier était complexe (disponibilités, tarification), l'unitaire redeviendrait le plus précieux.

(3) La répartition attendue en entrevue : les huit cas limites vont dans (A), en table de cas, à une milliseconde chacun ; (B) reste unique, avec un cas représentatif, parce que son rôle est de prouver que le fil est branché. Couverture métier complète et suite rapide ; l'erreur inverse donne une suite huit fois plus lente pour la même information.

🧠 Quiz éclair

Pourquoi ne faut-il pas re-tester les quinze cas limites du validateur à travers l'interface ?

Parce que l'information est déjà acquise à moindre coût. Les règles métier se testent à l'unité sur la fonction pure — une milliseconde par cas. Le test d'intégration a un autre rôle : prouver le câblage, c'est-à-dire qu'une erreur produite par le validateur atteint réellement l'écran, dans un élément annoncé. Un seul cas représentatif suffit pour ça. Repasser les quinze cas en intégration multiplie la durée de la suite sans rien apprendre de neuf.

Lire une suite de tests qu'on ne connaît pas

Dernière section, peut-être la plus rentable pour un stage : en équipe comme en test technique, tu passeras bien plus de temps à lire des tests qu'à en écrire. Voici la démarche, en trois passes, dans cet ordre.

Passe 1 — les noms, et seulement les noms. Ne lis que les describe et les it. Tu obtiens en trente secondes la spécification du comportement attendu — la meilleure documentation du module, à jour par construction. Si à la fin de cette passe tu ne sais toujours pas ce que fait le composant, tu viens d'apprendre quelque chose : les tests sont mal nommés, donc probablement écrits après coup pour la couverture, donc peu fiables comme filet.

Passe 2 — le décor. Regarde ce qui est partagé : beforeEach, vi.mock, gestionnaires msw, objets de données réutilisés. Cette passe répond à qu'est-ce qui est faux dans ce test ? Chaque doublure marque une frontière : un fichier qui simule douze modules dit un composant très couplé ; un fichier qui ne simule que le réseau dit une architecture saine.

Passe 3 — les assertions. Seulement maintenant, lis les expect et pose la question du niveau : parlent-ils de ce que l'utilisateur voit (findByRole, toBeDisabled) ou de rouages internes (noms de classes, état interne) ? La réponse te dit, mieux que n'importe quelle métrique, si cette suite va t'aider ou te gêner.

Les it.todo d'un test technique

Format très répandu d'exercice de stage : un dépôt avec un fichier de test déjà écrit… mais vide. Des it.todo('…') — des tests déclarés sans corps, que le lanceur affiche comme « à faire » sans les compter en échec. Ta tâche : les remplir, ou écrire le code qui les fera passer.

app/rendez-vous/PageRendezVous.test.tsx
describe('PageRendezVous', () => {
  it.todo('affiche une erreur quand le email est invalide');
  it.todo('n’envoie rien tant que le formulaire est invalide');
  it.todo('désactive le bouton pendant l’envoi');
  it.todo('affiche un message d’échec quand l’API répond 500');
  it.todo('vide les fields après une réservation réussie');
});

Ce n'est pas une liste de corvées : c'est la grille de notation, écrite noir sur blanc par l'évaluateur.

Cette liste te dit quatre choses, et savoir les extraire vaut des points avant la première ligne de code :

  • Le périmètre exact. Cinq comportements, pas six. Rien sur le design, l'animation ou la persistance. Livrer une bibliothèque de composants magnifique sans faire passer les cinq tests, c'est avoir échoué à l'exercice.
  • Le niveau attendu. « désactive le bouton pendant l'envoi » et « affiche un message d'échec quand l'API répond 500 » ne sont pas des tests de fonction pure : ce sont des tests d'intégration avec réseau simulé. L'évaluateur annonce qu'il veut te voir manier msw et findBy.
  • Les cas d'erreur comptent autant que le cas heureux. Sur cinq entrées, trois concernent des chemins fâcheux : ce qu'on évalue chez un développeur frontend n'est pas de faire marcher le cas nominal, c'est de penser aux autres.
  • Le vocabulaire de l'interface. « vide les champs après une réservation réussie » implique qu'il existe un état de succès et une réinitialisation. Les noms de tests décrivent l'interface attendue, avant même la maquette.

Deux conseils tactiques. Si tu manques de temps, remplis-les dans l'ordre du risque, pas du fichier : le test qui empêche une double réservation vaut plus que celui qui vérifie que les champs se vident. Et ne supprime jamais un it.todo que tu n'as pas fait : laisse-le, mentionne-le dans ton message d'accompagnement. Un candidat lucide sur ce qui manque inspire bien plus confiance qu'un candidat qui efface les traces.

🧠 Quiz éclair

On te livre un fichier de test rempli de it.todo(...). Qu'est-ce que ça t'apprend avant même d'écrire une ligne ?

C'est la grille de notation de l'évaluateur. Elle te donne le périmètre exact (ces comportements-là, pas d'autres), le niveau attendu (un « affiche un message d'échec quand l'API répond 500 » annonce qu'on veut de l'intégration avec réseau simulé), le vocabulaire de l'interface à construire, et surtout la proportion de chemins d'erreur — souvent majoritaire, parce que c'est là qu'on juge un développeur. Si le temps manque, on les remplit par ordre de risque, et on ne supprime jamais ceux qu'on n'a pas faits.

🎤 En entrevue

« Comment tu testes un formulaire ? »

« Je le sépare en deux niveaux. La validation vit dans une fonction pure, hors composant, qui prend les valeurs et rend un dictionnaire d'erreurs : je la teste directement, avec une table de cas — un cas par règle métier et par cas limite. C'est rapide et exhaustif, chaque test coûte une milliseconde. »

« Ensuite j'écris deux ou trois tests d'intégration avec Testing Library, qui rendent l'écran complet et jouent le parcours de l'utilisateur : getByLabelText pour les champs, userEvent.type et userEvent.click, et une assertion sur ce qui s'affiche. Le cas heureux, un cas d'erreur de validation, et un cas d'échec réseau — celui-là avec msw, en surchargeant la route pour qu'elle réponde 500. Ces trois-là couvrent ce qui casse vraiment : le câblage entre la règle, l'état et l'écran. »

« Et je fais attention à deux choses : le bouton doit être désactivé pendant l'envoi — c'est un test à part entière, parce que le double-clic crée des doublons en base — et j'utilise findBy plutôt que getBy pour tout ce qui apparaît après une requête. »

« Pourquoi getByRole plutôt que getByTestId ? »

« Pour deux raisons, et la seconde est la meilleure. D'abord la robustesse : un data-testid ou une classe CSS, ça change à chaque refonte de design, alors que “il y a un bouton qui dit Réserver”, ça ne change pas — c'est le produit. Un test accroché au rôle ne casse pas pour rien. »

« Ensuite, et c'est le vrai argument : un test qui passe par le rôle vérifie aussi l'accessibilité. Testing Library interroge l'arbre d'accessibilité, le même que lit un lecteur d'écran. Donc si quelqu'un remplace mon bouton par un <div> cliquable, ou si un champ perd son étiquette, la requête ne trouve plus rien et le test échoue. Ma suite de tests devient un audit d'accessibilité continu, sans outil supplémentaire. Avec getByTestId, le test passerait au vert sur une page inutilisable au clavier. »

« Cela dit je ne suis pas dogmatique : le testid reste légitime en dernier recours, quand un élément n'a réellement ni rôle ni texte stable — un conteneur de graphique, par exemple. »

« Qu'est-ce que tu ne testes PAS, et pourquoi ? »

« Je ne teste pas le framework. React garantit que useState met à jour une valeur et que <input value> affiche ce qu'on lui donne : le revérifier n'ajoute aucune information et me coûte de la maintenance. Donc pas de test du type “le composant affiche la prop qu'il reçoit”. »

« Je ne teste pas les détails d'implémentation non plus : les noms de classes CSS, la structure des div, l'état interne d'un composant. Ce sont les choses que je veux pouvoir changer sans casser mes tests — sinon mes tests deviennent un frein au refactor, ce qui est exactement l'inverse de leur raison d'être. »

« Et j'alloue le reste par risque : ce qui perd ou duplique des données d'abord, puis les règles métier, puis le câblage entre les deux. La plomberie et les composants de présentation purs, je les laisse au typage et à la relecture. Je préfère quinze tests que je fais tourner à chaque sauvegarde à deux cents tests que l'équipe finit par désactiver parce qu'ils sont lents et rouges pour rien. »

⚠️ Piège fréquent — tester l'implémentation au lieu du comportement

C'est le défaut qui donne mauvaise réputation aux tests, et il se reconnaît à un symptôme unique : tu changes du code sans changer le produit, et des tests deviennent rouges.

Formes courantes : chercher par container.querySelector('.btn') ou par data-testid alors qu'un rôle existe ; asserter sur un nom de classe plutôt que sur ce que l'utilisateur lit ; inspecter l'état interne ; vérifier qu'une fonction interne a été appelée plutôt que son effet visible ; et le cas extrême, les tests dits instantanés (snapshots) sur des arbres entiers, qui cassent au moindre espace et qu'on finit par régénérer aveuglément — ce qui les vide de tout sens.

L'épreuve décisive : ton test survit-il à une réécriture complète du composant ? Passage de useState à useReducer, découpage en sous-composants, migration vers React Hook Form — un test de comportement reste vert, parce que la clinique se réserve toujours de la même façon. Écris tes assertions dans le vocabulaire de l'utilisateur.

À retenir

Un test est un contrat exécutable et la seule documentation qui ne peut pas mentir. Le frontend met le poids sur l'intégration, parce que ses bugs vivent dans le câblage. Un fichier de test, c'est describe / it / expect, en trois temps Arrange-Act-Assert, avec des noms qui forment une phrase. Vitest est un Jest plus rapide adossé à Vite — même API, vi au lieu de jest — et jsdom lui fournit un faux DOM sans mise en page ni CSS réels. Testing Library cherche les éléments comme un humain : getByRole et getByLabelText d'abord, le texte ensuite, getByTestId en dernier recours — et le rôle vérifie l'accessibilité en prime. getBy exige une présence immédiate, queryBy prouve une absence, findBy attend une apparition. userEvent (asynchrone, à await) simule un vrai humain ; fireEvent non. Les doublures : vi.fn() pour une prop, vi.mock() pour un module de framework, vi.spyOn pour une sortie attendue, msw pour le réseau. Enfin, alloue par risque : perte de données > règles métier > câblage > plomberie, ne teste pas le framework, extrais tes règles en fonctions pures, et ne re-teste pas la même chose à deux couches.

Et ailleurs : l'idée maîtresse dépasse largement les tests : choisis toujours le point d'accroche le plus stable disponible. Un test s'accroche à un rôle plutôt qu'à une classe CSS pour la même raison qu'une API s'accroche à un contrat plutôt qu'à une structure de table, ou qu'un script d'automatisation cible un identifiant sémantique plutôt qu'une position à l'écran. La question est toujours : parmi tout ce à quoi je peux me raccrocher, qu'est-ce qui a le moins de raisons de bouger ? Second réflexe transférable, le coût comparé : « ce test coûte-t-il moins cher que le bug qu'il empêche ? » est l'instance d'une question qu'on te posera partout — cette abstraction coûte-t-elle moins cher que la duplication qu'elle supprime ? Savoir dire « je ne teste pas ça, et voici pourquoi » est un signe de séniorité bien plus fort qu'un pourcentage de couverture.

💡 Ce qui vient ensuite

Tu viens d'apprendre à juger un test. Le module suivant, Lire et critiquer du code frontend, élargit ce geste : parcourir une base de code inconnue, repérer les odeurs, formuler une critique utile sans être désagréable, et répondre à « qu'est-ce que tu changerais dans ce code ? ». Puis l'Étude de cas reprend l'application RendezVous du début à la fin — énoncé, code, tests à compléter.

🗂️ L'aide-mémoire
Les trois familles de requêtes, et ce qu'elles font quand l'élément manque
getBy lance — pour une présence immédiate ; queryBy rend null — pour prouver une absence ; findBy rend une promesse et réessaie — pour une apparition différée
Ce que findBy fait de plus que getBy
il attend : findBywaitFor + getBy. D'où le await — et il va dans le expect, sinon on compare une promesse
La requête à essayer en premier
getByRole, avec le nom accessible : c'est le rang 1 du classement officiel. data-testid est le dernier recours, pas le réflexe
Ce qu'une assertion négative ne prouve pas
rien du tout : elle est satisfaite par le néant — un render() oublié la rend verte. La parade : casser volontairement le code et vérifier que le test rougit
Ce qui fait un bon nom de test
un verbe au présent et une condition — « affiche X quand Y ». Jamais un nom de fonction interne : c'est ce qu'on lira, seul et en rouge, dans un rapport d'intégration continue
fireEvent contre userEvent
fireEvent émet un événement brut ; userEvent rejoue la séquence humaine complète — vérifier que l'élément est actionnable, déplacer le pointeur, donner le focus, puis émettre
Le réglage qui dit qu'un projet teste des composants
environment: 'jsdom' dans la configuration. Avec 'node', le projet ne teste que de la logique
Ce que jsdom ne sait pas faire
aucune mise en page — tout élément mesure zéro — et le CSS des feuilles n'est pas résolu. Tester une couleur, une visibilité ou un responsive y est une illusion
Ce qu'il faut faire après un vi.spyOn
mockRestore(). Sans lui, l'espion fuit dans les tests suivants du fichier : le test isolé passe, la suite complète échoue
Ce que la couverture de code mesure
les lignes exécutées, pas les comportements vérifiés. Une suite sans un seul expect peut afficher 90 %