Un épisode de programmation en TDD : le bowling
Le célèbre kata du score de bowling mené en binôme et en TDD, où une conception simple émerge pas à pas — bien plus simple que l'UML imaginé au départ.
Plutôt qu'un cours abstrait, ce chapitre est une transcription vivante. Deux programmeurs — Robert C. Martin (RCM) et Robert S. Koss (RSK) — s'assoient devant un seul clavier et écrivent, en binôme (pair programming) et en développement piloté par les tests (Test-Driven Development, TDD), une application qui calcule le score d'une partie de bowling. On les suit pas à pas, dans la boucle rouge-vert-remaniement (red-green-refactor), et l'on assiste à quelque chose de troublant : la conception qu'ils avaient esquissée au tableau s'effondre, et une structure bien plus simple s'impose d'elle-même. La morale, qui ouvre toute la suite du livre sur la conception agile, est que le code écrit test d'abord produit une conception plus simple que la conception en amont (big design up front).
Note
Au bowling, une partie compte dix tours (frames). À chaque tour, le joueur dispose de deux lancers pour faire tomber dix quilles. Tout faire tomber au premier lancer est un abat (strike) ; y parvenir au second est une réserve (spare). Un abat vaut 10 plus les quilles des deux lancers suivants ; une réserve vaut 10 plus celles du lancer suivant. Au dixième tour, un abat ou une réserve donne droit à des lancers supplémentaires. Retenir ces règles suffit à comprendre tout le chapitre.
L'histoire utilisateur et le premier test
RCM joue le rôle du client (customer), comme le veut la pratique XP. L'histoire utilisateur (user story) qu'il choisit est minimale : scorer une seule partie. Les entrées sont une suite de lancers — un entier par lancer, le nombre de quilles abattues. La sortie est le score. Sommé par RSK de préciser sous quelle forme il veut ces entrées et sorties, le client répond qu'il faudra « une fonction pour ajouter des lancers et une autre qui rend le score », et il en jette une esquisse libre, à main levée :
throwBall(6);
throwBall(3);
assertEquals(9, getScore()); À ce stade, ce ne sont là que des noms jetés sur un coin de table : le client ne fait que dire ce qu'il attend du système — un moyen d'enregistrer les lancers, un moyen de lire le résultat. C'est notre premier test d'acceptation (acceptance test), exprimé avant la moindre ligne de production. L'interface définitive — un objet Game, une méthode add pour enregistrer un lancer, une méthode score pour lire le résultat — n'émergera, on le verra, que beaucoup plus tard, quand le binôme écrira enfin le test de la classe Game.
L'erreur fondatrice : concevoir d'abord l'UML
Avant d'écrire le code, le binôme cède à une tentation très répandue : dessiner un diagramme du domaine. En coiffant « son chapeau de concepteur objet », RCM raisonne ainsi : « Un objet Game est manifestement constitué d'une suite de dix Frame. Chaque Frame contient un, deux ou trois Throw. » RSK acquiesce — « les grands esprits se rencontrent » — et trace ces trois boîtes :
1 1..3
Game ─────────► Frame ─────────► Throw
contient contient Ce diagramme paraît évident. Il est pourtant entièrement faux, et c'est tout l'enjeu du chapitre que de le démontrer en laissant les tests parler. Le binôme décide de partir du bout de la chaîne de dépendances — Throw — pour remonter vers Game, sous prétexte que cela faciliterait les tests.
Throw n'a aucun comportement
Première classe, premier test à écrire. Mais RSK pose la bonne question : quel est le comportement d'un Throw ? RCM ne sait répondre que ceci : « il contient le nombre de quilles abattues ». Autrement dit, c'est une pure structure de données, sans aucun comportement — rien que des accesseurs. RSK refuse de s'y attarder : travailler sur un objet qui n'a que des getters et setters ne fait pas avancer la conception. Ils remontent donc d'un cran vers Frame, espérant qu'un test sur Frame les forcera à étoffer Throw.
Astuce
Un objet qui n'a aucun comportement, seulement des données, est un signal d'alarme. En TDD, on ne sait pas écrire de test pour un objet qui ne fait rien. Si un test refuse d'émerger, c'est peut-être que l'objet lui-même est superflu.
Frame ne tient pas non plus
Sur Frame, le premier test est trivial : un tour sans lancer vaut zéro.
// Test : un tour vide score 0.
const f = new Frame();
expect(f.getScore()).toBe(0);
// Implémentation la plus bête qui passe.
class Frame {
getScore(): number {
return 0;
}
} Le test passe, mais getScore est une fonction stupide qui échouera dès qu'on ajoutera un lancer. On écrit donc le test suivant — ajouter un lancer de 5 et lire 5 — et c'est là que tout vacille. RCM tape spontanément f.add(5), alors qu'avec le modèle UML il aurait dû écrire f.add(new Throw(5)) — « horrible ». Comme Throw n'a aucun comportement identifiable, ils tranchent : Frame.add prendra un simple number. « Quand on souffrira, on fera plus sophistiqué. » La leçon est déjà là : on n'invente pas une abstraction tant que le code ne l'exige pas.
class Frame {
private itsScore = 0;
getScore(): number {
return this.itsScore;
}
add(pins: number): void {
this.itsScore += pins;
}
} Le vrai mur arrive avec les abats et les réserves. Si l'on appelle add(10), que doit renvoyer getScore() sur ce tour ? Rien de sensé : le score d'un tour avec abat dépend des tours suivants. Un Frame devrait donc connaître ses voisins. RCM propose une liste chaînée de Frame, chacun pointant vers le précédent et le suivant pour aller chercher les lancers bonus. RSK, lui, ne « visualise » pas et réclame du code. Et c'est la bascule décisive : pour prouver le besoin de cette liste chaînée, il faut un test sur Game, car c'est Game qui construirait et relierait les Frame. Ils abandonnent Frame et passent à Game.
Le glissement vers Game : la simplicité s'impose
Le premier test de Game est calqué sur l'esquisse du client.
// Test : un seul lancer.
const g = new Game();
g.add(5);
expect(g.score()).toBe(5);
// Implémentation minimale.
class Game {
private itsScore = 0;
score(): number {
return this.itsScore;
}
add(pins: number): void {
this.itsScore += pins;
}
} RSK guette toujours la « preuve » du besoin d'une liste de Frame. RCM aussi : il s'attend pleinement à ce que les cas d'abat et de réserve les forcent à bâtir des Frame reliés. Mais — et c'est le principe directeur — il ne veut pas les construire tant que le code ne l'y oblige pas. On avance par tout petits pas. Deux lancers sans marque ? Ça passe déjà. Quatre lancers ? Ça passe encore, et toujours aucun Frame en vue.
Le test suivant introduit pourtant une exigence oubliée : afficher le score par tour.
// Test : quatre lancers, plus le score par tour.
const g = new Game();
g.add(5);
g.add(4);
g.add(7);
g.add(2);
expect(g.score()).toBe(18);
expect(g.scoreForFrame(1)).toBe(9);
expect(g.scoreForFrame(2)).toBe(18); Pour faire passer ce test, faut-il enfin créer des objets Frame ? RSK pose la question rituelle du TDD : « est-ce la chose la plus simple qui fasse passer le test ? » Non. La chose la plus simple est un tableau d'entiers dans Game : chaque add y ajoute un lancer, et scoreForFrame parcourt le tableau en avant pour calculer le score.
class Game {
private itsScore = 0;
private itsThrows: number[] = new Array(21).fill(0);
private itsCurrentThrow = 0;
add(pins: number): void {
this.itsThrows[this.itsCurrentThrow++] = pins;
this.itsScore += pins;
}
scoreForFrame(frame: number): number {
let score = 0;
for (
let ball = 0;
frame > 0 && ball < this.itsCurrentThrow;
ball += 2, frame--
) {
score += this.itsThrows[ball] + this.itsThrows[ball + 1];
}
return score;
}
} Le 21 est le nombre maximal de lancers d'une partie. RSK grimace devant cette boucle « à la Unix hacker », indéchiffrable, et soulève au passage un point de conception capital : Game accepte les lancers et sait scorer chaque tour ; il viole donc le principe de responsabilité unique (Single-Responsibility Principle, SRP). N'y aurait-il pas matière à un objet Scorer ? RCM balaie la remarque d'un revers de main : il veut d'abord faire marcher le scoring ; on débattra du SRP ensuite. C'est une discipline en soi — ne pas remanier tant que le vert n'est pas acquis.
À retenir
En TDD, on n'autorise le remaniement (refactoring) qu'une fois la barre verte. Identifier une odeur de conception comme la violation du SRP est précieux, mais on note l'intention et on la traite plus tard, dans une étape de remaniement dédiée — jamais en mêlant nouvelle fonctionnalité et nettoyage.
La boucle rouge-vert-remaniement à l'œuvre
Le reste du chapitre est une longue alternance de petits tests rouges, de code juste suffisant pour les rendre verts, et de remaniements ponctuels. Quelques étapes clés méritent d'être suivies.
Les réserves
Avant d'écrire le cas de la réserve, RCM se lasse de recréer le Game dans chaque test et remanie les tests eux-mêmes en extrayant un setup(). Puis vient le test de la réserve simple : 3, puis 7 (réserve), puis 3, doit donner 13 au premier tour (10 + 3 du lancer suivant).
scoreForFrame(theFrame: number): number {
let ball = 0;
let score = 0;
for (let currentFrame = 0; currentFrame < theFrame; currentFrame++) {
const firstThrow = this.itsThrows[ball++];
const secondThrow = this.itsThrows[ball++];
const frameScore = firstThrow + secondThrow;
// La réserve a besoin du premier lancer du tour suivant.
if (frameScore === 10) {
score += frameScore + this.itsThrows[ball];
} else {
score += frameScore;
}
}
return score;
} Un test ultérieur révèle un bug subtil : l'incrément de ball dans la branche réserve était fautif. Le retirer fait passer le test du tour qui suit une réserve. Le TDD agit comme un filet : chaque nouveau test piège une régression que le pas précédent avait introduite.
score() était faux depuis le début
Surprise : score() renvoyait jusqu'ici la somme brute des quilles, pas le vrai score. Il faut qu'il appelle scoreForFrame avec le tour courant. D'où l'introduction laborieuse d'un getCurrentFrame(), puis d'une variable itsCurrentFrame ajustée après chaque lancer.
private adjustCurrentFrame(): void {
if (this.firstThrow) {
this.firstThrow = false;
} else {
this.firstThrow = true;
this.itsCurrentFrame++;
}
} Cette histoire de « tour courant » va devenir un véritable calvaire. La partie parfaite — douze abats, score 300 — fait d'abord apparaître 330, parce que le tour grimpe à 12. On le borne à 10 : 270. On le borne à 11 : le score est juste mais le tour vaut 11. RCM déteste ce 11. Le binôme tourne et retourne le problème, change le code pour coller aux tests, change les tests pour coller au code — Dave Thomas et Andy Hunt appelleraient cela « programmer par coïncidence ». Ils finissent par accepter, à contrecœur, que getCurrentFrame() renvoyant 11 signifie « partie terminée ».
Piège courant
Ajuster sans cesse le code pour faire passer un test, sans comprendre pourquoi ça marche, est ce qu'on appelle « programmer par coïncidence ». Les tests restent verts, mais la conception est confuse. Le signal — ici, l'angoisse récurrente autour du nombre 11 — annonce qu'une simplification reste à trouver.
Les abats, et les cas limites
Le cas de l'abat est ajouté à scoreForFrame (un seul lancer dans le tour, plus les deux suivants). Puis le binôme déroule une batterie de tests de cas limites : la partie échantillon de la carte de score (133), le crève-cœur de onze abats suivis d'un 9 (299), la réserve au dixième tour (270), le débordement de tableau. Chaque test confirme que le tableau plat suffit — toujours aucun Frame à l'horizon.
Le remaniement final : le code dit les règles du bowling
Une fois tous les tests au vert, le binôme attaque le remaniement de la fonction scoreForFrame, encore touffue. RCM remarque que sa structure, en pseudo-code, ressemble aux règles mêmes du bowling :
si abat: score += 10 + deuxLancersSuivants()
sinon si réserve: score += 10 + lancerSuivant()
sinon: score += deuxLancersDuTour() Pour atteindre cette forme, ils procèdent par micro-étapes, chacune validée par les tests : promouvoir les variables locales en champs (pour pouvoir extraire des fonctions), extraire handleSecondThrow, remplacer les conditions par des prédicats nommés strike() et spare(), normaliser la progression de l'itérateur ball, puis fusionner les trois cas en une seule cascade. Le résultat est limpide :
scoreForFrame(theFrame: number): number {
let ball = 0;
let score = 0;
for (let currentFrame = 0; currentFrame < theFrame; currentFrame++) {
if (this.strike(ball)) {
score += 10 + this.nextTwoBallsForStrike(ball);
ball += 1;
} else if (this.spare(ball)) {
score += 10 + this.nextBallForSpare(ball);
ball += 2;
} else {
score += this.twoBallsInFrame(ball);
ball += 2;
}
}
return score;
} « Voilà les règles du bowling énoncées aussi succinctement que possible. » RSK ne peut résister : « Mais, Bob, qu'est devenue ta liste chaînée d'objets Frame ? » RCM, penaud : « Nous avons été ensorcelés par les démons de la surconception diagrammatique. Trois petites boîtes au dos d'une serviette — Game, Frame, Throw — et c'était déjà trop compliqué, et tout simplement faux. »
Le Scorer émerge enfin — par le code
C'est seulement maintenant, le code étant déjà simple et entièrement testé, que la variable ball se révèle être un itérateur privé de scoreForFrame et de ses fonctions auxiliaires. Toutes devraient vivre dans un autre objet. RSK avait donc raison depuis le début sur le Scorer — mais cet objet apparaît parce que le code l'a rendu évident, et non parce qu'un diagramme l'avait décrété.
class Scorer {
private itsThrows: number[] = new Array(21).fill(0);
private itsCurrentThrow = 0;
addThrow(pins: number): void {
this.itsThrows[this.itsCurrentThrow++] = pins;
}
scoreForFrame(theFrame: number): number {
let ball = 0;
let score = 0;
for (let currentFrame = 0; currentFrame < theFrame; currentFrame++) {
if (this.strike(ball)) {
score += 10 + this.nextTwoBallsForStrike(ball);
ball += 1;
} else if (this.spare(ball)) {
score += 10 + this.nextBallForSpare(ball);
ball += 2;
} else {
score += this.twoBallsInFrame(ball);
ball += 2;
}
}
return score;
}
private strike(ball: number): boolean {
return this.itsThrows[ball] === 10;
}
private spare(ball: number): boolean {
return this.itsThrows[ball] + this.itsThrows[ball + 1] === 10;
}
private nextTwoBallsForStrike(ball: number): number {
return this.itsThrows[ball + 1] + this.itsThrows[ball + 2];
}
private nextBallForSpare(ball: number): number {
return this.itsThrows[ball + 2];
}
private twoBallsInFrame(ball: number): number {
return this.itsThrows[ball] + this.itsThrows[ball + 1];
}
} Game ne fait plus que suivre les tours et déléguer le calcul au Scorer. Le SRP est enfin respecté : « le Game suit les tours, le Scorer calcule le score ». Au passage, on s'aperçoit que itsScore n'est plus utilisé : on le supprime avec délectation. Et le fameux 11 ? En dégageant getCurrentFrame() — que personne n'utilisait vraiment — et en revenant à scoreForFrame(itsCurrentFrame) borné à 10, toute l'angoisse s'évanouit. « On a juste changé la limite de 11 à 10 et retiré le -1. Ça ne valait pas tout ce tourment. »
class Game {
private itsCurrentFrame = 0;
private firstThrowInFrame = true;
private itsScorer = new Scorer();
score(): number {
return this.scoreForFrame(this.itsCurrentFrame);
}
add(pins: number): void {
this.itsScorer.addThrow(pins);
this.adjustCurrentFrame(pins);
}
scoreForFrame(theFrame: number): number {
return this.itsScorer.scoreForFrame(theFrame);
}
private adjustCurrentFrame(pins: number): void {
if (this.lastBallInFrame(pins)) this.advanceFrame();
else this.firstThrowInFrame = false;
}
private lastBallInFrame(pins: number): boolean {
return this.strike(pins) || !this.firstThrowInFrame;
}
private strike(pins: number): boolean {
return this.firstThrowInFrame && pins === 10;
}
private advanceFrame(): void {
this.itsCurrentFrame = Math.min(10, this.itsCurrentFrame + 1);
}
} La morale : la conception émerge des tests
En conclusion, Martin rapporte les réactions de ses lecteurs. Certains furent troublés par l'absence quasi totale de conception orientée objet — le Scorer étant la seule concession, et encore, plus un simple découpage qu'une véritable OOD. Doit-on imposer de l'orienté objet à chaque programme ? Non : celui-ci n'en avait tout simplement pas besoin. D'autres réclamaient une classe Frame ; l'un d'eux en écrivit une version — bien plus grosse et plus complexe que celle ci-dessus.
Attention
Les diagrammes deviennent nuisibles quand on les crée sans code pour les valider et qu'on a ensuite l'intention de les suivre aveuglément. Rien n'interdit d'esquisser un schéma pour explorer une idée ; mais ne présumez jamais qu'il constitue la meilleure conception. La meilleure conception évolue au fil de petits pas, tests d'abord.
Aurait-on obtenu un programme plus maintenable en suivant l'UML ? Martin en doute : le programme final est facile à comprendre, donc facile à maintenir, et ne contient aucune dépendance mal gérée qui le rendrait rigide ou fragile. Ce dernier point ouvre directement la suite du livre, qui inventorie les symptômes d'une mauvaise conception — rigidité, fragilité, immobilité, viscosité, complexité et répétition inutiles, opacité — autant de raisons pour lesquelles on cherche à laisser la structure du système s'améliorer à chaque itération, plutôt qu'à la figer d'avance.
À retenir
- Le code écrit test d'abord produit une conception plus simple que la conception en amont : ici, les classes
FrameetThrowdu diagramme initial se sont révélées inutiles, supplantées par un simple tableau de lancers. - La boucle rouge-vert-remaniement structure tout l'épisode : un petit test qui échoue, le code minimal qui le fait passer, puis le remaniement — et seulement une fois la barre verte.
- On n'introduit une abstraction que lorsque le code l'exige : la liste chaînée de
Framen'est jamais venue, et leScorern'a émergé qu'à la toute fin, rendu évident par le code lui-même. - Le principe de responsabilité unique (SRP) se révèle dans le code, pas sur un diagramme :
Gamesuit les tours,Scorercalcule le score — séparation découverte par remaniement. - Méfiez-vous de la programmation par coïncidence : ajuster le code jusqu'à ce que les tests passent sans comprendre pourquoi (l'angoisse du nombre 11) signale une simplification encore à trouver.
- Le binôme remanie aussi les tests (extraction du
setup, suppression des cas devenus inutiles) : la suite de tests est du code de production à part entière. - Les diagrammes sont des outils d'exploration, pas des plans à suivre ; non validés par du code, ils enferment dans une surconception coûteuse.