Cours JavaScript et TypeScript · Module 11: Tests avec Jest
Tester du code asynchrone
Dans cette leçon3
Le scan d'un échantillon d'ADN ne se termine pas au moment où la fonction est appelée. Le résultat arrive plus tard, sous forme de promesse (Promise), et un test qui ne l'attend pas se terminera avant l'arrivée du résultat et annoncera un succès, alors qu'il n'a rien vérifié. Ce faux calme est plus dangereux qu'une alarme rouge.
À Jurassic Park, beaucoup d'opérations sont asynchrones - le scan de l'ADN, la communication avec les capteurs, la récupération de données satellite. Jest propose plusieurs façons de tester ce type de code.
Tester les Promise
async/await
La façon la plus lisible de tester du code asynchrone demande deux étapes : marque la fonction de test comme async et utilise await lors de l'appel de la fonction testée. On suppose ici que fetch est remplacé par un mock, comme dans la leçon précédente, et qu'il renvoie un échantillon de T-Rex d'une pureté de 0.95 :
1// Fonction asynchrone à tester
2async function scanDNA(sampleId) {
3 const response = await fetch(`/api/dna/${sampleId}`);
4 const data = await response.json();
5 return {
6 species: data.species,
7 purity: data.purity,
8 viable: data.purity > 0.9
9 };
10}
11
12// Test avec async/await
13test("le scan de l'ADN renvoie l'espèce", async () => {
14 const result = await scanDNA('SAMPLE-001');
15
16 expect(result.species).toBe('T-Rex');
17 expect(result.purity).toBeGreaterThan(0.9);
18 expect(result.viable).toBe(true);
19});Jest voit que la fonction de test renvoie une promesse et l'attend avant d'évaluer le résultat. Sans await, la variable result serait une promesse et non un objet, donc result.species donnerait undefined. La fonction scanDNA elle-même ne change pas - la seule différence, c'est que le test attend patiemment sa réponse.
resolves et rejects
Jest possède des matchers dédiés aux Promise. .resolves déballe la valeur d'une promesse tenue, .rejects la raison du rejet, puis tu ajoutes un matcher ordinaire. Tu fais précéder toute l'expression de await (ou tu la renvoies avec return). Pour l'échantillon INVALID, notre mock de fetch rejette la promesse avec l'erreur « Sample not found » :
1// Tester resolve
2test('DNA scan resolves with data', async () => {
3 await expect(scanDNA('SAMPLE-001')).resolves.toEqual({
4 species: 'T-Rex',
5 purity: 0.95,
6 viable: true
7 });
8});
9
10// Tester reject
11test('DNA scan rejects for invalid sample', async () => {
12 await expect(scanDNA('INVALID')).rejects.toThrow('Sample not found');
13});Oublier await devant expect est une erreur classique : le test se terminera avant que le matcher vérifie quoi que ce soit. La même règle vaut pour les fonctions async qui lèvent elles-mêmes une exception - comme un système de défense qui refuse de s'activer sans numéro de zone :
1// Autre exemple - tester une Promise rejetée
2async function activateDefense(zoneId) {
3 if (!zoneId) {
4 throw new Error('Zone ID is required');
5 }
6 return { status: 'activated', zone: zoneId };
7}
8
9test('defense rejects without zone ID', async () => {
10 await expect(activateDefense(null)).rejects.toThrow('Zone ID is required');
11});
12
13test('defense resolves with valid zone', async () => {
14 await expect(activateDefense('A')).resolves.toEqual({
15 status: 'activated',
16 zone: 'A'
17 });
18});activateDefense est une fonction async, donc un throw à l'intérieur n'explose pas tout de suite : il se transforme en promesse rejetée. C'est pourquoi on la teste avec rejects.toThrow, et non avec expect(() => ...).toThrow(), vu dans la leçon sur les matchers. Si tu préfères le classique try/catch, ajoute au début du test expect.assertions(1). Ainsi, une promesse qui serait tenue de façon inattendue ne pourra pas passer à côté du bloc catch sans la moindre assertion.
Tester les callbacks
Le code plus ancien ne renvoie pas de promesses : il accepte un callback, c'est-à-dire une fonction appelée avec le résultat. Sur le modèle de Node.js, fetchDinoReport passe au callback d'abord l'erreur, puis les données. Le test reçoit alors un paramètre done et doit l'appeler quand il a terminé :
1function fetchDinoReport(id, callback) {
2 setTimeout(() => {
3 if (id === 'unknown') {
4 callback(new Error('Dinosaur not found'), null);
5 } else {
6 callback(null, { id, status: 'healthy' });
7 }
8 }, 100);
9}
10
11// Test d'un callback avec done
12test('le callback renvoie le rapport du dinosaure', (done) => {
13 fetchDinoReport('REX-001', (error, report) => {
14 try {
15 expect(error).toBeNull();
16 expect(report.status).toBe('healthy');
17 done(); // Signale la fin du test
18 } catch (err) {
19 done(err); // Transmet l'erreur à Jest
20 }
21 });
22});Si done n'est jamais appelé, le test échouera une fois le délai dépassé, 5 secondes par défaut. Le try/catch est nécessaire, car un expect raté lève une exception à l'intérieur du callback, et une fois transmise à done(err), elle apparaît dans le rapport comme la véritable cause de l'échec. Ne combine pas done avec un test async, car Jest signalerait alors une erreur. Pour du nouveau code, je te recommande async/await et garde done pour les anciennes API.
Fake Timers
Quand le code utilise setTimeout, setInterval ou Date, tu peux contrôler le temps. Attendre 30 secondes dans un test est une perte de temps, alors jest.useFakeTimers() remplace les horloges par des horloges factices. Voici une fonction qui annonce l'évacuation au bout de 30 secondes :
1// Fonction avec un timeout
2function startEmergencyCountdown(callback) {
3 console.log('Évacuation dans 30 secondes !');
4 setTimeout(() => {
5 callback('Évacuation lancée !');
6 }, 30000); // 30 secondes
7}Et voici comment on la teste. jest.advanceTimersByTime fait avancer le temps du nombre de millisecondes indiqué, instantanément. On active les horloges factices dans beforeEach et on les désactive dans afterEach, pour qu'elles n'affectent pas les autres tests :
1describe('Emergency System', () => {
2 beforeEach(() => {
3 jest.useFakeTimers(); // Active les timers factices
4 });
5
6 afterEach(() => {
7 jest.useRealTimers(); // Restaure les vrais timers
8 });
9
10 test('le compte à rebours appelle le callback après 30 s', () => {
11 const callback = jest.fn();
12
13 startEmergencyCountdown(callback);
14
15 // Le callback n'a pas encore été appelé
16 expect(callback).not.toHaveBeenCalled();
17
18 // Avance le temps de 30 secondes
19 jest.advanceTimersByTime(30000);
20
21 // Maintenant, le callback a été appelé !
22 expect(callback).toHaveBeenCalledWith('Évacuation lancée !');
23 });
24
25 test('le compte à rebours ne se déclenche pas trop tôt', () => {
26 const callback = jest.fn();
27
28 startEmergencyCountdown(callback);
29
30 // Avance de 29 secondes
31 jest.advanceTimersByTime(29000);
32 expect(callback).not.toHaveBeenCalled();
33
34 // Avance d'une seconde de plus (30 s au total)
35 jest.advanceTimersByTime(1000);
36 expect(callback).toHaveBeenCalled();
37 });
38});Le deuxième test montre que le temps factice est précis : après 29 secondes, le callback attend toujours, et une seconde plus tard, il se déclenche. Les deux tests durent quelques millisecondes, et non une demi-minute. Le code de production ne sait pas que le temps est truqué - nous n'y avons pas changé une seule ligne.
Lancer les timers en attente
Quand tu ne veux pas compter les millisecondes, jest.runAllTimers() lance d'un coup tous les timers en attente. Important : il faut activer les horloges factices avant que le code planifie les timers, c'est pourquoi jest.useFakeTimers() se trouve tout au début du test :
1test('runAllTimers - lance tous les timers', () => {
2 jest.useFakeTimers();
3
4 const steps = [];
5
6 setTimeout(() => steps.push('étape 1'), 1000);
7 setTimeout(() => steps.push('étape 2'), 2000);
8 setTimeout(() => steps.push('étape 3'), 5000);
9
10 // Lance TOUS les timers en attente d'un coup
11 jest.runAllTimers();
12
13 expect(steps).toEqual(['étape 1', 'étape 2', 'étape 3']);
14
15 jest.useRealTimers();
16});Des timers planifiés avant l'activation des horloges factices iraient dans la vraie file d'attente, et runAllTimers ne les verrait pas, donc le tableau steps resterait vide. Avec setInterval, fais attention : runAllTimers lance aussi les timers créés en cours de route, donc un intervalle tournerait sans fin, jusqu'à ce que Jest interrompe la boucle avec une erreur. Utilise alors jest.runOnlyPendingTimers(), et quand les timers se mélangent aux promesses, opte pour await jest.advanceTimersByTimeAsync(ms).
Dans le projet final, les horloges factices serviront à la surveillance des capteurs. Dans le laboratoire ci-dessous, tu verras une simulation du scan d'ADN avec des promesses et des timers.
Retiens : dans un test asynchrone, attends toujours le résultat - await est la sentinelle qui ne quitte pas son poste avant la fin du scan d'ADN.
Code de cette leçon : index.js
1// Tester du code asynchrone
2console.log("=== Jurassic Park - Scan Asynchrone de l'ADN ===\n");
3
4// --- async/await dans les tests ---
5console.log("--- async/await ---\n");
6
7async function scanDNA(sampleId) {
8 // Simulation d'un scan asynchrone
9 return new Promise((resolve, reject) => {
10 setTimeout(() => {
11 if (sampleId === "INVALID") {
12 reject(new Error("Sample not found"));
13 return;
14 }
15 resolve({
16 species: "T-Rex",
17 purity: 0.95,
18 viable: true,
19 });
20 }, 100);
21 });
22}
23
24// Test async/await
25async function testAsyncAwait() {
26 try {
27 const result = await scanDNA("SAMPLE-001");
28 console.log("[PASS] DNA scan returned :", result.species);
29 console.log("[PASS] Purity :", result.purity, "> 0.9 ?", result.purity > 0.9);
30 console.log("[PASS] Viable :", result.viable);
31 } catch (e) {
32 console.log("[FAIL]", e.message);
33 }
34}
35
36// Test reject
37async function testReject() {
38 try {
39 await scanDNA("INVALID");
40 console.log("[FAIL] Aurait dû lever une exception");
41 } catch (e) {
42 console.log("[PASS] Rejeté :", e.message);
43 }
44}
45
46// --- Test de callbacks ---
47console.log("--- Callbacks ---\n");
48
49function fetchDinoReport(id, callback) {
50 setTimeout(() => {
51 if (id === "unknown") {
52 callback(new Error("Dinosaur not found"), null);
53 } else {
54 callback(null, { id, status: "healthy" });
55 }
56 }, 50);
57}
58
59function testCallback() {
60 return new Promise((resolve) => {
61 fetchDinoReport("REX-001", (error, report) => {
62 if (!error && report.status === "healthy") {
63 console.log("[PASS] Callback : statut du rapport =", report.status);
64 } else {
65 console.log("[FAIL] Échec du callback");
66 }
67 resolve();
68 });
69 });
70}
71
72// --- Fake Timers ---
73console.log("--- Fake Timers ---\n");
74
75function demonstrateFakeTimers() {
76 console.log("Dans un vrai Jest :");
77 console.log(" jest.useFakeTimers() - active les fake timers");
78 console.log(" jest.advanceTimersByTime(30000) - avance de 30s");
79 console.log(" jest.runAllTimers() - exécute tous les timers");
80 console.log(" jest.useRealTimers() - restaure les vrais timers");
81
82 console.log("\nExemple :");
83 console.log(" function startCountdown(cb) {");
84 console.log(" setTimeout(() => cb('GO!'), 30000);");
85 console.log(" }");
86 console.log(" ");
87 console.log(" test('countdown', () => {");
88 console.log(" jest.useFakeTimers();");
89 console.log(" const cb = jest.fn();");
90 console.log(" startCountdown(cb);");
91 console.log(" expect(cb).not.toHaveBeenCalled();");
92 console.log(" jest.advanceTimersByTime(30000);");
93 console.log(" expect(cb).toHaveBeenCalledWith('GO!');");
94 console.log(" });");
95}
96
97// --- resolves/rejects ---
98console.log("--- matchers resolves / rejects ---\n");
99
100async function demonstrateResolves() {
101 // resolves
102 const goodPromise = Promise.resolve({ status: "activated", zone: "A" });
103 const result = await goodPromise;
104 const matches = result.status === "activated" && result.zone === "A";
105 console.log("[" + (matches ? "PASS" : "FAIL") + "] resolves.toEqual :", JSON.stringify(result));
106
107 // rejects
108 const badPromise = Promise.reject(new Error("Zone ID is required"));
109 try {
110 await badPromise;
111 console.log("[FAIL] Aurait dû être rejeté");
112 } catch (e) {
113 const matches = e.message === "Zone ID is required";
114 console.log("[" + (matches ? "PASS" : "FAIL") + "] rejects.toThrow :", e.message);
115 }
116}
117
118// Lancer les tests
119async function runAll() {
120 await testAsyncAwait();
121 console.log("");
122 await testReject();
123 console.log("");
124 await testCallback();
125 console.log("");
126 await demonstrateResolves();
127 console.log("");
128 demonstrateFakeTimers();
129}
130
131runAll();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. Comment tester correctement une fonction async dans Jest ?
2. Quelle méthode Jest permet d'avancer le temps factice d'un nombre de millisecondes donné ?
Tâches pratiques dans le jeu
- Tri vertical
Ordonne les étapes pour tester une fonction asynchrone :
- Éditeur de code
Écris des tests async/await pour les fonctions analyzeDNA et cloneDinosaur.
- Clique dans l'ordre
Ordonne les éléments de l'utilisation des fake timers dans un test :
- Éditeur de code
Utilise des fake timers pour tester le système d'évacuation du parc.