Cours JavaScript et TypeScript · Module 8: Événements et DOM

Tests unitaires des gestionnaires d'événements

6 min de lecture

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. 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. 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 :

Articles utiles