Cours JavaScript et TypeScript · Module 11: Tests avec Jest

Test-Driven Development (TDD)

8 min de lecture
Dans cette leçon4

D'habitude, tu écris d'abord le code, puis, s'il reste du temps, les tests. Sauf qu'il en reste rarement, et que les tests écrits après coup ont tendance à confirmer ce que le code fait déjà, y compris ses bugs. Le TDD inverse cet ordre.

Le TDD est une méthodologie de développement dans laquelle tu écris d'abord les tests, et seulement ensuite le code de production. À Jurassic Park, c'est comme concevoir d'abord tous les systèmes de sécurité, et seulement ensuite faire venir les dinosaures. La méthode a été popularisée par Kent Beck, auteur du livre Test-Driven Development: By Example.

Le cycle Red-Green-Refactor

Le TDD repose sur trois étapes qui se répètent. Les couleurs viennent du rapport de tests : le rouge signifie l'alarme, et le vert, que tout fonctionne.

1. RED - Écris un test qui échoue

On commence par un test pour une fonction qui n'existe pas encore. On y décrit comment on veut l'utiliser : createFence doit accepter une zone et une tension, et renvoyer une clôture active :

1// ÉTAPE 1 : on écrit le test AVANT le code de production
2describe('FenceSystem', () => {
3  it('should create a fence with voltage', () => {
4    const fence = createFence('Zone A', 10000);
5
6    expect(fence.zone).toBe('Zone A');
7    expect(fence.voltage).toBe(10000);
8    expect(fence.isActive).toBe(true);
9  });
10});
11// Résultat : RED - createFence n'existe pas !

Le test échoue avec une erreur ReferenceError, car createFence n'existe pas, et c'est normal : un échec dû à une fonction manquante compte aussi comme un état rouge. L'important, c'est de voir le rouge de tes propres yeux. Un test qui n'a jamais été rouge ne vérifie peut-être rien du tout.

2. GREEN - Écris le minimum de code pour que le test passe

Maintenant, on écrit le code le plus simple qui satisfait le test, sans aucune réserve pour l'avenir ni fonction « au cas où » :

1// ÉTAPE 2 : on écrit le code le plus simple qui passe le test
2function createFence(zone, voltage) {
3  return {
4    zone,
5    voltage,
6    isActive: true
7  };
8}
9// Résultat : GREEN - le test passe !

L'implémentation est carrément naïve, mais le test passe. À ce stade, l'élégance ne compte pas, seul compte le feu vert.

3. REFACTOR - Améliore le code en gardant les tests verts

Avec un test vert comme filet de sécurité, on peut restructurer le code. On remplace le littéral d'objet par la classe Fence, et createFence devient une simple fabrique :

1// ÉTAPE 3 : on améliore le code (les tests restent verts !)
2class Fence {
3  constructor(zone, voltage) {
4    this.zone = zone;
5    this.voltage = voltage;
6    this.isActive = true;
7  }
8
9  getStatus() {
10    return this.isActive ? 'ACTIVE' : 'OFFLINE';
11  }
12}
13
14function createFence(zone, voltage) {
15  return new Fence(zone, voltage);
16}
17// Résultat : GREEN - les tests passent toujours après le refactoring !

Le test n'a pas changé d'un seul caractère et passe toujours, car le refactoring change la structure du code, pas son comportement. Je dois toutefois avouer que la méthode getStatus s'est glissée ici sans test. En TDD pur, le refactoring n'ajoute pas de nouvelles fonctionnalités : pour getStatus, il aurait d'abord fallu un test rouge séparé.

Exemple pratique de TDD

Construisons un système d'alimentation des dinosaures pas à pas. Chaque itération ajoute un test, et seulement autant de code que ce test en impose.

Itération 1 : Alimentation de base

Le premier test décrit le cas le plus simple : le T-Rex reçoit de la viande et le repas réussit. DinoFeeder est une classe dont la méthode feed accepte une espèce et une nourriture :

1// RED : on écrit le test
2test('carnivore eats meat', () => {
3  const feeder = new DinoFeeder();
4  const result = feeder.feed('T-Rex', 'meat');
5  expect(result.success).toBe(true);
6});

Voici maintenant l'implémentation la plus simple, qui transforme le rouge en vert. Pour l'instant, la méthode feed ne regarde même pas ses arguments :

1// GREEN : implémentation minimale
2class DinoFeeder {
3  feed(species, food) {
4    return { success: true };
5  }
6}
7// Le test passe - mais l'implémentation est trop simple !

Renvoyer toujours success: true ressemble à de la triche, mais c'est un minimum voulu. Les tests suivants forceront l'implémentation à devenir plus intelligente, et c'est exactement le principe du TDD.

Itération 2 : Validation du régime

Le deuxième test comble la lacune : un carnivore ne peut pas accepter de plantes, et le résultat doit indiquer la raison du refus :

