Cours JavaScript et React · Module 13: Tester React
Tester les interactions : le simulateur de vol
Dans cette leçon4
Dans un vrai vaisseau spatial, le pilote appuie sur des boutons, tourne des molettes et saisit des données au clavier. Dans les tests React, on doit simuler ces mêmes interactions pour s'assurer que l'application répond correctement. Un test de rendu seul te dit seulement que le panneau s'est allumé. Il ne te dit pas si le moteur démarre vraiment après un appui sur « Launch ».
On dispose de deux outils pour simuler les interactions : fireEvent, intégré à RTL, et la bibliothèque séparée userEvent. Les deux sont enveloppés dans act(), donc React a le temps de redessiner le composant avant que tu vérifies le résultat. Ils diffèrent par la fidélité avec laquelle ils imitent un humain, et tu verras cette différence étape par étape dans cette leçon.
fireEvent : la simulation de base
fireEvent est un outil de React Testing Library pour déclencher des événements DOM. Chaque appel envoie exactement un événement, par exemple click, à l'élément indiqué :
1import { render, screen, fireEvent } from '@testing-library/react';
2
3test('toggles engine status on button click', () => {
4 render(<EnginePanel />);
5
6 const toggleButton = screen.getByRole('button', { name: 'Toggle Engine' });
7 expect(screen.getByText('Engine: OFF')).toBeInTheDocument();
8
9 fireEvent.click(toggleButton);
10 expect(screen.getByText('Engine: ON')).toBeInTheDocument();
11
12 fireEvent.click(toggleButton);
13 expect(screen.getByText('Engine: OFF')).toBeInTheDocument();
14});Le test vérifie l'état avant le clic, après le premier et après le second, donc on voit le cycle complet de l'interrupteur. On ne regarde pas dans l'état du composant, on lit le texte, comme le ferait le pilote.
Événements de formulaire
On modifie un champ de texte avec fireEvent.change, en passant la nouvelle valeur dans target.value. Pour vérifier que le formulaire a envoyé ses données, on utilise une fonction factice, jest.fn() :
1test('updates pilot name input', () => {
2 render(<PilotForm />);
3
4 const nameInput = screen.getByLabelText('Pilot Name');
5 fireEvent.change(nameInput, { target: { value: 'Commander Nova' } });
6
7 expect(nameInput).toHaveValue('Commander Nova');
8});
9
10test('submits mission form', () => {
11 const mockSubmit = jest.fn();
12 render(<MissionForm onSubmit={mockSubmit} />);
13
14 fireEvent.change(screen.getByLabelText('Destination'), {
15 target: { value: 'Mars' }
16 });
17 fireEvent.click(screen.getByRole('button', { name: 'Launch' }));
18
19 expect(mockSubmit).toHaveBeenCalledWith({ destination: 'Mars' });
20});L'assertion toHaveBeenCalledWith vérifie que onSubmit a reçu exactement { destination: 'Mars' }. L'état interne de MissionForm ne nous intéresse pas du tout, ce qui compte, c'est ce que le composant a transmis à l'extérieur.
userEvent : la simulation réaliste
userEvent est une bibliothèque plus avancée qui simule les événements à la manière d'un vrai utilisateur. Au lieu d'un seul événement change, elle simule l'appui sur chaque touche individuelle. Tu l'installes séparément sous le nom @testing-library/user-event, et en version 14 tu commences par userEvent.setup() :
1import userEvent from '@testing-library/user-event';
2
3test('types pilot name realistically', async () => {
4 const user = userEvent.setup();
5 render(<PilotForm />);
6
7 const nameInput = screen.getByLabelText('Pilot Name');
8 await user.type(nameInput, 'Commander Nova');
9
10 expect(nameInput).toHaveValue('Commander Nova');
11});L'objet user créé par setup() garde l'état du clavier et du pointeur pendant tout le test. Toutes ses méthodes sont asynchrones, c'est pourquoi chacune est précédée d'un await.
Différences clés : fireEvent vs userEvent
Comparons ce qui se passe vraiment en coulisses avec les deux approches :
1// fireEvent : rapide mais moins réaliste
2fireEvent.change(input, { target: { value: 'text' } });
3// Déclenche UNIQUEMENT l'événement change
4
5// userEvent : plus lent mais réaliste
6await user.type(input, 'text');
7// Clique d'abord sur le champ (pointer, mouse, focus, click),
8// puis pour CHAQUE caractère : keydown, keypress, beforeinput, input, keyup
9// (le onChange de React réagit à chaque événement input)fireEvent.change remplace la valeur d'un coup et ne vérifie ni si le champ est désactivé, ni s'il est seulement cliquable. userEvent passe par toute la séquence comme un humain et fait au passage les mêmes contrôles qu'un navigateur : il ne saisira pas de texte dans un champ qui a l'attribut disabled et lance une erreur quand tu cliques sur un élément avec la règle CSS pointer-events: none. En revanche, il ne remarquera pas qu'un élément en recouvre un autre, car jsdom ne calcule pas la mise en page. Ma recommandation : utilise userEvent par défaut, et garde fireEvent pour les événements que userEvent ne gère pas.
Clic et double-clic
En plus du clic classique, userEvent gère le double-clic et n'importe quel bouton de la souris :
1test('handles click and double click', async () => {
2 const user = userEvent.setup();
3 render(<NavigationPanel />);
4
5 // Clic simple
6 await user.click(screen.getByRole('button', { name: 'Select' }));
7
8 // Double-clic
9 await user.dblClick(screen.getByText('Mission Details'));
10
11 // Clic droit
12 await user.pointer({
13 keys: '[MouseRight]',
14 target: screen.getByText('Options')
15 });
16});La méthode pointer reçoit la description du bouton entre crochets, ici [MouseRight], le bouton droit de la souris.
Interactions au clavier
Une application accessible doit fonctionner sans souris. user.tab() déplace le focus comme la touche Tab, et user.keyboard saisit les touches spéciales entre accolades :
1test('keyboard navigation in mission list', async () => {
2 const user = userEvent.setup();
3 render(<MissionList />);
4
5 // Tab vers l'élément suivant
6 await user.tab();
7 expect(screen.getByText('Mission Alpha')).toHaveFocus();
8
9 // Tab vers le suivant
10 await user.tab();
11 expect(screen.getByText('Mission Beta')).toHaveFocus();
12
13 // Enter pour sélectionner
14 await user.keyboard('{Enter}');
15 expect(screen.getByText('Selected: Mission Beta')).toBeInTheDocument();
16});Le matcher toHaveFocus de jest-dom vérifie quel élément a le focus. Un tel test protège la navigation au clavier contre une casse accidentelle.
Effacer et sélectionner du texte
Parfois, un champ a déjà une valeur par défaut qu'il faut effacer avant d'en taper une nouvelle :
1test('clears and replaces input value', async () => {
2 const user = userEvent.setup();
3 render(<CoordinateInput defaultValue="0,0,0" />);
4
5 const input = screen.getByRole('textbox');
6 // Tout sélectionner et effacer
7 await user.clear(input);
8 expect(input).toHaveValue('');
9
10 // Saisir une nouvelle valeur
11 await user.type(input, '42,15,88');
12 expect(input).toHaveValue('42,15,88');
13});user.clear sélectionne tout le contenu et le supprime, tandis que user.type ajoute le texte à la fin, donc sans effacement tu obtiendrais 0,0,042,15,88.
Liste déroulante et case à cocher
Les listes déroulantes et les cases à cocher ont leurs propres méthodes :
1test('selects planet from dropdown', async () => {
2 const user = userEvent.setup();
3 render(<PlanetSelector />);
4
5 await user.selectOptions(
6 screen.getByRole('combobox'),
7 screen.getByRole('option', { name: 'Mars' })
8 );
9
10 expect(screen.getByRole('option', { name: 'Mars' }).selected).toBe(true);
11});
12
13test('toggles autopilot checkbox', async () => {
14 const user = userEvent.setup();
15 render(<AutopilotSwitch />);
16
17 const checkbox = screen.getByRole('checkbox', { name: 'Autopilot' });
18 expect(checkbox).not.toBeChecked();
19
20 await user.click(checkbox);
21 expect(checkbox).toBeChecked();
22});selectOptions accepte un élément option ou sa valeur, et on bascule une case à cocher avec un simple clic, comme un utilisateur.
Tester les callbacks et les props
Pour finir, deux tests du même bouton : une fois quand les systèmes sont prêts, une fois quand ils ne le sont pas :
1test('calls onLaunch when all systems ready', async () => {
2 const user = userEvent.setup();
3 const handleLaunch = jest.fn();
4
5 render(<LaunchButton onLaunch={handleLaunch} systemsReady={true} />);
6
7 await user.click(screen.getByRole('button', { name: 'Launch' }));
8 expect(handleLaunch).toHaveBeenCalledTimes(1);
9});
10
11test('does not call onLaunch when systems not ready', async () => {
12 const user = userEvent.setup();
13 const handleLaunch = jest.fn();
14
15 render(<LaunchButton onLaunch={handleLaunch} systemsReady={false} />);
16
17 await user.click(screen.getByRole('button', { name: 'Launch' }));
18 expect(handleLaunch).not.toHaveBeenCalled();
19});Le second test est aussi important que le premier. Il vérifie que le bouton ne se déclenche pas quand systemsReady vaut false, et c'est souvent le comportement le plus critique. Un test négatif est un contrôle de sécurité : il vérifie ce que le système ne fera pas quand les conditions ne sont pas remplies.
Chargement des données et bouton pour réessayer
Un panneau de mission doit souvent d'abord récupérer des données, par exemple la liste de l'équipage. Un tel composant passe toujours par la même séquence d'états, et le test la vérifie étape par étape :
- État initial :
loading: true, un message de chargement à l'écran. - Envoi de la requête : après le premier rendu, un effet appelle la fonction qui récupère les données.
- Réception de la réponse : la Promise est résolue avec les données ou rejetée avec une erreur.
- Mise à jour de l'état :
loading: falseetdataavec la réponse, ouerroravec un message. - Nouveau rendu : le composant affiche la liste ou un message d'erreur avec un bouton pour réessayer.
Pour que le test n'envoie pas de vraies requêtes dans l'espace, le composant reçoit la fonction qui récupère les données dans ses props. Dans l'application, tu passes une vraie fonction définie hors du composant, par exemple loadCrew={fetchCrew}, et dans le test un mock :
1function CrewLoader({ loadCrew }) {
2 const [state, setState] = useState({ loading: true, data: null, error: null });
3 const [attempt, setAttempt] = useState(0);
4
5 useEffect(() => {
6 let ignore = false;
7 loadCrew()
8 .then((data) => {
9 if (!ignore) setState({ loading: false, data, error: null });
10 })
11 .catch((err) => {
12 if (!ignore) setState({ loading: false, data: null, error: err.message });
13 });
14 return () => {
15 ignore = true;
16 };
17 }, [loadCrew, attempt]);
18
19 function retry() {
20 setState({ loading: true, data: null, error: null });
21 setAttempt((n) => n + 1);
22 }
23
24 if (state.loading) return <p>Loading crew...</p>;
25 if (state.error) {
26 return (
27 <div>
28 <p role="alert">{state.error}</p>
29 <button onClick={retry}>Retry</button>
30 </div>
31 );
32 }
33 return (
34 <ul>
35 {state.data.map((name) => <li key={name}>{name}</li>)}
36 </ul>
37 );
38}Un clic sur « Retry » revient à l'état de chargement et incrémente le compteur attempt, et le changement de dépendance relance l'effet, donc le composant envoie une nouvelle requête. Le drapeau ignore dans la fonction de nettoyage écarte une réponse arrivée trop tard, par exemple après le démontage du composant. C'est important aussi parce qu'en développement avec StrictMode, React exécute chaque effet deux fois. L'erreur a le rôle alert, donc le test la trouve par son rôle.
Le test parcourt tout le chemin de la panne à la réparation. Le mock renvoie une Promise rejetée au premier appel, puis des données au second :
1test('shows an error and loads the crew again after Retry', async () => {
2 const user = userEvent.setup();
3 const loadCrew = jest.fn()
4 .mockRejectedValueOnce(new Error('Signal lost'))
5 .mockResolvedValueOnce(['Nova', 'Astro']);
6
7 render(<CrewLoader loadCrew={loadCrew} />);
8 expect(screen.getByText('Loading crew...')).toBeInTheDocument();
9
10 expect(await screen.findByRole('alert')).toHaveTextContent('Signal lost');
11 await user.click(screen.getByRole('button', { name: 'Retry' }));
12
13 expect(await screen.findByText('Nova')).toBeInTheDocument();
14 expect(loadCrew).toHaveBeenCalledTimes(2);
15});mockRejectedValueOnce fait que le premier appel du mock renvoie une Promise rejetée, et mockResolvedValueOnce que le second renvoie des données. findByRole et findByText attendent que le composant passe à l'état suivant, car la réponse arrive de façon asynchrone. Enfin, le nombre d'appels du mock confirme que le bouton a vraiment envoyé une seconde requête. Le composant ne sait pas qu'il parle à un mock, et c'est tout l'intérêt de passer la fonction dans les props.
Dans la prochaine leçon, tu examineras l'attente de plus près : tu découvriras waitFor et tu apprendras à remplacer fetch. Retiens : un bon test d'interaction appuie sur les mêmes boutons que le pilote et ne vérifie que ce que le pilote voit sur le panneau.
Code de cette leçon : App.jsx
1import React, { useState } from 'react';
2
3const ROLES = [
4 { value: 'pilot', label: 'Pilote' },
5 { value: 'captain', label: 'Capitaine' },
6 { value: 'engineer', label: 'Ingénieur' },
7 { value: 'scientist', label: 'Scientifique' },
8];
9
10// Un formulaire que le test remplit avec userEvent avant de vérifier l’appel de onSubmit
11function PilotForm({ onSubmit }) {
12 const [name, setName] = useState('');
13 const [role, setRole] = useState('pilot');
14 const [experience, setExperience] = useState(0);
15 const [autopilot, setAutopilot] = useState(false);
16 const [lastAdded, setLastAdded] = useState(null);
17
18 const handleSubmit = (e) => {
19 e.preventDefault();
20 if (!name.trim()) return;
21 onSubmit({ name, role, experience, autopilot });
22 setLastAdded(name);
23 setName('');
24 setRole('pilot');
25 setExperience(0);
26 setAutopilot(false);
27 };
28
29 return (
30 <form className="pilot-form" onSubmit={handleSubmit}>
31 <h2>Inscription d’un membre d’équipage</h2>
32
33 <div className="field">
34 <label htmlFor="name">Nom :</label>
35 <input
36 id="name"
37 type="text"
38 value={name}
39 onChange={(e) => setName(e.target.value)}
40 placeholder="Saisis le nom du pilote..."
41 />
42 </div>
43
44 <div className="field">
45 <label htmlFor="role">Rôle :</label>
46 <select id="role" value={role} onChange={(e) => setRole(e.target.value)}>
47 {ROLES.map((r) => (
48 <option key={r.value} value={r.value}>{r.label}</option>
49 ))}
50 </select>
51 </div>
52
53 <div className="field">
54 <label htmlFor="experience">Expérience (années) :</label>
55 <input
56 id="experience"
57 type="number"
58 value={experience}
59 onChange={(e) => setExperience(Number(e.target.value))}
60 min="0"
61 max="50"
62 />
63 </div>
64
65 <div className="field checkbox">
66 <label>
67 <input
68 type="checkbox"
69 checked={autopilot}
70 onChange={(e) => setAutopilot(e.target.checked)}
71 />
72 Certifié pilote automatique
73 </label>
74 </div>
75
76 <button type="submit" disabled={!name.trim()}>
77 Ajouter le membre
78 </button>
79
80 {lastAdded && (
81 <p className="success" role="status">Ajouté : {lastAdded}</p>
82 )}
83 </form>
84 );
85}
86
87// Des interrupteurs que le test clique et vérifie par leur texte
88function TogglePanel() {
89 const [engines, setEngines] = useState({ left: false, right: false });
90 const [shield, setShield] = useState(0);
91
92 const status =
93 engines.left && engines.right
94 ? 'Les deux moteurs tournent - pleine puissance !'
95 : engines.left || engines.right
96 ? 'Mode un seul moteur - vitesse réduite'
97 : 'Tous les moteurs sont éteints';
98
99 return (
100 <div className="toggle-panel">
101 <h2>Commandes du vaisseau</h2>
102
103 <div className="controls">
104 <button
105 onClick={() => setEngines((e) => ({ ...e, left: !e.left }))}
106 className={engines.left ? 'active' : ''}
107 >
108 Moteur gauche : {engines.left ? 'MARCHE' : 'ARRÊT'}
109 </button>
110
111 <button
112 onClick={() => setEngines((e) => ({ ...e, right: !e.right }))}
113 className={engines.right ? 'active' : ''}
114 >
115 Moteur droit : {engines.right ? 'MARCHE' : 'ARRÊT'}
116 </button>
117
118 <button onClick={() => setShield((s) => Math.min(s + 25, 100))}>
119 Boucliers : {shield} %
120 </button>
121 </div>
122
123 <p className="status">{status}</p>
124 </div>
125 );
126}
127
128export default function App() {
129 const [calls, setCalls] = useState([]);
130
131 return (
132 <div className="app">
133 <h1>Tester les interactions - Simulateur de vol</h1>
134 <p className="note">
135 L’aperçu ne lance pas les tests. Remplis et envoie le formulaire : un test
136 avec userEvent fait la même chose, puis vérifie avec toHaveBeenCalledWith
137 les données reçues par la fonction onSubmit.
138 </p>
139 <PilotForm onSubmit={(data) => setCalls((c) => [...c, data])} />
140
141 {calls.length > 0 && (
142 <div className="submissions">
143 <h3>Appels de onSubmit ({calls.length}) :</h3>
144 {calls.map((data, i) => (
145 <code key={i} className="call">{JSON.stringify(data)}</code>
146 ))}
147 </div>
148 )}
149
150 <TogglePanel />
151 </div>
152 );
153}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. Quel est le principal avantage de userEvent par rapport à fireEvent pour tester React ?
2. Comment vérifier dans un test que le callback onSubmit a été appelé avec les bonnes données ?
Tâches pratiques dans le jeu
- Éditeur de code
Termine PlanetLoader, qui charge les planètes avec la fonction loadPlanets reçue par les props et affiche trois états : le chargement (role="status"), l'erreur (role="alert" avec un bouton Réessayer) et la liste des planètes. ___BLANK1___ : après un chargement réussi, enregistre dans l'état status que les données sont prêtes (par ex. 'success'), pour que la liste remplace le message de chargement. ___BLANK2___ : quand la promesse est rejetée, mets le status à 'error'. ___BLANK3___ : retry augmente le compteur de tentatives attempt de 1, ce qui relance l'effet et rappelle loadPlanets. Dans l'aperçu, le premier chargement échoue exprès, donc tu verras le message d'erreur et la nouvelle tentative.
- Tri vertical
Ordonne les états d'un composant asynchrone dans l'ordre où ils apparaissent :
- Clique dans l'ordre
Ordonne les éléments de la syntaxe userEvent.type() dans le bon ordre :