Cours JavaScript et TypeScript · Module 11: Tests avec Jest
Projet : Suite de tests complète pour le système de Jurassic Park
Dans cette leçon2
Le parc tourne, les dinosaures sont nourris, mais personne ne sait si le correctif de demain dans le système d'alimentation ne va pas couper la clôture de la zone des raptors. C'est pour ça qu'on écrit une suite de tests : pour que chaque modification passe par les mêmes capteurs avant d'arriver dans le parc.
L'heure du défi final a sonné ! Tu vas construire une suite de tests complète pour le système de gestion de Jurassic Park. Ce projet réunit tous les concepts du module : Jest, les matchers, le setup/teardown, le mocking, les tests asynchrones, le TDD et TypeScript.
Le système à tester
Tu trouveras ci-dessous un système complet de gestion du parc. Ta mission est d'écrire des tests pour chacun de ses modules. Le code est long, mais tu n'as pas besoin de le mémoriser. Lis-le comme une spécification dans laquelle chaque instruction if et chaque throw est candidat à un test séparé.
Module 1 : Gestion des dinosaures
DinosaurManager stocke les spécimens dans une Map, c'est-à-dire une collection de paires clé-valeur, et leur attribue les identifiants DINO-001, DINO-002 et ainsi de suite. Remarque l'exception levée en l'absence de nom ou d'espèce, ainsi que les omnivores, qui acceptent deux types de nourriture :
1// dinosaurManager.js
2class DinosaurManager {
3 constructor() {
4 this.dinosaurs = new Map();
5 this.nextId = 1;
6 }
7
8 addDinosaur(name, species, diet, zone) {
9 if (!name || !species) {
10 throw new Error('Name and species are required');
11 }
12
13 const id = `DINO-${String(this.nextId).padStart(3, '0')}`;
14 this.nextId++;
15
16 const dinosaur = {
17 id,
18 name,
19 species,
20 diet,
21 zone,
22 health: 100,
23 fed: false,
24 lastFed: null,
25 };
26
27 this.dinosaurs.set(id, dinosaur);
28 return dinosaur;
29 }
30
31 getDinosaur(id) {
32 return this.dinosaurs.get(id) || null;
33 }
34
35 feedDinosaur(id, food) {
36 const dino = this.getDinosaur(id);
37 if (!dino) throw new Error('Dinosaur not found');
38
39 const correctFood = {
40 carnivore: 'meat',
41 herbivore: 'plants',
42 omnivore: ['meat', 'plants'],
43 };
44
45 const expected = correctFood[dino.diet];
46 const isCorrect = Array.isArray(expected)
47 ? expected.includes(food)
48 : expected === food;
49
50 if (!isCorrect) {
51 return { success: false, message: 'Wrong food type' };
52 }
53
54 dino.health = Math.min(100, dino.health + 10);
55 dino.fed = true;
56 dino.lastFed = new Date();
57
58 return { success: true, health: dino.health };
59 }
60
61 getByZone(zone) {
62 return [...this.dinosaurs.values()].filter(d => d.zone === zone);
63 }
64
65 getByDiet(diet) {
66 return [...this.dinosaurs.values()].filter(d => d.diet === diet);
67 }
68
69 getTotalCount() {
70 return this.dinosaurs.size;
71 }
72}Commence par un beforeEach qui crée un gestionnaire tout neuf, sinon le compteur nextId se transmettra d'un test à l'autre, et le deuxième test obtiendra DINO-002 au lieu de DINO-001. Le champ lastFed reçoit new Date(), c'est-à-dire une valeur que tu ne peux pas prévoir. Vérifie-le avec expect.any(Date), ou fige le temps grâce à une horloge factice et à jest.setSystemTime. Avec une mauvaise nourriture, assure-toi aussi que la santé n'a pas changé.
Module 2 : Système de sécurité
SecuritySystem reçoit alertService dans son constructeur. C'est de l'injection de dépendances, et la meilleure nouvelle qui soit pour un testeur : au lieu de jest.mock, il suffit de passer un objet contenant un mock. À la troisième intrusion, la méthode reportBreach active le lockdown et double la tension de la clôture :
1// securitySystem.js
2class SecuritySystem {
3 constructor(alertService) {
4 this.alertService = alertService; // dépendance à mocker
5 this.zones = new Map();
6 this.logs = [];
7 }
8
9 registerZone(name, fenceVoltage, securityLevel) {
10 this.zones.set(name, {
11 name,
12 fenceVoltage,
13 securityLevel,
14 status: 'active',
15 breachCount: 0,
16 });
17 }
18
19 async reportBreach(zoneName) {
20 const zone = this.zones.get(zoneName);
21 if (!zone) throw new Error('Zone not found');
22
23 zone.breachCount++;
24 zone.status = 'breached';
25
26 this.logs.push({
27 type: 'BREACH',
28 zone: zoneName,
29 timestamp: Date.now(),
30 });
31
32 await this.alertService.sendAlert(zoneName, 'Security breach detected');
33
34 if (zone.breachCount >= 3) {
35 await this.activateLockdown(zoneName);
36 }
37
38 return { status: 'reported', breachCount: zone.breachCount };
39 }
40
41 async activateLockdown(zoneName) {
42 const zone = this.zones.get(zoneName);
43 zone.status = 'lockdown';
44 zone.fenceVoltage *= 2;
45
46 await this.alertService.sendAlert(
47 zoneName,
48 'LOCKDOWN ACTIVATED'
49 );
50
51 this.logs.push({
52 type: 'LOCKDOWN',
53 zone: zoneName,
54 timestamp: Date.now(),
55 });
56 }
57
58 getZoneStatus(zoneName) {
59 const zone = this.zones.get(zoneName);
60 return zone ? zone.status : null;
61 }
62}Voici à quoi peut ressembler le début de tes tests. Le mock sendAlert renvoie directement une promesse tenue, et la zone reçoit une tension de 10000 et un niveau de sécurité 9 :
1const alertService = {
2 sendAlert: jest.fn().mockResolvedValue({ status: 'sent' }),
3};
4const security = new SecuritySystem(alertService);
5security.registerZone('Raptor Pen', 10000, 9);Après trois appels à await security.reportBreach('Raptor Pen'), la zone devrait avoir le statut lockdown, et sendAlert devrait avoir été appelé quatre fois : trois alertes d'intrusion et un « LOCKDOWN ACTIVATED ». Une zone inconnue donne une promesse rejetée, que tu vérifieras donc avec rejects.toThrow('Zone not found').
Module 3 : Système de surveillance (asynchrone)
MonitoringService combine une promesse et un timer : checkSensor répond au bout d'une seconde, et startMonitoring transmet les relevés au callback toutes les intervalMs millisecondes, grâce à setInterval :
1// monitoringService.js
2class MonitoringService {
3 constructor() {
4 this.sensors = [];
5 this.interval = null;
6 }
7
8 addSensor(zoneId, type) {
9 this.sensors.push({
10 zoneId,
11 type,
12 status: 'online',
13 lastReading: null,
14 });
15 }
16
17 async checkSensor(index) {
18 return new Promise((resolve, reject) => {
19 setTimeout(() => {
20 if (index < 0 || index >= this.sensors.length) {
21 reject(new Error('Sensor not found'));
22 return;
23 }
24
25 const sensor = this.sensors[index];
26 sensor.lastReading = Date.now();
27
28 resolve({
29 zoneId: sensor.zoneId,
30 type: sensor.type,
31 status: sensor.status,
32 reading: Math.random() * 100,
33 });
34 }, 1000);
35 });
36 }
37
38 startMonitoring(callback, intervalMs) {
39 this.interval = setInterval(() => {
40 const readings = this.sensors.map(s => ({
41 zone: s.zoneId,
42 status: s.status,
43 }));
44 callback(readings);
45 }, intervalMs);
46 }
47
48 stopMonitoring() {
49 if (this.interval) {
50 clearInterval(this.interval);
51 this.interval = null;
52 }
53 }
54}C'est ici que les horloges factices vont servir. Appelle checkSensor, fais avancer le temps de 1000 ms, et seulement ensuite attends la promesse avec await. Sans cette avance du temps, la promesse ne serait jamais tenue, et le test resterait bloqué jusqu'au timeout. Le champ reading provient de Math.random(), alors vérifie-le avec expect.any(Number). Pour startMonitoring, fais avancer le temps de plusieurs intervalles et compte les appels du callback avec toHaveBeenCalledTimes, puis, après stopMonitoring, assure-toi qu'une nouvelle avance du temps n'ajoute plus rien.
Ta mission
Écris une suite de tests complète couvrant :
- DinosaurManager - tests unitaires avec beforeEach, tests des exceptions
- SecuritySystem - mocking d'alertService, tests async
- MonitoringService - fake timers, tests des callbacks et des Promise
- Tests d'intégration - coopération entre les modules
N'oublie pas le pattern AAA (Arrange-Act-Assert) et les principes du TDD ! Un test d'intégration peut par exemple ajouter un raptor à une zone dans DinosaurManager, enregistrer la même zone dans SecuritySystem, puis vérifier qu'après une série d'intrusions, le raptor figure toujours sur la liste et que la zone a le statut lockdown.
Mon conseil : commence par un describe par module et un test par branche de if. Ce n'est que lorsque tous les chemins sont verts que tu ajoutes les cas limites, comme nourrir un dinosaure absent du registre. Envoie le projet terminé à ton mentor dans la tâche suivante.
Dans le laboratoire ci-dessous t'attendent des tests simplifiés de DinosaurManager et des commentaires TODO pour les deux autres modules. Considère-les comme un échauffement avant la vraie suite de tests dans Jest.
Retiens : une suite de tests complète est le réseau de capteurs de tout le parc - ce n'est que lorsque chaque enclos a son capteur que tu peux dormir tranquille.
Code de cette leçon : index.js
1// Projet : Suite de tests complète - Jurassic Park
2console.log("=== Jurassic Park - Système de Test Complet ===\n");
3
4let passed = 0, failed = 0;
5function assert(cond, msg) {
6 if (cond) { passed++; console.log(" [PASS] " + msg); }
7 else { failed++; console.log(" [FAIL] " + msg); }
8}
9function assertThrows(fn, expected) {
10 try { fn(); failed++; console.log(" [FAIL] Exception attendue"); }
11 catch (e) {
12 if (expected && !e.message.includes(expected)) {
13 failed++; console.log(" [FAIL] Mauvaise erreur : " + e.message);
14 } else { passed++; console.log(" [PASS] Exception levée : " + e.message); }
15 }
16}
17
18// =============================================
19// CODE DE PRODUCTION
20// =============================================
21
22class DinosaurManager {
23 constructor() { this.dinosaurs = new Map(); this.nextId = 1; }
24
25 addDinosaur(name, species, diet, zone) {
26 if (!name || !species) throw new Error("Name and species are required");
27 const id = "DINO-" + String(this.nextId).padStart(3, "0");
28 this.nextId++;
29 const dino = { id, name, species, diet, zone, health: 100, fed: false, lastFed: null };
30 this.dinosaurs.set(id, dino);
31 return dino;
32 }
33
34 getDinosaur(id) { return this.dinosaurs.get(id) || null; }
35
36 feedDinosaur(id, food) {
37 const dino = this.getDinosaur(id);
38 if (!dino) throw new Error("Dinosaur not found");
39 const correct = { carnivore: "meat", herbivore: "plants", omnivore: ["meat", "plants"] };
40 const expected = correct[dino.diet];
41 const ok = Array.isArray(expected) ? expected.includes(food) : expected === food;
42 if (!ok) return { success: false, message: "Wrong food type" };
43 dino.health = Math.min(100, dino.health + 10);
44 dino.fed = true;
45 dino.lastFed = new Date();
46 return { success: true, health: dino.health };
47 }
48
49 getByZone(zone) { return [...this.dinosaurs.values()].filter(d => d.zone === zone); }
50 getByDiet(diet) { return [...this.dinosaurs.values()].filter(d => d.diet === diet); }
51 getTotalCount() { return this.dinosaurs.size; }
52}
53
54// TODO : Écris les tests pour SecuritySystem et MonitoringService
55// Utilise le pattern AAA, le mocking et les fake timers
56
57// =============================================
58// TESTS
59// =============================================
60
61console.log("Suite : DinosaurManager\n");
62
63// équivalent de beforeEach
64function setup() { return new DinosaurManager(); }
65
66console.log(" describe : addDinosaur");
67let mgr = setup();
68let rex = mgr.addDinosaur("Rex", "T-Rex", "carnivore", "Zone A");
69assert(rex.id === "DINO-001", "first dino has ID DINO-001");
70assert(rex.name === "Rex", "has correct name");
71assert(rex.health === 100, "starts with 100 health");
72assert(rex.fed === false, "starts not fed");
73
74mgr = setup();
75assertThrows(() => mgr.addDinosaur("", "T-Rex", "carnivore", "A"), "Name and species");
76assertThrows(() => mgr.addDinosaur("Rex", "", "carnivore", "A"), "Name and species");
77
78console.log("\n describe : feedDinosaur");
79mgr = setup();
80rex = mgr.addDinosaur("Rex", "T-Rex", "carnivore", "Zone A");
81let feedResult = mgr.feedDinosaur(rex.id, "meat");
82assert(feedResult.success === true, "carnivore eats meat");
83assert(rex.fed === true, "marked as fed");
84assert(rex.health === 100, "health capped at 100");
85
86mgr = setup();
87rex = mgr.addDinosaur("Rex", "T-Rex", "carnivore", "A");
88feedResult = mgr.feedDinosaur(rex.id, "plants");
89assert(feedResult.success === false, "carnivore rejects plants");
90assert(feedResult.message === "Wrong food type", "correct error message");
91
92mgr = setup();
93assertThrows(() => mgr.feedDinosaur("FAKE-ID", "meat"), "not found");
94
95console.log("\n describe : queries");
96mgr = setup();
97mgr.addDinosaur("Rex", "T-Rex", "carnivore", "Zone A");
98mgr.addDinosaur("Blue", "Velociraptor", "carnivore", "Zone A");
99mgr.addDinosaur("Brachio", "Brachiosaurus", "herbivore", "Zone B");
100
101assert(mgr.getTotalCount() === 3, "total count is 3");
102assert(mgr.getByZone("Zone A").length === 2, "2 in Zone A");
103assert(mgr.getByZone("Zone B").length === 1, "1 in Zone B");
104assert(mgr.getByDiet("carnivore").length === 2, "2 carnivores");
105assert(mgr.getByDiet("herbivore").length === 1, "1 herbivore");
106assert(mgr.getDinosaur("FAKE") === null, "null for missing dino");
107
108// TODO : Ajoute tes propres tests ci-dessous !
109// - Teste SecuritySystem avec un alertService mocké
110// - Teste MonitoringService avec des fake timers
111// - Écris des tests d'intégration
112
113console.log(`\n=== Résultats : ${passed} réussis, ${failed} échoués ===`);
114console.log("\nAjoute plus de tests dans l'éditeur ci-dessus !");Tu as repéré une erreur dans cette leçon ?