1// RED : on ajoute un test pour une nourriture incorrecte
2test('carnivore rejects plants', () => {
3  const feeder = new DinoFeeder();
4  const result = feeder.feed('T-Rex', 'plants');
5  expect(result.success).toBe(false);
6  expect(result.reason).toBe('Wrong diet');
7});

Pour y répondre, la classe a besoin d'une table des régimes par espèce. Le constructeur l'enregistre dans this.diets, et feed vérifie si la nourriture correspond au régime :

1// GREEN : on étend l'implémentation
2class DinoFeeder {
3  constructor() {
4    this.diets = {
5      'T-Rex': 'carnivore',
6      'Triceratops': 'herbivore',
7      'Velociraptor': 'carnivore',
8    };
9  }
10
11  feed(species, food) {
12    const diet = this.diets[species];
13
14    if (diet === 'carnivore' && food !== 'meat') {
15      return { success: false, reason: 'Wrong diet' };
16    }
17    if (diet === 'herbivore' && food !== 'plants') {
18      return { success: false, reason: 'Wrong diet' };
19    }
20
21    return { success: true };
22  }
23}

Les deux tests sont maintenant verts. Le premier protège toujours le premier scénario : si la nouvelle validation cassait l'alimentation en viande, on le saurait immédiatement.

Itération 3 : Espèce inconnue

Le troisième test vérifie une exception, donc on enveloppe l'appel dans une fonction fléchée, comme avec le matcher toThrow :

1// RED : test pour une espèce inconnue
2test('unknown species throws error', () => {
3  const feeder = new DinoFeeder();
4  expect(() => feeder.feed('Unknown', 'meat')).toThrow('Unknown species');
5});

L'implémentation ajoute une seule condition au début de feed. Le commentaire « code précédent » désigne le constructeur avec la table des régimes de l'itération 2, qui ne change pas :

1// GREEN : on ajoute la gestion des erreurs
2class DinoFeeder {
3  // ... code précédent ...
4
5  feed(species, food) {
6    const diet = this.diets[species];
7
8    if (!diet) {
9      throw new Error('Unknown species');
10    }
11
12    if (diet === 'carnivore' && food !== 'meat') {
13      return { success: false, reason: 'Wrong diet' };
14    }
15    if (diet === 'herbivore' && food !== 'plants') {
16      return { success: false, reason: 'Wrong diet' };
17    }
18
19    return { success: true };
20  }
21}

Après trois itérations, les deux conditions jumelles pour carnivore et herbivore réclament un refactoring, par exemple une table « régime - nourriture autorisée ». Trois tests verts donnent le courage de le mener à bien.

Les principes du TDD

Le cycle repose sur quelques principes. Les trois premiers résument ce qu'on appelle les trois lois du TDD, formulées par Robert C. Martin :

  1. N'écris pas de code de production sans test rouge - toujours le test d'abord
  2. Écris le minimum de test - juste ce qu'il faut pour montrer l'erreur
  3. Écris le minimum de code - juste ce qu'il faut pour que le test passe
  4. Refactorise souvent - améliore le code quand les tests sont verts
  5. Petits pas - chaque itération ajoute une petite fonctionnalité

Les avantages du TDD

Que gagnes-tu à travailler à ce rythme ? Nous avons tout rassemblé dans un objet, comme il se doit dans le laboratoire du parc :

1// Le TDD mène à :
2const tddBenefits = {
3  betterDesign: 'Le code est conçu pour les tests = meilleur design',
4  lessDebugging: 'Les bugs sont attrapés immédiatement',
5  documentation: 'Les tests documentent le comportement du code',
6  confidence: 'Confiance lors des modifications et du refactoring',
7  coverage: 'Très haute couverture de code par les tests (mais pas de garantie de 100 %)'
8};

Attention à la dernière entrée : le TDD donne généralement une très haute couverture, car chaque ligne naît en réponse à un test, mais il ne garantit pas 100 %, et la couverture à elle seule ne prouve pas encore la qualité des tests. Mon conseil : applique le TDD là où tu sais quel doit être le résultat, c'est-à-dire dans la logique métier, la validation et les calculs. Sur des prototypes rapides, ce cycle rigide peut te ralentir.

Dans les exercices qui suivent cette leçon, tu construiras avec la méthode TDD un système de billets et un planning d'alimentation, et dans la leçon suivante, tu transposeras ces habitudes à TypeScript. Dans le laboratoire ci-dessous, tu suivras une simulation des itérations successives.

Retiens : en TDD, tu construis d'abord la clôture, et ce n'est qu'ensuite que tu fais entrer le dinosaure - le test rouge est le plan, le test vert l'enclos terminé.

