Cours JavaScript et React · Module 13: Tester React
React Testing Library : le panneau de contrôle de mission
Dans cette leçon9
Imagine un test qui vérifie que la variable isOpen dans l'état d'un composant vaut true. Tu renommes la variable en expanded, l'application fonctionne exactement pareil, et le test échoue. Un tel test surveille des détails d'implémentation, pas ce que voit le pilote. Après quelques refactorisations, l'équipe cesse de faire confiance aux tests, car le voyant rouge s'allume sans raison.
React Testing Library (RTL) est une bibliothèque qui change notre façon de tester les composants React. Au lieu de tester l'implémentation interne, RTL se concentre sur ce que l'utilisateur voit et fait : exactement comme un panneau de contrôle de mission qui ne montre au pilote que ce qui compte.
Le principe fondamental de RTL
« Plus tes tests ressemblent à la façon dont ton logiciel est utilisé, plus ils peuvent t'inspirer confiance. » - Kent C. Dodds
Tout le reste découle de ce principe : on cherche les éléments comme le ferait un utilisateur, par rôle, label et texte, et non par classes CSS ou noms de variables.
Rendu des composants
La fonction render est le socle de RTL : elle monte un composant dans un document jsdom, une implémentation du DOM qui tourne dans Node.js (ce n'est pas le DOM virtuel de React). L'objet screen te donne des requêtes sur tout le document :
1import { render, screen } from '@testing-library/react';
2import SpaceshipDashboard from './SpaceshipDashboard';
3
4test('renders spaceship dashboard', () => {
5 render(<SpaceshipDashboard pilotName="Nova" />);
6 // Le composant est maintenant disponible pour les tests
7 expect(screen.getByText('Welcome, Nova!')).toBeInTheDocument();
8});Après render, le composant est dans le document, et screen.getByText le trouve comme un utilisateur le lirait. Tu n'as pas à faire le ménage : RTL appelle automatiquement cleanup après chaque test, ce qui démonte les composants, dès que le lanceur de tests fournit une fonction globale afterEach. Jest le fait toujours, Vitest quand tu actives l'option globals: true. Ainsi, chaque test commence avec un document vide et ne voit jamais les restes des précédents.
Requêtes (queries) : trouver les éléments
RTL propose trois familles de requêtes, chacune pour un usage différent. Elles se distinguent par leur comportement quand l'élément est absent.
getBy : trouve et exige
Utilise getBy quand l'élément doit exister. La requête lance une erreur s'il manque, et aussi quand plus d'un élément correspond :
1// Lève une erreur si l'élément n'existe PAS
2const heading = screen.getByText('Mission Control');
3const input = screen.getByRole('textbox');
4const img = screen.getByAltText('spaceship');Pour plusieurs éléments, il existe la variante getAllBy, qui renvoie un tableau. Tu la découvriras de plus près, avec queryAllBy et within, plus loin dans cette leçon.
queryBy : trouve mais n'exige pas
queryBy est pratique pour vérifier que quelque chose n'est pas à l'écran :
1// Retourne null si l'élément n'existe PAS (pas d'erreur)
2const error = screen.queryByText('Error occurred');
3expect(error).not.toBeInTheDocument();Si tu utilisais getByText ici, le test échouerait dès la recherche, avant d'atteindre l'assertion.
findBy : trouve de façon asynchrone
findBy attend que l'élément apparaisse, donc il renvoie une Promise et demande un await :
1// Attend que l'élément apparaisse (retourne une Promise)
2const data = await screen.findByText('Data loaded');
3expect(data).toBeInTheDocument();En coulisses, il combine getBy avec waitFor. Par défaut, il attend jusqu'à 1000 ms, puis signale une erreur.
Types de sélecteurs
Chaque famille de requêtes a des variantes. Le nom d'une requête, c'est la famille plus la méthode de recherche, par exemple getByRole ou findByText.
ByRole : la bonne pratique !
Un rôle ARIA, c'est ce qu'est un élément pour un lecteur d'écran : un bouton, un titre, un champ de texte. L'option name correspond au nom accessible, le plus souvent le texte ou le label :
1// Recherche par rôle ARIA : la meilleure approche !
2screen.getByRole('button', { name: 'Launch' });
3screen.getByRole('heading', { level: 1 });
4screen.getByRole('textbox', { name: /pilot name/i });
5screen.getByRole('checkbox', { checked: true });
6screen.getByRole('link', { name: 'Dashboard' });Si getByRole ne trouve pas un bouton, c'est souvent le signe d'un problème d'accessibilité dans le composant lui-même, et non dans le test. Pour utiliser cette requête en connaissance de cause, tu dois savoir deux choses : d'où un élément tient son rôle, et d'où il tient son nom.
Les rôles intégrés aux balises
Le test doit trouver la liste des missions, mais le code du composant n'a aucun attribut role ? Il n'en a pas besoin. La plupart des balises HTML ont un rôle par défaut (implicite), que le navigateur et RTL lisent tout seuls :
| Balise | Rôle |
|---|---|
<button> | button |
<a href="..."> | link |
<h1> à <h6> | heading (l'option level est le numéro du titre) |
<input type="text">, <textarea> | textbox |
<input type="checkbox"> | checkbox |
<select> | combobox |
<ul> ou <ol> et <li> | list et listitem |
<table>, <tr>, <th>, <td> | table, row, columnheader, cell |
Deux pièges sont à retenir. Un lien sans attribut href n'a pas le rôle link, et une liste déroulante <select> est un combobox pour un lecteur d'écran, donc tu la cherches avec getByRole('combobox'), et non « select ».
Les rôles donnés avec l'attribut role
Tu construis généralement une jauge de carburant, un message d'alerte ou des onglets avec des éléments div et p, qui n'ont pas de rôle propre. Tu leur donnes alors un rôle explicite avec l'attribut role et tu décris leur état avec des attributs aria-* :
1function ShieldMeter({ level }) {
2 return (
3 <div>
4 <div
5 role="progressbar"
6 aria-label="Shield level"
7 aria-valuenow={level}
8 aria-valuemin={0}
9 aria-valuemax={100}
10 />
11 {level < 20 && <p role="alert">Shields critical!</p>}
12 </div>
13 );
14}role="progressbar" transforme un simple div en indicateur de progression : aria-valuenow, aria-valuemin et aria-valuemax donnent sa valeur et sa plage, et aria-label lui donne un nom. role="alert" signale un message urgent que le lecteur d'écran annonce immédiatement. Les informations plus calmes, comme « Analyse terminée », reçoivent role="status", et les onglets trois rôles à la fois : tablist sur le conteneur, tab sur chaque bouton et tabpanel sur le contenu.
Le test vérifie la jauge et l'alerte comme un lecteur d'écran les lirait :
1test('warns when shields are critical', () => {
2 render(<ShieldMeter level={15} />);
3
4 const meter = screen.getByRole('progressbar', { name: 'Shield level' });
5 expect(meter).toHaveAttribute('aria-valuenow', '15');
6 expect(screen.getByRole('alert')).toHaveTextContent('Shields critical!');
7});Remarque qu'on ne cherche pas le message avec l'option name. Les rôles alert et status ne tirent pas leur nom du contenu, donc c'est le matcher toHaveTextContent qui vérifie le texte, et toHaveAttribute compare la valeur d'un attribut. Tous deux viennent de jest-dom, dont on parle juste après.
D'où vient le nom d'un élément
L'option name compare le nom accessible de l'élément, c'est-à-dire ce que le lecteur d'écran annonce avec le rôle. Le nom vient le plus souvent de l'une de ces trois sources :
1// 1. Depuis le contenu : boutons, liens, titres
2// <button>Launch</button>
3screen.getByRole('button', { name: 'Launch' });
4
5// 2. Depuis aria-label, quand l'élément n'a pas de texte lisible
6// <button aria-label="Close panel">×</button>
7screen.getByRole('button', { name: 'Close panel' });
8
9// 3. Depuis un label relié par htmlFor et id...
10// <label htmlFor="pilot">Pilot name</label>
11// <input id="pilot" type="text" />
12screen.getByRole('textbox', { name: 'Pilot name' });
13
14// ...ou depuis un label qui entoure le champ
15// <label><input type="checkbox" /> Autopilot</label>
16screen.getByRole('checkbox', { name: 'Autopilot' });Pour RTL, un champ sans label a un nom vide, même s'il a un placeholder, donc une requête avec l'option name ne le trouvera pas. C'est une bonne nouvelle : le test signale tout de suite le label manquant, et un placeholder disparaît dès qu'on tape du texte, il ne peut donc pas remplacer un label.
Filtrer par état : checked, pressed, selected
Quand l'écran contient plusieurs éléments du même rôle, tu peux choisir celui qui est dans un état précis :
1// Case cochée (checkbox ou radio)
2screen.getByRole('checkbox', { name: 'Autopilot', checked: true });
3
4// Interrupteur enfoncé : <button aria-pressed="true">Orbit</button>
5screen.getByRole('button', { pressed: true });
6
7// Onglet sélectionné ou option choisie dans une liste déroulante
8screen.getByRole('tab', { selected: true });
9screen.getByRole('option', { name: 'Mars', selected: true });checked lit l'état d'une case à cocher, pressed lit l'attribut aria-pressed des boutons qui fonctionnent comme un interrupteur, et selected lit l'attribut aria-selected des onglets ou la sélection d'une option dans un <select>. Quand une requête avec un tel filtre ne trouve rien, tu sais tout de suite que le composant affiche un autre état que celui prévu par le test.
ByText : recherche par texte
Le texte convient bien aux éléments non interactifs, comme les messages et les paragraphes :
1screen.getByText('Mission Status: Active');
2screen.getByText(/mission/i); // regex, insensible à la casseUne chaîne simple doit correspondre entièrement, tandis qu'une expression régulière permet de chercher un fragment sans tenir compte de la casse.
ByLabelText : recherche par le label du formulaire
Les champs de formulaire se trouvent le mieux par leur label, comme le fait un utilisateur :
1// <label htmlFor="speed">Speed</label>
2// <input id="speed" />
3screen.getByLabelText('Speed');La requête ne fonctionne que si le label est correctement relié au champ, donc le test surveille l'accessibilité au passage.
ByPlaceholderText : recherche par le placeholder
Le placeholder est l'indication affichée dans un champ vide :
1screen.getByPlaceholderText('Enter coordinates');Considère-le comme un choix de secours, car le placeholder disparaît dès qu'on tape du texte et ne remplace pas un label.
ByTestId : en dernier recours
L'attribut data-testid est un marqueur visible uniquement par les tests :
1// <div data-testid="fuel-gauge">...</div>
2screen.getByTestId('fuel-gauge');L'utilisateur ne le voit jamais, donc un test basé dessus en dit le moins sur l'expérience réelle.
Priorité des sélecteurs
RTL recommande la priorité suivante (du meilleur au moins bon) :
- getByRole : accessibilité, comment l'utilisateur voit l'élément
- getByLabelText : formulaires
- getByPlaceholderText : quand il n'y a pas de label
- getByText : éléments non interactifs
- getByDisplayValue : valeur actuelle d'un champ
- getByAltText : images
- getByTitle : attribut title
- getByTestId : en dernier recours, quand rien d'autre ne convient
Plusieurs éléments et requêtes à l'intérieur d'un élément
Une liste de missions a plusieurs cartes, un tableau plusieurs lignes, et les notifications ont souvent des boutons portant le même nom. getBy lancerait ici une erreur, car plus d'un élément correspond. Pour ces cas, chaque famille de requêtes a une variante All, et la fonction within limite la recherche à l'intérieur d'un seul élément. Supposons que CrewTable affiche un tableau avec une ligne d'en-tête et une ligne par membre d'équipage :
1import { render, screen, within } from '@testing-library/react';
2
3test('lists the crew in a table', () => {
4 const crew = [
5 { name: 'Nova', role: 'Pilot' },
6 { name: 'Astro', role: 'Engineer' },
7 ];
8 render(<CrewTable crew={crew} />);
9
10 // getAllBy renvoie un tableau et lance une erreur si rien ne correspond
11 const rows = screen.getAllByRole('row');
12 expect(rows).toHaveLength(3); // l'en-tête et deux membres
13
14 // within limite les requêtes à l'intérieur d'une seule ligne
15 const cells = within(rows[1]).getAllByRole('cell');
16 expect(cells[0]).toHaveTextContent('Nova');
17
18 // queryAllBy renvoie un tableau vide au lieu d'une erreur
19 expect(screen.queryAllByRole('alert')).toHaveLength(0);
20});getAllBy (et son équivalent asynchrone findAllBy) lance une erreur quand il ne trouve aucun élément, tandis que queryAllBy renvoie alors un tableau vide, ce qui en fait le bon outil pour vérifier que des éléments ont disparu. within(element) donne les mêmes requêtes que screen, mais cherche seulement à l'intérieur de l'élément indiqué. De la même façon, tu trouves le bouton de fermeture d'une notification précise : within(screen.getByRole('alert')).getByRole('button', { name: 'Close' }). Tu importes within depuis @testing-library/react, avec render et screen.
Matchers jest-dom
La bibliothèque @testing-library/jest-dom ajoute des matchers spéciaux. Tu l'actives une seule fois, en important @testing-library/jest-dom dans le fichier jest.setup.js que tu as indiqué dans l'option setupFilesAfterEnv lors de la première leçon :
1// Visibilité
2expect(element).toBeVisible();
3expect(element).toBeInTheDocument();
4
5// Attributs et état
6expect(button).toBeDisabled();
7expect(input).toBeRequired();
8expect(input).toHaveValue('Apollo');
9expect(checkbox).toBeChecked();
10expect(link).toHaveAttribute('href', '/dashboard');
11
12// Classes CSS
13expect(element).toHaveClass('active');
14
15// Texte
16expect(element).toHaveTextContent('Mission');
17
18// Styles
19expect(element).toHaveStyle({ color: 'rgb(0, 128, 0)' });toBeInTheDocument vérifie seulement la présence dans le document, tandis que toBeVisible vérifie aussi que l'élément n'est pas caché, par exemple par display: none. toHaveStyle compare le style calculé par jsdom, qui renvoie les couleurs en notation rgb(...), donc tu indiques une couleur sous la forme rgb(0, 128, 0) ou #008000 : le simple nom green ne passera pas.
Rendu avec contexte
Les composants ont souvent besoin de providers (Router, Theme, Store). Au lieu de les répéter dans chaque test, on crée notre propre fonction de rendu avec l'option wrapper :
1function renderWithProviders(ui, options = {}) {
2 function Wrapper({ children }) {
3 return (
4 <ThemeProvider theme="dark">
5 <MissionProvider>
6 {children}
7 </MissionProvider>
8 </ThemeProvider>
9 );
10 }
11 return render(ui, { wrapper: Wrapper, ...options });
12}
13
14// Utilisation
15test('renders themed dashboard', () => {
16 renderWithProviders(<Dashboard />);
17 expect(screen.getByText('Dark Mode')).toBeInTheDocument();
18});renderWithProviders accepte les mêmes arguments que render, donc les tests se ressemblent presque, et la configuration vit à un seul endroit.
Debug : quand les tests échouent
Quand un test échoue et que tu ne sais pas pourquoi, utilise screen.debug() pour inspecter l'état actuel du DOM :
1test('shows mission status', () => {
2 render(<MissionPanel />);
3
4 // Affiche l'état actuel du DOM dans la console
5 screen.debug();
6
7 // Tu peux aussi déboguer un élément spécifique
8 const panel = screen.getByRole('main');
9 screen.debug(panel);
10});screen.debug() affichera le HTML de tout le composant rendu, ce qui aide à comprendre pourquoi un sélecteur ne trouve pas un élément. C'est comme un système de diagnostic à bord du vaisseau : quand quelque chose ne fonctionne pas, tu vérifies d'abord les logs !
Mon conseil : commence par getByRole et descends dans la liste des priorités seulement si tu y es obligé. Dans la prochaine leçon, on ajoutera la simulation des clics et de la saisie.
React Testing Library est le socle des tests modernes de composants React. Au lieu de tester des détails d'implémentation, tu testes ce qui compte vraiment : l'expérience utilisateur.
Code de cette leçon : App.jsx
1import React, { useState } from 'react';
2
3const MISSIONS = [
4 { id: 1, name: 'Apollo 11', status: 'completed', crew: 3 },
5 { id: 2, name: 'Artemis I', status: 'active', crew: 0 },
6 { id: 3, name: 'Mars Explorer', status: 'planned', crew: 6 },
7 { id: 4, name: 'Jupiter Probe', status: 'active', crew: 4 },
8];
9
10const STATUS_LABELS = {
11 completed: 'terminée',
12 active: 'active',
13 planned: 'prévue',
14};
15
16// La requête RTL qui trouve l’élément ci-dessus dans son état actuel
17function Query({ code }) {
18 return <code className="query">{code}</code>;
19}
20
21function MissionCard({ mission, selected, onSelect }) {
22 return (
23 <li className={'mission-card status-' + mission.status}>
24 <h3>{mission.name}</h3>
25 <p>Status: <span className="badge">{STATUS_LABELS[mission.status]}</span></p>
26 <p>Équipage : {mission.crew}</p>
27 <button
28 aria-label={'Sélectionner la mission ' + mission.name}
29 aria-pressed={selected}
30 onClick={onSelect}
31 >
32 Sélectionner
33 </button>
34 </li>
35 );
36}
37
38function MissionList() {
39 const [query, setQuery] = useState('');
40 const [onlyActive, setOnlyActive] = useState(false);
41 const [selected, setSelected] = useState(null);
42
43 const visible = MISSIONS.filter(
44 (m) =>
45 m.name.toLowerCase().includes(query.toLowerCase()) &&
46 (!onlyActive || m.status === 'active')
47 );
48 const statusText = selected ? 'Mission sélectionnée : ' + selected : 'Aucune mission sélectionnée';
49 const pressedVisible = visible.some((m) => m.name === selected);
50
51 return (
52 <div className="mission-list">
53 <div className="field">
54 <label htmlFor="search">Rechercher une mission</label>
55 <input
56 id="search"
57 type="text"
58 value={query}
59 placeholder="Saisis le nom de la mission..."
60 onChange={(e) => setQuery(e.target.value)}
61 />
62 <Query code="screen.getByRole('textbox', { name: 'Rechercher une mission' })" />
63 </div>
64
65 <div className="field">
66 <label className="checkbox">
67 <input
68 type="checkbox"
69 checked={onlyActive}
70 onChange={(e) => setOnlyActive(e.target.checked)}
71 />
72 Actives uniquement
73 </label>
74 <Query code={"screen.getByRole('checkbox', { name: 'Actives uniquement', checked: " + onlyActive + " })"} />
75 </div>
76
77 <p role="status" className="selected-info">{statusText}</p>
78 <Query code={"expect(screen.getByRole('status')).toHaveTextContent('" + statusText + "')"} />
79 <Query
80 code={pressedVisible
81 ? "screen.getByRole('button', { name: 'Sélectionner la mission " + selected + "', pressed: true })"
82 : "expect(screen.queryByRole('button', { pressed: true })).toBeNull()"}
83 />
84
85 {visible.length > 0 ? (
86 <>
87 <Query code={"expect(screen.getAllByRole('listitem')).toHaveLength(" + visible.length + ")"} />
88 <ul className="cards">
89 {visible.map((m) => (
90 <MissionCard
91 key={m.id}
92 mission={m}
93 selected={selected === m.name}
94 onSelect={() => setSelected(m.name)}
95 />
96 ))}
97 </ul>
98 </>
99 ) : (
100 <>
101 <p className="no-results">Aucune mission trouvée</p>
102 <Query code="expect(screen.queryAllByRole('listitem')).toHaveLength(0)" />
103 </>
104 )}
105 </div>
106 );
107}
108
109export default function App() {
110 return (
111 <div className="app">
112 <h1>React Testing Library - Panneau de contrôle</h1>
113 <p className="note">
114 L’aperçu ne lance pas Jest. Sous les éléments, tu vois les requêtes RTL
115 qui les trouvent dans leur état actuel : saisis un nom, coche la case ou
116 sélectionne une mission et regarde changer les options checked et pressed.
117 </p>
118 <MissionList />
119 </div>
120 );
121}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que fait la fonction cleanup() de React Testing Library, et quand est-elle appelée automatiquement ?
2. Quelle est la différence clé entre getBy et queryBy dans React Testing Library ?
Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Termine la jauge de carburant pour que le test la trouve comme un utilisateur de lecteur d'écran. ___BLANK1___ : isLow est vrai quand le niveau de carburant est inférieur à 20. ___BLANK2___ : la barre a le rôle progressbar, donc le test la trouve avec getByRole('progressbar', { name: 'Niveau de carburant' }) (aria-label donne le nom et aria-valuenow le niveau). ___BLANK3___ : l'avertissement de carburant faible a le rôle alert, qu'un lecteur d'écran annonce tout de suite. ___BLANK4___ : le bouton Faire le plein ajoute 25 points de pourcentage, mais le niveau ne doit pas dépasser 100.
- Tri horizontal
Ordonne les éléments de la requête screen.getByRole avec une option name dans le bon ordre :
- Éditeur de code
Termine le formulaire de recrutement de l'équipage CrewForm pour qu'on puisse le tester avec React Testing Library. ___BLANK1___ : l'étiquette Nom du membre d'équipage pointe avec htmlFor vers l'id du champ texte (crew-name), donc le test le trouve avec getByLabelText('Nom du membre d’équipage'). ___BLANK2___ : l'erreur du nom apparaît quand le nom, après suppression des espaces au début et à la fin, a moins de 2 caractères. ___BLANK3___ : l'erreur du rôle apparaît quand aucun rôle n'est choisi (la valeur vide de la liste). Les erreurs ont role="alert", et avec des données valides le formulaire appelle onSubmit({ name, role }) avec le nom nettoyé et vide les champs.
- Tri vertical
Ordonne les étapes du rendu et du test d'un composant dans RTL dans le bon ordre :
- Éditeur de code
Termine deux composants du panneau de commande. ToggleSwitch est une case à cocher dans une étiquette, donc le test la trouve avec getByRole('checkbox', { name: 'Boucliers' }). ___BLANK1___ : la case à cocher est contrôlée, son état coché vient de l'état on. ___BLANK2___ : après un clic, l'état prend la nouvelle valeur cochée depuis l'objet événement (le champ checked de event.target), et l'état affiché à côté passe à activé ou désactivé. NavigationList affiche les boutons des destinations du voyage. ___BLANK3___ : aria-pressed d'un bouton vaut true seulement pour la destination choisie et false pour les autres, donc le test trouve la destination choisie avec getByRole('button', { pressed: true }).