Cours JavaScript et TypeScript · Module 10: TypeScript en pratique
Test-Driven Development (TDD) - faire évoluer le code par les tests
Dans cette leçon6
Bienvenue dans le monde du Test-Driven Development ! Imagine que tu modifies une seule ligne du système des clôtures sans savoir si tu n'as pas, au passage, désactivé l'alarme de l'enclos des vélociraptors. Tout comme les scientifiques de Jurassic Park ont dû comprendre l'ADN des dinosaures avant de pouvoir les cloner, le TDD exige de définir d'abord le comportement attendu (le test), et seulement ensuite d'écrire l'implémentation.
Qu'est-ce que le TDD ?
Le Test-Driven Development est une technique de programmation dans laquelle on écrit d'abord un test qui échoue au départ, puis le code minimal nécessaire pour le faire passer, et enfin on refactorise le code en gardant les tests au vert. Cette technique a été popularisée par Kent Beck au tournant des XXe et XXIe siècles.
Le cycle Red-Green-Refactor
Ce rythme se répète en boucle, et chaque tour devrait être court, idéalement de quelques minutes :
1 ┌──────────────────────────────────────┐
2 │ │
3 │ RED │
4 │ Écris un test qui échoue │
5 │ │
6 └───────────────┬──────────────────────┘
7 │
8 ▼
9 ┌──────────────────────────────────────┐
10 │ │
11 │ GREEN │
12 │ Écris le code minimal │
13 │ qui fait passer le test │
14 │ │
15 └───────────────┬──────────────────────┘
16 │
17 ▼
18 ┌──────────────────────────────────────┐
19 │ │
20 │ REFACTOR │
21 │ Améliore le code en gardant │
22 │ les tests au vert │
23 │ │
24 └───────────────┬──────────────────────┘
25 │
26 └──────────► Recommence le cycleUn test rouge prouve que le test est capable de détecter quelque chose. Le vert signifie que l'exigence est remplie, et la refactorisation améliore le code sans changer son comportement.
Le TDD en pratique - un exemple à Jurassic Park
Imagine que tu construis un système de surveillance des dinosaures. On commence par un test.
Étape 1 : RED - on écrit le test
Un test Vitest décrit le comportement dans des blocs describe et it, et les commentaires montrent le pattern AAA : Arrange (préparer), Act (exécuter), Assert (vérifier) :
1// dinosaur.test.js
2import { describe, it, expect } from 'vitest';
3import { Dinosaur } from './dinosaur';
4
5describe('Dinosaur', () => {
6 describe('constructor', () => {
7 it('should create a dinosaur with name and species', () => {
8 // Arrange & Act
9 const dino = new Dinosaur('Rex', 'Tyrannosaurus');
10
11 // Assert
12 expect(dino.name).toBe('Rex');
13 expect(dino.species).toBe('Tyrannosaurus');
14 });
15 });
16
17 describe('isDangerous', () => {
18 it('should return true for carnivore dinosaurs', () => {
19 const dino = new Dinosaur('Rex', 'Tyrannosaurus', 'carnivore');
20 expect(dino.isDangerous()).toBe(true);
21 });
22
23 it('should return false for herbivore dinosaurs', () => {
24 const dino = new Dinosaur('Steggy', 'Stegosaurus', 'herbivore');
25 expect(dino.isDangerous()).toBe(false);
26 });
27 });
28});Pour l'instant, ce test échoue, et c'est une bonne chose : nous n'avons pas encore de classe Dinosaur, donc le rapport s'allume en rouge.
Étape 2 : GREEN - le code minimal
On écrit le code le plus simple qui satisfait les tests, sans rien prévoir pour l'avenir :
1// dinosaur.js
2export class Dinosaur {
3 constructor(name, species, diet = 'unknown') {
4 this.name = name;
5 this.species = species;
6 this.diet = diet;
7 }
8
9 isDangerous() {
10 return this.diet === 'carnivore';
11 }
12}Maintenant, les tests passent. Le code a l'air modeste, mais il fait exactement ce que décrivent les tests, et rien de plus.
Étape 3 : REFACTOR - on améliore le code
Avec le filet de sécurité vert, on range le code : champs privés #, getters et une constante avec la liste des régimes dangereux :
1// dinosaur.js - après la refactorisation
2const DANGEROUS_DIETS = ['carnivore', 'omnivore'];
3
4export class Dinosaur {
5 #name;
6 #species;
7 #diet;
8
9 constructor(name, species, diet = 'unknown') {
10 this.#validateInput(name, species);
11 this.#name = name;
12 this.#species = species;
13 this.#diet = diet;
14 }
15
16 get name() { return this.#name; }
17 get species() { return this.#species; }
18 get diet() { return this.#diet; }
19
20 #validateInput(name, species) {
21 if (!name || !species) {
22 throw new Error('Name and species are required');
23 }
24 }
25
26 isDangerous() {
27 return DANGEROUS_DIETS.includes(this.#diet);
28 }
29}Attention : ce n'est pas une refactorisation pure. Cette version considère les omnivores comme dangereux et lève une erreur en l'absence de nom, autrement dit elle change le comportement. En vrai TDD, chacune de ces règles commence par un nouveau test rouge, et la refactorisation ne modifie que la structure du code.
TDD avancé - le système de sécurité du parc
Construisons maintenant un système plus complexe, en commençant par une série complète de tests.
Test Suite pour SecuritySystem
beforeEach crée un système tout neuf avant chaque test, donc les tests ne s'influencent pas entre eux, et vi.fn() crée une fonction espionne (spy) qui mémorise ses appels :
1// security-system.test.js
2import { describe, it, expect, beforeEach, vi } from 'vitest';
3import { SecuritySystem } from './security-system';
4import { Enclosure } from './enclosure';
5import { Dinosaur } from './dinosaur';
6
7describe('SecuritySystem', () => {
8 let securitySystem;
9 let mockEnclosure;
10 let mockDinosaur;
11
12 beforeEach(() => {
13 mockDinosaur = new Dinosaur('Rex', 'Tyrannosaurus', 'carnivore');
14 mockEnclosure = new Enclosure('Paddock 1', [mockDinosaur], {
15 fenceVoltage: 10000,
16 isActive: true
17 });
18 securitySystem = new SecuritySystem();
19 securitySystem.addEnclosure(mockEnclosure);
20 });
21
22 describe('addEnclosure', () => {
23 it('should add an enclosure to monitoring', () => {
24 const newEnclosure = new Enclosure('Paddock 2', [], { fenceVoltage: 5000 });
25 securitySystem.addEnclosure(newEnclosure);
26
27 expect(securitySystem.getEnclosures()).toHaveLength(2);
28 });
29
30 it('should throw error for duplicate enclosure names', () => {
31 const duplicate = new Enclosure('Paddock 1', [], { fenceVoltage: 5000 });
32
33 expect(() => securitySystem.addEnclosure(duplicate))
34 .toThrow('Enclosure with this name already exists');
35 });
36 });
37
38 describe('checkStatus', () => {
39 it('should return SAFE when all fences are active', () => {
40 expect(securitySystem.checkStatus()).toBe('SAFE');
41 });
42
43 it('should return DANGER when any fence is inactive', () => {
44 mockEnclosure.deactivateFence();
45
46 expect(securitySystem.checkStatus()).toBe('DANGER');
47 });
48
49 it('should return CRITICAL when dangerous dino escapes', () => {
50 mockEnclosure.deactivateFence();
51 mockEnclosure.markEscaped(mockDinosaur);
52
53 expect(securitySystem.checkStatus()).toBe('CRITICAL');
54 });
55 });
56
57 describe('getAlerts', () => {
58 it('should return empty array when everything is safe', () => {
59 expect(securitySystem.getAlerts()).toEqual([]);
60 });
61
62 it('should generate alert when fence voltage drops', () => {
63 mockEnclosure.setFenceVoltage(1000);
64
65 const alerts = securitySystem.getAlerts();
66
67 expect(alerts).toHaveLength(1);
68 expect(alerts[0]).toMatchObject({
69 type: 'LOW_VOLTAGE',
70 enclosure: 'Paddock 1',
71 severity: 'WARNING'
72 });
73 });
74 });
75
76 describe('emergencyProtocol', () => {
77 it('should trigger alarms for all affected zones', async () => {
78 const alarmSpy = vi.fn();
79 securitySystem.onAlarm(alarmSpy);
80
81 await securitySystem.emergencyProtocol('Paddock 1');
82
83 expect(alarmSpy).toHaveBeenCalledWith({
84 type: 'EMERGENCY',
85 enclosure: 'Paddock 1',
86 actions: ['LOCKDOWN', 'EVACUATE', 'ALERT_SECURITY']
87 });
88 });
89 });
90});Les tests parlent la langue du parc : un enclos en double est une erreur, une clôture désactivée correspond à l'état DANGER, et un prédateur en fuite, à l'état CRITICAL.
L'implémentation guidée par le TDD
L'implémentation se construit test après test, jusqu'à ce qu'ils passent tous au vert :
1// security-system.js
2export class SecuritySystem {
3 #enclosures = new Map();
4 #alarmCallbacks = [];
5
6 addEnclosure(enclosure) {
7 if (this.#enclosures.has(enclosure.name)) {
8 throw new Error('Enclosure with this name already exists');
9 }
10 this.#enclosures.set(enclosure.name, enclosure);
11 }
12
13 getEnclosures() {
14 return Array.from(this.#enclosures.values());
15 }
16
17 checkStatus() {
18 const enclosures = this.getEnclosures();
19
20 for (const enclosure of enclosures) {
21 const escaped = enclosure.getEscapedDinosaurs();
22 if (escaped.some(dino => dino.isDangerous())) {
23 return 'CRITICAL';
24 }
25 }
26
27 for (const enclosure of enclosures) {
28 if (!enclosure.isFenceActive()) {
29 return 'DANGER';
30 }
31 }
32
33 return 'SAFE';
34 }
35
36 getAlerts() {
37 const alerts = [];
38 const MINIMUM_SAFE_VOLTAGE = 5000;
39
40 for (const enclosure of this.getEnclosures()) {
41 if (enclosure.getFenceVoltage() < MINIMUM_SAFE_VOLTAGE) {
42 alerts.push({
43 type: 'LOW_VOLTAGE',
44 enclosure: enclosure.name,
45 severity: 'WARNING',
46 currentVoltage: enclosure.getFenceVoltage()
47 });
48 }
49 }
50
51 return alerts;
52 }
53
54 onAlarm(callback) {
55 this.#alarmCallbacks.push(callback);
56 }
57
58 async emergencyProtocol(enclosureName) {
59 const alarmData = {
60 type: 'EMERGENCY',
61 enclosure: enclosureName,
62 actions: ['LOCKDOWN', 'EVACUATE', 'ALERT_SECURITY']
63 };
64
65 for (const callback of this.#alarmCallbacks) {
66 await callback(alarmData);
67 }
68 }
69}L'ordre dans checkStatus n'est pas un hasard : d'abord l'évasion, ensuite les clôtures, car le test CRITICAL désactive une clôture et signale une évasion en même temps. La classe Enclosure n'apparaît pas ici, mais son interface découle directement des tests.
Les avantages du TDD
Des modifications en toute confiance
Le principal avantage, c'est le filet de sécurité à chaque modification :
1// Avec le TDD, tu as un filet de sécurité : si les tests passent, les comportements vérifiés fonctionnent toujoursLes tests ne protègent que ce qu'ils vérifient. Un résultat vert ne prouve pas l'absence de bugs, mais il te dit que les comportements décrits fonctionnent toujours.
La documentation dans le code
Des tests bien nommés se lisent comme la spécification du parc, sans avoir à regarder l'implémentation :
1describe('DinosaurFeeder', () => {
2 it('should feed herbivores plants only', () => {
3 // Ce test documente clairement le comportement attendu
4 });
5
6 it('should not feed carnivores to each other', () => {
7 // Une règle métier inscrite dans un test !
8 });
9});Les descriptions dans it sont des règles métier qui ne deviendront pas obsolètes en silence comme une documentation ordinaire, car le test cesserait alors de passer.
Un meilleur design - le TDD impose le loose coupling
Une classe qui crée elle-même sa base de données et son mailer est difficile à tester, donc le TDD mène naturellement à l'injection des dépendances par le constructeur :
1// Le TDD mène à la Dependency Injection
2class GoodParkManager {
3 constructor(database, mailer) {
4 this.database = database;
5 this.mailer = mailer;
6 }
7}
8
9// Facile à tester avec des mocks :
10const mockDb = { query: vi.fn() };
11const manager = new GoodParkManager(mockDb, mockMailer);Dans le test, tu passes des doublures (mocks), en production de vrais objets, et la classe ne voit pas la différence. C'est le principe d'inversion des dépendances vu dans la leçon sur SOLID.
Les anti-patterns du TDD - ce qu'il faut éviter
Les tests aussi peuvent être mauvais. Compare ces deux paires d'exemples :
1// ANTI-PATTERN : un test trop général
2it('should work', () => {
3 expect(doSomething()).toBeTruthy();
4});
5
6// CORRECT : un test précis
7it('should return dinosaur count for specific species', () => {
8 expect(countDinosaurs('Velociraptor')).toBe(8);
9});
10
11// ANTI-PATTERN : tester l'implémentation au lieu du comportement
12it('should call database.query', () => {
13 service.getDinosaurs();
14 expect(database.query).toHaveBeenCalled();
15});
16
17// CORRECT : tester le comportement
18it('should return all dinosaurs from paddock', () => {
19 const result = service.getDinosaurs();
20 expect(result).toHaveLength(5);
21});Un test qui vérifie l'appel à database.query casse à chaque changement d'implémentation, alors que le comportement est correct. Teste le résultat, et non la façon de l'obtenir.
Les outils du TDD
Vitest - un framework moderne
Vitest est un runner rapide compatible avec l'API de Jest, le choix naturel dans les projets Vite. Tu définis les seuils de couverture de code dans le champ coverage.thresholds :
1// vitest.config.js
2import { defineConfig } from 'vitest/config';
3
4export default defineConfig({
5 test: {
6 globals: true,
7 environment: 'node',
8 coverage: {
9 provider: 'v8',
10 thresholds: { statements: 80, branches: 80, functions: 80, lines: 80 }
11 }
12 }
13});Lancer les tests avec la couverture se terminera par une erreur si le résultat descend sous 80 %. Considère ce seuil comme une alarme, et non comme un objectif en soi.
Jest - un framework populaire
Dans Jest, tu écris ce même seuil dans le champ coverageThreshold :
1// jest.config.js
2module.exports = {
3 testEnvironment: 'node',
4 coverageThreshold: {
5 global: { statements: 80, branches: 80, functions: 80, lines: 80 }
6 }
7};Les deux outils ont une API similaire (describe, it, expect), donc passer de l'un à l'autre est facile. Pour les nouveaux projets, je te recommande Vitest, et dans les projets existants, reste sur Jest s'il fonctionne bien.
Le TDD est une technique puissante qui change la façon de penser le code. Au lieu de « écrire d'abord, tester ensuite », tu adoptes l'approche « définir d'abord les attentes, implémenter ensuite ». Dans la leçon suivante, tu combineras TypeScript avec des frameworks populaires.
Retiens : un test, c'est une clôture dressée avant l'arrivée du dinosaure, et non après son évasion.
Code de cette leçon : index.js
1// SharedArrayBuffer et Atomics - mémoire partagée entre threads
2console.log("Jurassic Park - Système de mémoire partagée ADN");
3console.log("Simulation de SharedArrayBuffer et Atomics\n");
4
5// ====================================
6// 1. SHAREDARRAYBUFFER - LES BASES
7// ====================================
8console.log("=== 1. SharedArrayBuffer ===\n");
9
10// Création d'un SharedArrayBuffer (16 octets = 4 x Int32)
11const sharedBuffer = new SharedArrayBuffer(16);
12const sharedView = new Int32Array(sharedBuffer);
13
14console.log("SharedArrayBuffer créé :");
15console.log(" Taille :", sharedBuffer.byteLength, "octets");
16console.log(" Éléments Int32 :", sharedView.length);
17
18// Initialisation des valeurs
19sharedView[0] = 100; // Nombre de dinosaures
20sharedView[1] = 85; // Niveau de sécurité
21sharedView[2] = 0; // Compteur d'alertes
22sharedView[3] = 1; // Statut du système (1 = actif)
23
24console.log("\nValeurs initiales :");
25console.log(" Dinosaures :", sharedView[0]);
26console.log(" Sécurité :", sharedView[1], "%");
27console.log(" Alertes :", sharedView[2]);
28console.log(" Statut :", sharedView[3] === 1 ? "ACTIF" : "INACTIF");
29
30// ====================================
31// 2. ATOMICS - OPÉRATIONS ATOMIQUES
32// ====================================
33console.log("\n=== 2. Opérations Atomics ===\n");
34
35// Atomics.add - ajouter une valeur de façon atomique
36const previousAlerts = Atomics.add(sharedView, 2, 1);
37console.log("Atomics.add(alerts, 1) :");
38console.log(" Valeur précédente :", previousAlerts);
39console.log(" Nouvelle valeur :", Atomics.load(sharedView, 2));
40
41// Atomics.sub - soustraire une valeur de façon atomique
42const previousSafety = Atomics.sub(sharedView, 1, 10);
43console.log("\nAtomics.sub(safety, 10) :");
44console.log(" Valeur précédente :", previousSafety);
45console.log(" Nouvelle valeur :", Atomics.load(sharedView, 1));
46
47// Atomics.exchange - échanger des valeurs
48const oldStatus = Atomics.exchange(sharedView, 3, 0);
49console.log("\nAtomics.exchange(status, 0) :");
50console.log(" Ancienne valeur :", oldStatus);
51console.log(" Nouvelle valeur :", Atomics.load(sharedView, 3));
52
53// Atomics.compareExchange - échange conditionnel
54const result = Atomics.compareExchange(sharedView, 3, 0, 1);
55console.log("\nAtomics.compareExchange(status, expected=0, new=1) :");
56console.log(" Résultat (précédent) :", result);
57console.log(" Valeur actuelle :", Atomics.load(sharedView, 3));
58
59// ====================================
60// 3. SIMULATION DE LA COMMUNICATION ENTRE THREADS
61// ====================================
62console.log("\n=== 3. Simulation de la communication entre threads ===\n");
63
64// Simulation producteur-consommateur avec Atomics
65class DinoMonitorSystem {
66 constructor() {
67 this.buffer = new SharedArrayBuffer(32);
68 this.data = new Int32Array(this.buffer);
69 // Indices : 0=lock, 1=dinoCount, 2=alertCount, 3=safetyLevel
70 }
71
72 // Simulation d'un mutex avec Atomics
73 acquireLock() {
74 while (Atomics.compareExchange(this.data, 0, 0, 1) !== 0) {
75 // Spin-wait (dans du vrai code : Atomics.wait)
76 }
77 console.log(" Lock acquis");
78 }
79
80 releaseLock() {
81 Atomics.store(this.data, 0, 0);
82 console.log(" Lock relâché");
83 }
84
85 // Ajouter un dinosaure (en toute sécurité)
86 addDinosaur() {
87 this.acquireLock();
88 const current = Atomics.load(this.data, 1);
89 Atomics.store(this.data, 1, current + 1);
90 console.log(` Dinosaure ajouté. Total : ${current + 1}`);
91 this.releaseLock();
92 }
93
94 // Signaler une alerte (en toute sécurité)
95 reportAlert() {
96 // Atomics.add est atomique en lui-même - pas besoin de lock
97 const prev = Atomics.add(this.data, 2, 1);
98 console.log(` Alerte #${prev + 1} signalée`);
99 }
100
101 getStatus() {
102 return {
103 dinosaurs: Atomics.load(this.data, 1),
104 alerts: Atomics.load(this.data, 2),
105 safety: Atomics.load(this.data, 3)
106 };
107 }
108}
109
110const monitor = new DinoMonitorSystem();
111
112console.log("Simulation d'opérations depuis plusieurs threads :");
113monitor.addDinosaur();
114monitor.addDinosaur();
115monitor.reportAlert();
116monitor.addDinosaur();
117monitor.reportAlert();
118
119console.log("\nÉtat du système :", monitor.getStatus());
120
121// ====================================
122// 4. COMPARAISON AVEC UN BUFFER ORDINAIRE
123// ====================================
124console.log("\n=== 4. SharedArrayBuffer vs ArrayBuffer ===\n");
125
126console.log("ArrayBuffer :");
127console.log(" - Copié entre les threads (structured clone)");
128console.log(" - Sûr mais plus lent");
129console.log(" - Aucun problème de race conditions");
130
131console.log("\nSharedArrayBuffer :");
132console.log(" - Partagé entre les threads (zero-copy)");
133console.log(" - Plus rapide mais nécessite une synchronisation");
134console.log(" - Nécessite Atomics pour un accès sûr");
135console.log(" - Nécessite les en-têtes COOP/COEP");
136
137console.log("\nQuand utiliser SharedArrayBuffer :");
138console.log("- Calculs numériques intensifs");
139console.log("- Traitement de grands jeux de données");
140console.log("- Applications temps réel (jeux, simulations)");
141console.log("- Partage d'état entre Web Workers");Tu as repéré une erreur dans cette leçon ?