Code de cette leçon : index.js
1// TDD - Test-Driven Development
2console.log("=== Jurassic Park - Protocoles de Sécurité TDD ===\n");
3
4// Simulation du TDD
5let testsPassed = 0, testsFailed = 0;
6
7function assert(condition, msg) {
8  if (condition) {
9    console.log("  [PASS] " + msg);
10    testsPassed++;
11  } else {
12    console.log("  [FAIL] " + msg);
13    testsFailed++;
14  }
15}
16
17function assertThrows(fn, expectedMsg) {
18  try {
19    fn();
20    console.log("  [FAIL] Exception attendue : " + expectedMsg);
21    testsFailed++;
22  } catch (e) {
23    if (e.message === expectedMsg) {
24      console.log("  [PASS] Exception levée : " + e.message);
25      testsPassed++;
26    } else {
27      console.log("  [FAIL] Mauvaise erreur : " + e.message + " (attendu : " + expectedMsg + ")");
28      testsFailed++;
29    }
30  }
31}
32
33// --- Cycle Red-Green-Refactor ---
34console.log("--- Cycle TDD : Red -> Green -> Refactor ---\n");
35console.log("1. RED    - Écris un test qui ÉCHOUE");
36console.log("2. GREEN  - Écris le minimum de code pour qu'il PASSE");
37console.log("3. REFACTOR - Améliore le code (les tests restent verts)\n");
38
39// --- Itération 1 : Création de fence ---
40console.log("=== Itération 1 : Création de fence ===\n");
41
42// RED -> GREEN
43function createFence(zone, voltage) {
44  return {
45    zone,
46    voltage,
47    isActive: true,
48    getStatus() { return this.isActive ? "ACTIVE" : "OFFLINE"; }
49  };
50}
51
52assert(createFence("Zone A", 10000).zone === "Zone A", "fence has zone");
53assert(createFence("Zone A", 10000).voltage === 10000, "fence has voltage");
54assert(createFence("Zone A", 10000).isActive === true, "fence is active");
55
56// --- Itération 2 : DinoFeeder ---
57console.log("\n=== Itération 2 : Système d'Alimentation (TDD) ===\n");
58
59class DinoFeeder {
60  constructor() {
61    this.diets = {
62      "T-Rex": "carnivore",
63      "Triceratops": "herbivore",
64      "Velociraptor": "carnivore",
65      "Gallimimus": "omnivore",
66    };
67    this.feedLog = [];
68  }
69
70  feed(species, food) {
71    const diet = this.diets[species];
72
73    if (!diet) {
74      throw new Error("Unknown species");
75    }
76
77    if (diet === "carnivore" && food !== "meat") {
78      return { success: false, reason: "Wrong diet" };
79    }
80    if (diet === "herbivore" && food !== "plants") {
81      return { success: false, reason: "Wrong diet" };
82    }
83
84    this.feedLog.push({ species, food, time: Date.now() });
85    return { success: true };
86  }
87
88  getFeedCount() {
89    return this.feedLog.length;
90  }
91}
92
93// Test 1 : Nourrir les carnivores
94console.log("Test : Nourrir les carnivores");
95const feeder = new DinoFeeder();
96const result1 = feeder.feed("T-Rex", "meat");
97assert(result1.success === true, "T-Rex eats meat");
98
99// Test 2 : Refuser la mauvaise nourriture
100console.log("\nTest : Refuser la mauvaise nourriture");
101const result2 = feeder.feed("T-Rex", "plants");
102assert(result2.success === false, "T-Rex rejects plants");
103assert(result2.reason === "Wrong diet", "reason is Wrong diet");
104
105// Test 3 : Herbivores
106console.log("\nTest : Nourrir les herbivores");
107const result3 = feeder.feed("Triceratops", "plants");
108assert(result3.success === true, "Triceratops eats plants");
109
110// Test 4 : Espèce inconnue
111console.log("\nTest : Espèce inconnue");
112assertThrows(() => feeder.feed("Unknown", "meat"), "Unknown species");
113
114// Test 5 : Compteur de nourrissages
115console.log("\nTest : Compteur de nourrissages");
116assert(feeder.getFeedCount() === 2, "2 successful feedings logged");
117
118// --- Résumé du TDD ---
119console.log("\n=== Principes du TDD ===");
120console.log("1. N'écris PAS de code sans un test rouge");
121console.log("2. Écris le minimum de test (un seul assert)");
122console.log("3. Écris le minimum de code (pour que ça passe)");
123console.log("4. Refactorise souvent");
124console.log("5. Petits pas - une fonctionnalité par itération");
125
126console.log(`\n=== Résultats : ${testsPassed} réussis, ${testsFailed} échoués ===`);

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. Quel est l'ordre correct des étapes du cycle TDD ?

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes d'une itération TDD dans le bon ordre :

  • Éditeur de code

    Écris des tests pour le système de billetterie du parc, puis implémente le code qui les fait passer.

  • Clique dans l'ordre

    Ordonne les éléments d'une itération TDD :

  • Tri horizontal

    Ordonne les éléments d'un test vérifiant une exception :

  • Éditeur de code

    Construis un FeedingScheduler de façon itérative : test -> code -> refactor.

Articles utiles