Cours JavaScript et TypeScript · Module 8: Événements et DOM
Tests unitaires des gestionnaires d'événements
Comment tester le code lié à la gestion des événements.
Après chaque modification du panneau de contrôle, tu pourrais cliquer à la main sur chaque carte de dinosaure pour vérifier que l'alarme fonctionne toujours. C'est comme faire chaque jour le tour de toutes les clôtures avec une lampe torche. Les tests automatisés font cette ronde à ta place, en une fraction de seconde. Nous utiliserons Jest, un framework de test populaire pour JavaScript. La fonction describe regroupe les tests, test décrit un cas, beforeEach prépare des données fraîches avant chaque test, et expect vérifie le résultat. L'outil le plus important est jest.fn() : il crée une fausse fonction, appelée mock, qui enregistre chaque appel et ses arguments.
Le composant que nous testons
Avant d'écrire les tests, faisons connaissance avec le héros. DinosaurCardHandler attache le clic à une carte, bloque l'action par défaut et annonce le choix d'un dinosaure par un événement personnalisé.
1class DinosaurCardHandler {
2 constructor(element) {
3 this.element = element;
4 this.handleClick = this.handleClick.bind(this);
5 this.element.addEventListener('click', this.handleClick);
6 }
7
8 handleClick(event) {
9 event.preventDefault();
10 this.emitDinosaurSelected(event.target.dataset.dinoId);
11 }
12
13 emitDinosaurSelected(dinosaurId) {
14 this.element.dispatchEvent(
15 new CustomEvent('dinosaurSelected', { detail: { dinosaurId } })
16 );
17 }
18
19 destroy() {
20 this.element.removeEventListener('click', this.handleClick);
21 }
22}La classe a quatre comportements qui méritent d'être vérifiés : elle enregistre l'écouteur dans le constructeur, elle appelle preventDefault() dans handleClick(), elle envoie l'événement dinosaurSelected avec l'identifiant, et dans destroy(), elle retire la même fonction que celle qu'elle a attachée. Chacun d'eux aura son propre test.
Tester les événements
On commence par la préparation. Au lieu d'un vrai élément du DOM, on crée un objet ordinaire avec trois méthodes issues de jest.fn().
1// Exemple de tests avec Jest
2describe('Event Handlers', () => {
3 let mockElement;
4 let eventHandler;
5
6 beforeEach(() => {
7 // Création d'un mock d'élément DOM
8 mockElement = {
9 addEventListener: jest.fn(),
10 removeEventListener: jest.fn(),
11 dispatchEvent: jest.fn()
12 };
13
14 // Mock des fonctions globales
15 global.document = {
16 getElementById: jest.fn(() => mockElement),
17 createElement: jest.fn(() => mockElement)
18 };
19 });Voilà comment on construit un mock d'élément : on affecte jest.fn() à addEventListener, puis on lui demande comment il a été appelé. Grâce à beforeEach, chaque test reçoit un mock tout neuf, donc les appels d'un test ne se mélangent pas avec ceux du suivant. Remplacer tout global.document n'est ici qu'une précaution, car les tests passent de toute façon mockElement directement. Dans l'environnement jsdom, document existe déjà, donc au lieu de l'écraser, remplace une seule méthode avec jest.spyOn(document, 'getElementById'). Le premier test vérifie l'enregistrement.
1 test('should add event listener on init', () => {
2 const handler = new DinosaurCardHandler(mockElement);
3
4 expect(mockElement.addEventListener).toHaveBeenCalledWith(
5 'click',
6 expect.any(Function)
7 );
8 });Le matcher toHaveBeenCalledWith() vérifie que l'écouteur a été ajouté avec le type 'click', et expect.any(Function) accepte n'importe quelle fonction, car peu nous importe laquelle exactement. Le deuxième test vérifie la gestion du clic elle-même.
1 test('should handle click event correctly', () => {
2 const mockCallback = jest.fn();
3 const handler = new DinosaurCardHandler(mockElement);
4
5 // Simulation d'un événement
6 const mockEvent = {
7 target: { dataset: { dinoId: '123' } },
8 preventDefault: jest.fn()
9 };
10
11 // Appel direct du gestionnaire
12 handler.handleClick(mockEvent);
13
14 expect(mockEvent.preventDefault).toHaveBeenCalled();
15 });Le mock d'élément n'a pas de vrai système d'événements, donc on appelle handleClick() directement avec un faux objet événement. Son preventDefault est lui aussi un jest.fn(), on peut donc vérifier qu'il a été appelé. La variable mockCallback n'est utilisée nulle part ici, et dans un vrai projet, il vaut mieux supprimer une telle ligne morte. Le troisième test surveille le nettoyage.
1 test('should remove event listener on destroy', () => {
2 const handler = new DinosaurCardHandler(mockElement);
3 handler.destroy();
4
5 expect(mockElement.removeEventListener).toHaveBeenCalledWith(
6 'click',
7 expect.any(Function)
8 );
9 });Ce test confirme que destroy() a appelé removeEventListener() pour le clic. Une version plus stricte comparerait la fonction avec celle passée plus tôt à addEventListener(), que Jest conserve dans mock.calls. Le quatrième test vérifie l'événement personnalisé.
1 test('should emit custom event', () => {
2 const handler = new DinosaurCardHandler(mockElement);
3 const customEvent = new CustomEvent('dinosaurSelected');
4
5 handler.emitDinosaurSelected('123');
6
7 expect(mockElement.dispatchEvent).toHaveBeenCalledWith(
8 expect.objectContaining({
9 type: 'dinosaurSelected',
10 detail: { dinosaurId: '123' }
11 })
12 );
13 });
14});Le matcher expect.objectContaining() ne vérifie que les champs indiqués de l'objet envoyé : le type et detail. La variable customEvent est superflue ici, car l'assertion regarde l'événement créé par le gestionnaire lui-même.
Test d'intégration avec le vrai DOM
Les tests unitaires vérifient la logique de manière isolée. Un test d'intégration crée un vrai bouton et vérifie que le mécanisme d'événements du navigateur le relie réellement à l'écouteur.
1// Test d'intégration avec le DOM réel
2describe('Integration Tests', () => {
3 test('should handle real DOM events', () => {
4 // Création d'un élément réel
5 const button = document.createElement('button');
6 document.body.appendChild(button);
7
8 let clickCount = 0;
9 button.addEventListener('click', () => {
10 clickCount++;
11 });
12
13 // Simulation d'un clic
14 button.click();
15
16 expect(clickCount).toBe(1);
17
18 // Cleanup
19 document.body.removeChild(button);
20 });
21});La méthode click() simule un clic et envoie un vrai événement click, qui bouillonne comme après un clic de souris. À la fin, on supprime le bouton pour qu'il n'influence pas les tests suivants. Un tel test a besoin de l'environnement jsdom : depuis la version 27, Jest lance par défaut les tests dans l'environnement Node, il faut donc définir testEnvironment: 'jsdom', et depuis la version 28, installer en plus le paquet jest-environment-jsdom.
Chaque test de gestionnaire passe par les mêmes quatre étapes : on crée un mock d'élément DOM avec jest.fn(), on initialise le composant avec ce mock, on simule un événement, par exemple un clic, et on vérifie les assertions avec expect.
Mon conseil : vérifie la logique avec des tests unitaires rapides à base de mocks, et la connexion avec le DOM avec quelques tests d'intégration. Pour les événements qui n'ont pas de raccourci comme click(), utilise element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })). Dans le bac à sable ci-dessous, tu lanceras la série de tests d'un seul bouton et tu verras leurs résultats. La prochaine leçon fera le bilan de tout le module sur les événements.
Retiens bien : un bon test est un gardien qui fait chaque nuit le tour des clôtures à ta place et ne te réveille que lorsque quelque chose ne va vraiment pas.
Code de cette leçon : index.html
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <title>Tester les gestionnaires d'événements</title>
6 <style>
7 body { font-family: Arial, sans-serif; background: linear-gradient(135deg, #2c3e50, #34495e); color: white; padding: 20px; }
8 .test-section { background: rgba(0,0,0,0.7); padding: 20px; border-radius: 10px; margin: 20px 0; }
9 .test-result { padding: 10px; margin: 10px 0; border-radius: 6px; }
10 .pass { background: rgba(46, 204, 113, 0.2); border-left: 4px solid #27ae60; }
11 .fail { background: rgba(231, 76, 60, 0.2); border-left: 4px solid #e74c3c; }
12 button { padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; margin: 5px; }
13 code { background: rgba(255,255,255,0.1); padding: 2px 6px; border-radius: 3px; font-family: monospace; }
14 </style>
15</head>
16<body>
17 <h1>Tester les gestionnaires d'événements</h1>
18
19 <div class="test-section">
20 <h2>Composant à tester</h2>
21 <button id="test-button" data-dino="T-Rex">Clique-moi</button>
22 <div id="click-counter">Clicks: 0</div>
23 </div>
24
25 <div class="test-section">
26 <h2>Résultats de la suite de tests</h2>
27 <button onclick="runTests()">▶Lancer tous les tests</button>
28 <div id="test-results"></div>
29 </div>
30
31 <script>
32 console.log('Event Handler Testing Suite');
33
34 // COMPONENT TO TEST
35 class DinoButton {
36 constructor(element) {
37 this.element = element;
38 this.clickCount = 0;
39 this.handleClick = this.handleClick.bind(this);
40 this.init();
41 }
42
43 init() {
44 this.element.addEventListener('click', this.handleClick);
45 }
46
47 handleClick(e) {
48 this.clickCount++;
49 this.updateDisplay();
50 console.log(`Clicked: ${this.element.dataset.dino}`);
51 }
52
53 updateDisplay() {
54 document.getElementById('click-counter').textContent = `Clicks: ${this.clickCount}`;
55 }
56
57 getClickCount() {
58 return this.clickCount;
59 }
60
61 destroy() {
62 this.element.removeEventListener('click', this.handleClick);
63 }
64 }
65
66 // Create component
67 const testButton = document.getElementById('test-button');
68 const dinoButton = new DinoButton(testButton);
69
70 // TEST SUITE
71 class TestRunner {
72 constructor() {
73 this.results = [];
74 }
75
76 test(name, testFn) {
77 try {
78 testFn();
79 this.results.push({ name, passed: true, error: null });
80 console.log(`PASS: ${name}`);
81 } catch (error) {
82 this.results.push({ name, passed: false, error: error.message });
83 console.log(`FAIL: ${name} - ${error.message}`);
84 }
85 }
86
87 expect(value) {
88 return {
89 toBe: (expected) => {
90 if (value !== expected) {
91 throw new Error(`Expected ${expected}, got ${value}`);
92 }
93 },
94 toBeGreaterThan: (expected) => {
95 if (value <= expected) {
96 throw new Error(`Expected > ${expected}, got ${value}`);
97 }
98 },
99 toBeDefined: () => {
100 if (value === undefined || value === null) {
101 throw new Error(`Expected value to be defined`);
102 }
103 }
104 };
105 }
106
107 displayResults() {
108 const container = document.getElementById('test-results');
109 container.innerHTML = '';
110
111 const summary = document.createElement('div');
112 const passed = this.results.filter(r => r.passed).length;
113 const total = this.results.length;
114 summary.innerHTML = `<strong>Results: ${passed}/${total} tests passed</strong>`;
115 container.appendChild(summary);
116
117 this.results.forEach(result => {
118 const div = document.createElement('div');
119 div.className = `test-result ${result.passed ? 'pass' : 'fail'}`;
120 div.innerHTML = `
121 ${result.passed ? 'OK' : '×'} <strong>${result.name}</strong>
122 ${result.error ? `<br><code>${result.error}</code>` : ''}
123 `;
124 container.appendChild(div);
125 });
126 }
127 }
128
129 // RUN TESTS
130 function runTests() {
131 const runner = new TestRunner();
132
133 runner.test('Component initializes with click count 0', () => {
134 const button = document.createElement('button');
135 const component = new DinoButton(button);
136 runner.expect(component.getClickCount()).toBe(0);
137 component.destroy();
138 });
139
140 runner.test('Click count increments on click', () => {
141 const button = document.createElement('button');
142 const component = new DinoButton(button);
143 const initialCount = component.getClickCount();
144
145 // Simulate click
146 button.click();
147
148 runner.expect(component.getClickCount()).toBeGreaterThan(initialCount);
149 component.destroy();
150 });
151
152 runner.test('Element has correct data attribute', () => {
153 const button = document.getElementById('test-button');
154 const dino = button.dataset.dino;
155 runner.expect(dino).toBe('T-Rex');
156 });
157
158 runner.test('Event listener is added', () => {
159 const button = document.createElement('button');
160 const component = new DinoButton(button);
161
162 // Event listener should be added
163 runner.expect(component.element).toBeDefined();
164 runner.expect(component.handleClick).toBeDefined();
165
166 component.destroy();
167 });
168
169 runner.test('Event listener is removed on destroy', () => {
170 const button = document.createElement('button');
171 const component = new DinoButton(button);
172 const countBefore = component.getClickCount();
173
174 component.destroy();
175 button.click(); // This click should not increase the counter
176
177 // Counter should not change after destroy
178 runner.expect(component.getClickCount()).toBe(countBefore);
179 });
180
181 runner.test('Multiple clicks increment correctly', () => {
182 const button = document.createElement('button');
183 const component = new DinoButton(button);
184
185 button.click();
186 button.click();
187 button.click();
188
189 runner.expect(component.getClickCount()).toBe(3);
190 component.destroy();
191 });
192
193 runner.displayResults();
194 console.log('Test suite completed');
195 }
196
197 console.log('Test suite ready - click "Run All Tests"');
198 </script>
199</body>
200</html>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. Quelle fonction de l'API de Jest (sans bibliothèque supplémentaire) utilise-t-on pour créer un mock de la méthode addEventListener d'un élément du DOM ?
2. Comment simule-t-on un clic sur un élément du DOM dans les tests d'intégration ?
Tâches pratiques dans le jeu
- Éditeur de code
Le fichier index.js contient le composant DinosaurCardHandler de la leçon sur les tests, un jest.fn() simple (le mock mémorise ses appels dans mock.calls) et trois tests écrits comme des fonctions qui renvoient true quand le composant fonctionne correctement. Complète les blancs : ___BLANK1___ est le type d'événement que le constructeur doit enregistrer avec addEventListener, ___BLANK2___ est le nombre d'appels à preventDefault() après un clic, et ___BLANK3___ est l'opérateur de comparaison qui vérifie que destroy() retire la même fonction que celle ajoutée par le constructeur. Le correcteur lance tes tests sur le composant correct (ils doivent renvoyer true) et sur des versions cassées (ils doivent renvoyer false).
- Tri vertical
Place dans le bon ordre les étapes pour tester un gestionnaire d'événement :