Chapitre 1018 minIntermédiaire
TDD : le développement piloté par les tests, avec un kata corrigé
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- appliquer le cycle rouge, vert, refactor ;
- avancer par petits pas en écrivant le test avant le code ;
- distinguer TDD, BDD et ATDD ;
- utiliser le TDD pour corriger un bug.
3 exercices corrigés1 schéma
Le TDD (Test-Driven Development, développement piloté par les tests) est une méthode de programmation où l'on écrit le test avant le code, par tout petits pas : un test qui échoue (rouge), le code minimal qui le fait passer (vert), puis une amélioration du code sans changer son comportement (refactor). Formalisée par Kent Beck au début des années 2000, elle produit un code testé à 100 % par construction et une conception plus simple. Ce chapitre déroule un kata complet sur une règle de Prêt'Matos, étape par étape.
Le cycle rouge, vert, refactor
| Étape | Ce que tu fais | Ce que tu t'interdis |
|---|---|---|
| Rouge | Écrire un test pour le prochain petit comportement, le lancer, le voir échouer | Écrire plusieurs tests d'un coup ; écrire du code de production |
| Vert | Écrire le code le plus simple qui fait passer le test, même s'il paraît naïf | Anticiper les cas suivants ; « tant que j'y suis, je gère aussi… » |
| Refactor | Nettoyer : renommer, supprimer une duplication, extraire une fonction, dans le code et dans les tests | Ajouter un comportement ; laisser la suite au rouge |
Robert C. Martin a résumé la discipline en trois lois :
- Tu n'écris pas de code de production, sauf pour faire passer un test qui échoue.
- Tu n'écris pas plus de test qu'il n'en faut pour échouer (et une erreur de compilation est un échec).
- Tu n'écris pas plus de code de production qu'il n'en faut pour faire passer le test qui échoue.
Voir le test échouer d'abord n'est pas une formalité : c'est la preuve qu'il teste quelque chose. Un test écrit après le code, qui passe du premier coup, peut très bien ne rien vérifier.
Le kata : les relances de Prêt'Matos
Un kata est un petit exercice de programmation que l'on refait pour s'entraîner, comme les enchaînements des arts martiaux. Voici la règle à coder, donnée par Nadia, la responsable logistique :
| Situation du prêt | Niveau de relance |
|---|---|
| Le matériel a été rendu | aucune |
| La veille de la date de retour | rappel (un courriel au salarié) |
| Le jour du retour, ou avant la veille | aucune |
| 1 ou 2 jours de retard | relance (un courriel insistant au salarié) |
| 3 jours de retard ou plus | escalade (un courriel au salarié et à son responsable) |
On veut une fonction niveauDeRelance(pret, aujourdhui) qui renvoie 'aucune', 'rappel', 'relance' ou 'escalade'. On réutilise deux fonctions déjà écrites et testées : joursDeRetard (de regles.js) et lendemain (de rappels.js). Lance npx vitest en mode surveillance dans un terminal : chaque enregistrement relance les tests.
Avant de commencer, on écrit la liste des tests à faire, du plus simple au plus compliqué. Elle évoluera en route.
- aucune relance tant que le retour est loin
- une relance au premier jour de retard
- une escalade à partir de 3 jours de retard
- un rappel la veille du retour
- aucune relance pour un matériel rendu
Tour 1 : le cas le plus simple
Rouge. Le premier test porte sur le cas le plus simple : un prêt qui se termine le 14 mars, et nous sommes le 10.
// src/relances.test.js
import { describe, test, expect } from 'vitest';
import { niveauDeRelance } from './relances.js';
const pret = { materiel: 'PC-07', fin: '2027-03-14', rendu: false };
describe('niveauDeRelance', () => {
test('aucune relance tant que le retour est loin', () => {
expect(niveauDeRelance(pret, '2027-03-10')).toBe('aucune');
});
});
Le test échoue : le fichier relances.js n'existe pas, l'import est impossible. C'est un échec légitime (deuxième loi).
Vert. Le code le plus simple qui fait passer ce test :
// src/relances.js
export function niveauDeRelance() {
return 'aucune';
}
Oui, c'est « tricher ». C'est voulu : ce code est correct pour tout ce que les tests demandent aujourd'hui. Le test suivant obligera à écrire la vraie logique. Cette façon d'avancer évite d'écrire du code qu'aucun test ne justifie.
Refactor. Rien à nettoyer.
Tour 2 : le premier jour de retard
Rouge.
test('une relance au premier jour de retard', () => {
expect(niveauDeRelance(pret, '2027-03-15')).toBe('relance');
});
FAIL src/relances.test.js > niveauDeRelance > une relance au premier jour de retard
AssertionError: expected 'aucune' to be 'relance' // Object.is equality
Expected: "relance"
Received: "aucune"
Le test échoue pour la bonne raison : la fonction renvoie toujours 'aucune'.
Vert.
// src/relances.js
import { joursDeRetard } from './regles.js';
export function niveauDeRelance(pret, aujourdhui) {
if (joursDeRetard(pret.fin, aujourdhui) > 0) return 'relance';
return 'aucune';
}
Les deux tests sont verts.
Tour 3 : l'escalade
Rouge.
test('une escalade à partir de 3 jours de retard', () => {
expect(niveauDeRelance(pret, '2027-03-17')).toBe('escalade');
});
Le 17 mars, le prêt a 3 jours de retard : la fonction répond 'relance', le test échoue.
Vert. On a besoin du nombre de jours deux fois : on le range dans une variable.
export function niveauDeRelance(pret, aujourdhui) {
const retard = joursDeRetard(pret.fin, aujourdhui);
if (retard >= 3) return 'escalade';
if (retard > 0) return 'relance';
return 'aucune';
}
Refactor. Le 3 est un nombre magique : dans six mois, personne ne saura d'où il vient. On le nomme.
const RETARD_ESCALADE = 3;
export function niveauDeRelance(pret, aujourdhui) {
const retard = joursDeRetard(pret.fin, aujourdhui);
if (retard >= RETARD_ESCALADE) return 'escalade';
if (retard > 0) return 'relance';
return 'aucune';
}
On relance : tout est vert. Le refactor n'a rien changé au comportement, et les tests le prouvent.
Tour 4 : une valeur limite qui passe du premier coup
La frontière entre relance et escalade est entre 2 et 3 jours. Le 3 est testé ; le 2 ne l'est pas. On ajoute à la liste « encore une simple relance à 2 jours ».
test('encore une simple relance à 2 jours de retard', () => {
expect(niveauDeRelance(pret, '2027-03-16')).toBe('relance');
});
Il passe immédiatement. Est-ce contraire au TDD ? Non : ce test ne pilote pas de nouveau code, il verrouille une limite. Si quelqu'un abaisse un jour le seuil (RETARD_ESCALADE = 2, ou retard > 1), il passera au rouge, alors que le test des 3 jours resterait vert. Pour t'assurer qu'il teste bien quelque chose, remplace temporairement RETARD_ESCALADE = 3 par 2 : il échoue ; remets 3.
Tour 5 : le rappel de la veille
Rouge.
test('un rappel la veille du retour', () => {
expect(niveauDeRelance(pret, '2027-03-13')).toBe('rappel');
});
Vert.
import { joursDeRetard } from './regles.js';
import { lendemain } from './rappels.js';
const RETARD_ESCALADE = 3;
export function niveauDeRelance(pret, aujourdhui) {
const retard = joursDeRetard(pret.fin, aujourdhui);
if (retard >= RETARD_ESCALADE) return 'escalade';
if (retard > 0) return 'relance';
if (lendemain(aujourdhui) === pret.fin) return 'rappel';
return 'aucune';
}
On en profite pour ajouter à la liste la limite oubliée : « aucune relance le jour du retour ». Le 14 mars, il n'y a ni retard, ni veille : la fonction doit répondre 'aucune'. Le test passe du premier coup, comme au tour 4 ; il protège contre une future version qui enverrait un rappel le jour même.
test('aucune relance le jour du retour', () => {
expect(niveauDeRelance(pret, '2027-03-14')).toBe('aucune');
});
Tour 6 : le matériel rendu
Rouge. Un matériel rendu en retard ne doit plus générer de relance.
test('aucune relance pour un matériel rendu, même en retard', () => {
expect(niveauDeRelance({ ...pret, rendu: true }, '2027-03-20')).toBe('aucune');
});
AssertionError: expected 'escalade' to be 'aucune' // Object.is equality
Le 20 mars, le prêt a 6 jours de retard : sans le contrôle du retour, la fonction escalade. Imagine le courriel reçu par le responsable de Julie pour un portable rendu depuis trois jours.
Vert. Le cas du matériel rendu passe avant tout le reste :
// src/relances.js : version finale du kata TDD
import { joursDeRetard } from './regles.js';
import { lendemain } from './rappels.js';
const RETARD_ESCALADE = 3;
export function niveauDeRelance(pret, aujourdhui) {
if (pret.rendu) return 'aucune';
const retard = joursDeRetard(pret.fin, aujourdhui);
if (retard >= RETARD_ESCALADE) return 'escalade';
if (retard > 0) return 'relance';
if (lendemain(aujourdhui) === pret.fin) return 'rappel';
return 'aucune';
}
Refactor. Le code se lit de haut en bas comme le tableau de Nadia. Rien à changer. La suite complète :
✓ src/relances.test.js > niveauDeRelance > aucune relance tant que le retour est loin 2ms
✓ src/relances.test.js > niveauDeRelance > une relance au premier jour de retard 0ms
✓ src/relances.test.js > niveauDeRelance > une escalade à partir de 3 jours de retard 0ms
✓ src/relances.test.js > niveauDeRelance > encore une simple relance à 2 jours de retard 0ms
✓ src/relances.test.js > niveauDeRelance > un rappel la veille du retour 0ms
✓ src/relances.test.js > niveauDeRelance > aucune relance le jour du retour 0ms
✓ src/relances.test.js > niveauDeRelance > aucune relance pour un matériel rendu, même en retard 0ms
Test Files 1 passed (1)
Tests 7 passed (7)
Sept tests, une fonction de dix lignes, chaque ligne justifiée par un test. Et une liste de tests qui documente la règle mieux qu'un commentaire : un nouveau développeur comprend le comportement en lisant les noms.
Ce que le TDD apporte, et ses limites
| Ce qu'il apporte | Ses limites |
|---|---|
| Chaque ligne de code est couverte par un test : la couverture vient toute seule | Il demande de l'entraînement : les premiers katas paraissent lents |
| On réfléchit à l'interface (nom, paramètres, résultat) avant l'implémentation, en se mettant à la place de celui qui appellera la fonction | Il convient mal à l'exploration, quand on ne sait pas encore ce qu'on veut (un prototype d'interface, un essai de bibliothèque) |
| Le code reste simple : on n'écrit que ce qu'un test demande | Il ne remplace pas la réflexion sur les cas : une règle oubliée dans la liste ne sera pas codée |
| Le refactoring devient sans risque, à chaque tour | Il teste ce que le développeur a compris, pas ce que l'utilisateur voulait : la recette reste nécessaire |
| Les petits pas réduisent le temps passé à déboguer : si un test casse, le défaut est dans les trois dernières lignes |
TDD, BDD et ATDD
Deux méthodes prolongent le TDD au-delà du code :
- Le BDD (Behavior-Driven Development, développement piloté par le comportement) écrit les tests dans le langage du métier, au format Étant donné / Quand / Alors. Les scénarios, souvent rédigés en Gherkin, servent à la fois de spécification et de tests exécutables avec des outils comme Cucumber. Le cours Gestion de projet montre comment écrire ces scénarios pour les critères d'acceptation d'une user story (critères d'acceptation).
- L'ATDD (Acceptance Test-Driven Development) applique la même logique aux tests d'acceptation : le Product Owner, un testeur et un développeur écrivent ensemble les tests d'acceptation avant de développer la story ; la story est finie quand ils passent.
Dans les trois cas, l'idée est la même : décider de ce qui prouvera que c'est fait, avant de le faire.
S'entraîner avec les katas classiques
| Kata | Le principe | Ce qu'il fait travailler |
|---|---|---|
| FizzBuzz | Afficher les nombres de 1 à 100, « Fizz » pour les multiples de 3, « Buzz » pour ceux de 5, « FizzBuzz » pour les deux | Les premiers pas, l'ordre des conditions |
| Nombres romains | Convertir 1 994 en MCMXCIV, et l'inverse | Les règles qui émergent test après test, le refactoring |
| String Calculator | Additionner les nombres d'une chaîne "1,2,3", puis gérer les séparateurs, les négatifs… | Les exigences qui arrivent une par une |
| Bowling | Calculer le score d'une partie, avec les strikes et les spares | Une logique plus riche, le choix des structures |
Refais le même kata plusieurs fois, dans plusieurs langages : la deuxième fois, tu remarques l'ordre des tests ; la troisième, la taille des pas.
Exercices
Exercice 1 sur 3 Le tour suivant Réussi À revoir
Nadia ajoute une règle : « pendant les congés de l'entreprise, aucune relance n'est envoyée ». La fonction reçoit un troisième paramètre, la liste des jours de congé : niveauDeRelance(pret, aujourdhui, conges).
- Écris le premier test rouge de ce tour.
- Écris le code le plus simple qui le fait passer.
- Pourquoi les sept tests existants risquent-ils de casser, et comment l'éviter ?
Voir la réponseMasquer la réponse de l'exercice 1
- Le cas le plus simple : un prêt en retard, un jour de congé.
test('aucune relance un jour de congé, même en retard', () => {
expect(niveauDeRelance(pret, '2027-03-17', ['2027-03-17'])).toBe('aucune');
});
Il est rouge : la fonction renvoie 'escalade'.
export function niveauDeRelance(pret, aujourdhui, conges = []) {
if (pret.rendu || conges.includes(aujourdhui)) return 'aucune';
// … le reste est inchangé
}
- Les sept tests existants appellent la fonction avec deux arguments : sans valeur par défaut,
congesvaudraitundefinedetconges.includeslèverait uneTypeError. La valeur par défautconges = []garde l'ancien comportement : c'est une évolution compatible, et la suite existante le prouve en restant verte.
Tu avais trouvé ?
Exercice 2 sur 3 Repérer l'écart au TDD Réussi À revoir
Une élève dit faire du TDD. Voici ce qu'elle a fait, dans l'ordre. Repère les écarts à la méthode.
- Elle écrit la fonction
calculerAmendecomplète, avec ses quatre cas. - Elle écrit huit tests d'un coup.
- Elle lance les tests : sept passent, un échoue.
- Elle modifie l'attendu du test qui échoue pour qu'il corresponde à ce que la fonction renvoie.
Voir la réponseMasquer la réponse de l'exercice 2
- Le code est écrit avant les tests : c'est l'inverse du TDD (première loi).
- Plusieurs tests d'un coup : on perd les petits pas et le lien entre chaque test et le code qu'il justifie.
- Aucun test n'a été vu échouer avant d'écrire le code : rien ne prouve qu'ils testent quelque chose.
- C'est l'erreur la plus grave : elle a changé le test pour qu'il confirme le code, sans se demander qui avait raison. L'attendu doit venir de la règle métier. Si la règle dit autre chose que la fonction, c'est le code qu'il faut corriger.
Tu avais trouvé ?
Exercice 3 sur 3 Un kata en TDD Réussi À revoir
Écris en TDD une fonction codeInventaire(type, numero) qui fabrique le code d'un matériel : le préfixe du type ('portable' donne PC, 'videoprojecteur' donne VP, 'casque' donne CA), un tiret, puis le numéro sur deux chiffres (7 donne 07). Un type inconnu lève une erreur. Donne la liste des tests dans l'ordre où tu les écrirais, puis le code final.
Voir la réponseMasquer la réponse de l'exercice 3
Une liste possible, du plus simple au plus complet :
codeInventaire('portable', 12)renvoie'PC-12'(on peut le faire passer en renvoyant la chaîne en dur) ;codeInventaire('portable', 7)renvoie'PC-07'(force le complément à deux chiffres) ;codeInventaire('videoprojecteur', 2)renvoie'VP-02'(force la table des préfixes) ;codeInventaire('casque', 15)renvoie'CA-15';codeInventaire('imprimante', 1)lève une erreur.
const PREFIXES = { portable: 'PC', videoprojecteur: 'VP', casque: 'CA' };
export function codeInventaire(type, numero) {
const prefixe = PREFIXES[type];
if (!prefixe) throw new Error(`Type de matériel inconnu : ${type}`);
return `${prefixe}-${String(numero).padStart(2, '0')}`;
}
Le test 2 est celui qui transforme le code « en dur » en vraie logique. On pourrait encore ajouter un test pour un numéro à trois chiffres (codeInventaire('portable', 123) renvoie 'PC-123') : la règle ne dit rien de ce cas, c'est une question à poser à la logistique avant de coder.
Tu avais trouvé ?