Tests et qualité logicielle
Le TDD en Java : un kata complet et un bilan sans complaisance
Intermédiaire9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Le TDD est une discipline de conception qui, au passage, laisse des tests derrière elle. Ce guide déroule un problème complet, de la première assertion en échec jusqu'à une conception remaniée, puis examine ce que la pratique fait réellement à la structure du code, comment gérer les collaborateurs, où elle fonctionne mal et ce que les études en disent vraiment.
Ce que vous allez apprendre
- Dérouler la boucle red-green-refactor avec la rigueur qui la rend utile
- Faire émerger une petite conception à partir des tests, en choisissant délibérément le test suivant
- Choisir entre outside-in et inside-out pour une fonctionnalité donnée
- Reconnaître les situations où le test-first est mal adapté, et expliquer pourquoi
- Répondre aux objections classiques au TDD par autre chose que de l'enthousiasme
Le développement piloté par les tests se décrit facilement et se pratique difficilement. Vous écrivez un test qui échoue, puis le minimum de code pour le faire passer, puis vous améliorez la conception pendant que le test reste vert. Tout l'intérêt de la pratique tient à ce que cette contrainte vous impose : impossible d'écrire du code qu'on ne sait pas appeler, impossible d'inventer une abstraction avant d'en avoir l'usage, et vous savez toujours si ce que vous venez d'écrire fonctionne.
C'est aussi une pratique souvent survendue. Le TDD n'est pas une stratégie de test (une suite née du TDD au niveau unitaire couvre très peu du risque d'intégration) et il ne dispense pas de réfléchir à la conception. Ce qu'il apporte de façon fiable, c'est une boucle de feedback très courte et du code facile à instancier, et ces deux choses comptent énormément. Ce guide déroule un exemple complet, puis examine sans complaisance où la pratique fait ses preuves et où elle ne les fait pas.
La boucle, en détail
Red. Écrivez un test pour un comportement qui n'existe pas. Exécutez-le. Il doit échouer, et pour la raison que vous attendez : un test qui échoue à cause d'une faute de frappe dans la mise en place ne vous apprend rien. Un test qui passe immédiatement signifie que vous n'avez identifié aucun comportement nouveau.
Green. Écrivez le code le plus simple qui fasse passer le test. « Le plus simple » s'entend au sens littéral, y compris renvoyer une constante. Cela paraît absurde la première fois, mais c'est précisément ce qui oblige le test suivant à porter sur quelque chose de réel.
Refactor. Tous les tests étant verts, améliorez la structure : supprimez la duplication, renommez, extrayez, fusionnez. C'est l'étape que l'on saute, et la sauter transforme le TDD en moyen de produire du code mal conçu avec une bonne couverture de tests.
Deux habitudes font fonctionner la boucle. Limitez chaque cycle à quelques minutes : si vous êtes dans le rouge depuis un quart d'heure, le pas était trop grand ; revenez en arrière et faites-en un plus petit. Et traitez le code de test comme du code de production : il sera lu bien plus souvent qu'il ne sera écrit.
Un kata, de rien jusqu'à une conception
Le problème : un convertisseur RomanNumerals, de int vers String, pour les valeurs de 1 à 3999. Il est assez petit pour être montré en entier et assez riche pour comporter une vraie décision de conception en son milieu.
Le premier test est l'énoncé d'intention le plus petit possible.
class RomanNumeralsTest {
@Test
void one() {
assertThat(RomanNumerals.of(1)).isEqualTo("I");
}
}
Il ne compile pas, ce qui compte comme du rouge. La chose la plus simple qui le fasse passer au vert :
public final class RomanNumerals {
public static String of(int value) {
return "I";
}
}
Un deuxième test oblige alors le code à distinguer deux cas.
@Test
void two() {
assertThat(RomanNumerals.of(2)).isEqualTo("II");
}
La correction évidente, un if, est moins bonne que la solution générale, et c'est à ce moment que la conception apparaît :
public static String of(int value) {
return "I".repeat(value);
}
Cela passe pour 1, 2 et 3, et échoue à 4. Plutôt que d'écrire quatre tests séparés, exprimez les cas sous forme de tableau, ce qui est exactement le rôle d'un test paramétré :
@ParameterizedTest(name = "{0} -> {1}")
@CsvSource({"1,I", "3,III", "4,IV", "9,IX", "14,XIV", "40,XL", "90,XC",
"400,CD", "900,CM", "1994,MCMXCIV", "3999,MMMCMXCIX"})
void converts(int value, String expected) {
assertThat(RomanNumerals.of(value)).isEqualTo(expected);
}
Ajouter 4,IV suffit à casser repeat. La tentation est d'enchaîner les conditions ; c'est l'étape de refactoring qui l'empêche. L'idée clé, à savoir que les formes soustractives IV, IX, XL ne sont que des symboles supplémentaires dans une table ordonnée, surgit parce que la duplication dans les conditions la rend visible :
public final class RomanNumerals {
private record Symbol(int value, String glyph) {}
private static final List<Symbol> SYMBOLS = List.of(
new Symbol(1000, "M"), new Symbol(900, "CM"), new Symbol(500, "D"), new Symbol(400, "CD"),
new Symbol(100, "C"), new Symbol(90, "XC"), new Symbol(50, "L"), new Symbol(40, "XL"),
new Symbol(10, "X"), new Symbol(9, "IX"), new Symbol(5, "V"), new Symbol(4, "IV"),
new Symbol(1, "I"));
private RomanNumerals() {}
public static String of(int value) {
if (value < 1 || value > 3999) {
throw new IllegalArgumentException("Roman numerals cover 1..3999, got " + value);
}
StringBuilder out = new StringBuilder();
int remaining = value;
for (Symbol symbol : SYMBOLS) {
while (remaining >= symbol.value()) {
out.append(symbol.glyph());
remaining -= symbol.value();
}
}
return out.toString();
}
}
La clause de garde est née de son propre test rouge, écrit une fois la table au vert :
@ParameterizedTest
@ValueSource(ints = {0, -1, 4000, Integer.MAX_VALUE})
void rejectsValuesOutsideTheRange(int value) {
assertThatThrownBy(() -> RomanNumerals.of(value))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("1..3999");
}
Observez ce que la séquence a produit. La table n'est pas une implémentation qui s'est trouvée fonctionner : c'est la structure vers laquelle les tests ont poussé, parce que chaque condition qu'ils imposaient dupliquait la même forme. Observez aussi ce qui n'a pas été construit : pas de RomanNumeralFactory, pas d'interface de stratégie, pas de configuration. Rien ne les réclamait.
Ce que les tests font à la conception
Le TDD exerce quatre types de pression, et ce sont elles qui constituent le véritable produit de la pratique.
Tout doit pouvoir être construit. Une classe dont le constructeur lit un singleton statique, ouvre une connexion ou lance un pool de threads ne s'instancie pas à peu de frais dans un test. En TDD, vous le sentez dès la première minute et vous passez la dépendance en paramètre, ce qui rejoint exactement la conclusion à laquelle l'injection de dépendances aboutit par l'autre bout.
Tout doit être observable. Si le seul effet d'une méthode est une ligne de log ou la modification d'un champ privé, le test ne peut rien vérifier. Cette pression pousse à renvoyer des valeurs plutôt qu'à modifier un état caché, et à concentrer les effets de bord en quelques endroits explicites.
Les unités restent petites. Une méthode à huit branches demande au moins huit tests avec une mise en place laborieuse. La douleur arrive avant la livraison de la méthode, pas lors d'une revue trois semaines plus tard.
Les abstractions arrivent tard. L'étape de refactoring introduit une interface quand une deuxième implémentation existe, pas quand quelqu'un en anticipe une. La généralité spéculative est la forme de gaspillage la plus coûteuse d'une base de code, et le test-first la rend malcommode à créer.
Rien de tout cela n'est automatique. Une équipe peut produire du code procédural abondamment testé, avec un service fourre-tout et une centaine de mocks. La discipline crée la pression ; c'est toujours au concepteur d'y répondre.
Outside-in et inside-out
Il existe deux façons d'aborder une fonctionnalité, et elles conviennent à des situations différentes.
Inside-out (parfois appelée approche classique) part de l'objet du domaine au centre (le calcul, la machine à états, la règle de parsing) et progresse vers l'extérieur jusqu'au contrôleur. Les collaborateurs sont de vrais objets dès qu'ils sont bon marché. Les tests sont robustes parce qu'ils vérifient des valeurs plutôt que des interactions, et un refactoring les casse rarement. Le risque est de construire un cœur magnifiquement testé qui ne correspond pas à l'API dont vous aurez finalement besoin.
Outside-in (mockiste, école de Londres) part de la frontière : un test d'acceptation pour le comportement visible par l'utilisateur, puis un test du contrôleur avec un service mocké, puis le service avec un repository mocké, en découvrant chaque interface au moment où elle devient nécessaire. La conception en ressort cohérente, puisque chaque collaborateur a été inventé pour satisfaire un appelant. Le prix à payer, ce sont des tests couplés aux interactions : changez la façon dont un service parle à son repository et le test échoue alors que le comportement est identique.
En pratique, la plupart des équipes expérimentées travaillent en outside-in au niveau de la fonctionnalité pour en découvrir la forme, puis en inside-out dans chaque unité qui contient une vraie logique. Le guide BDD décrit la boucle la plus externe de ce schéma : un scénario en échec qui reste rouge pendant que plusieurs cycles unitaires s'exécutent à l'intérieur.
Les collaborateurs, sans se noyer dans les mocks
Le travail en test-first cale dès que l'objet testé a besoin de quelque chose de lent. Trois réponses, par ordre de préférence :
- Sortez la dépendance. Si une méthode a besoin de la date courante, passez-lui une
Clock. Si elle a besoin d'un total issu d'un repository, passez-lui le total en paramètre et laissez l'appelant aller le chercher. Une fonction de ses seuls arguments se teste trivialement. - Utilisez un fake. Une implémentation en mémoire d'une petite interface de repository permet d'écrire une douzaine de tests qui se lisent comme du code ordinaire, et détecte des bugs liés à l'ordre que des stubs ne peuvent pas repérer.
- Mockez, avec parcimonie. Réservez les mocks aux véritables frontières (une passerelle de paiement, un service d'envoi de mails), là où l'interaction est le comportement qu'il vaut la peine de vérifier.
Quand un test a besoin de plus de trois appels stubbés environ pour s'exécuter, la conception réclame généralement un objet plus petit. Ce signal est l'un des retours les plus fiables que fournit la pratique.
Là où le test-first est difficile
Le code legacy. On ne pilote pas du code qu'on ne sait pas instancier. Enveloppez d'abord le comportement existant dans un test de caractérisation à gros grain (enregistrez ce qu'il fait actuellement, que ce soit juste ou non), puis introduisez une couture et travaillez en test-first à l'intérieur.
Les algorithmes dont l'implémentation ne se devine pas. Pour une routine numérique ou un algorithme de graphe, de petits tests ne vous mèneront pas à la bonne approche : il faut d'abord comprendre l'algorithme. Le test-first reste utile, mais pour cerner la spécification et les cas limites, pas pour découvrir la méthode.
Les interfaces utilisateur. La mise en page et l'interaction se jugent à l'œil. Pilotez la logique de présentation en test-first et gardez la couche de rendu assez mince pour que sa justesse soit évidente.
L'exploration. Quand vous découvrez une API inconnue, écrivez un spike sans tests, apprenez ce dont vous aviez besoin, supprimez-le et recommencez en test-first. Vouloir faire du TDD en terrain inconnu produit des tests pour des suppositions.
Le code fortement concurrent. Un test qui passe quatre-vingt-dix-neuf fois sur cent est pire que pas de test du tout. La concurrence exige des conceptions déterministes, des campagnes de stress et des outils spécialisés, plutôt que la boucle ordinaire.
Les objections, sans langue de bois
« Ça double la quantité de code. » Grosso modo, oui : le code de test a souvent une taille comparable à celle du code de production. La question est de savoir si ce code se rentabilise en débogage évité, en refactorings plus sûrs et en documentation vivante. Sur une base de code maintenue pendant des années, c'est généralement le cas ; sur un script exécuté une seule fois, non.
« Les preuves ne sont pas concluantes. » C'est exact. Les études empiriques comparant test-first et test-after rapportent des effets allant d'une réduction notable des défauts à aucune différence significative, avec des résultats tout aussi contrastés sur l'effort. Une bonne part du bénéfice mesuré vient peut-être du fait d'écrire de petites unités et beaucoup de tests plutôt que de l'ordre dans lequel on les écrit. C'est une raison d'être modeste dans ses affirmations, pas une raison de renoncer aux tests.
« Mes tests cassent à chaque refactoring. » Alors ils vérifient la structure plutôt que le comportement : en général trop d'appels à verify, ou des tests écrits contre des méthodes privées rendues visibles au niveau du package. Vérifiez ce qu'un appelant peut observer.
« On n'a pas le temps. » Le temps est dépensé de toute façon ; le choix se fait entre écrire des tests et revérifier le comportement à la main à chaque changement. La version honnête de cette objection porte généralement sur l'échéance d'un prototype, et pour un prototype réellement jetable, c'est un arbitrage raisonnable, à condition que le prototype soit effectivement jeté.
« La couverture est déjà élevée, donc tout va bien. » La couverture mesure l'exécution, pas les assertions. Une suite peut exécuter chaque ligne tout en ne vérifiant presque rien. Ce que produit le TDD, ce n'est pas de la couverture mais la garantie que chaque comportement a eu un test qui échouait en son absence, une propriété bien plus forte, que le guide sur la stratégie d'automatisation recommande de privilégier par rapport à un objectif de couverture.
Points clés à retenir
- La boucle est red, green, refactor, et c'est l'étape de refactoring qui fait d'une suite de tests une pratique de conception plutôt qu'un simple filet de sécurité.
- Écrivez le code le plus simple qui passe, constantes comprises ; c'est le test suivant qui impose la généralité, et la duplication qu'il crée révèle la bonne structure.
- La pression du test-first rend le code constructible, observable et de taille réduite, et retarde les abstractions jusqu'à ce qu'un deuxième cas d'usage les justifie.
- Choisissez outside-in pour découvrir les interfaces à la frontière d'une fonctionnalité et inside-out pour les unités porteuses d'une vraie logique ; la plupart des équipes utilisent les deux dans une même fonctionnalité.
- Sortez les dépendances, préférez les fakes aux mocks, et considérez « ce test a besoin de quatre stubs » comme un signal de conception plutôt que comme un problème de mise en place.
- Soyez lucide sur les limites : le code legacy exige d'abord des coutures, les algorithmes exigent d'abord d'être compris, et les données empiriques justifient des affirmations modestes plutôt que grandioses.
Sources et lectures complémentaires
Questions fréquentes
- Le TDD est-il plus lent que d'écrire le code d'abord ?
- Fonctionnalité par fonctionnalité, la première version prend en général un peu plus de temps, parce que vous payez les tests d'emblée au lieu de ne jamais les payer. Sur la durée de vie d'une base de code, la comparaison change : le temps de débogage chute nettement, car les défaillances apparaissent quelques secondes après avoir été introduites, et le refactoring cesse d'être risqué. Les études contrôlées montrent des effets contrastés sur la densité de défauts et l'effort ; méfiez-vous donc des promesses d'accélération spectaculaire et jugez sur votre propre base de code.
- Faut-il écrire un test avant chaque ligne de code ?
- Non. La règle est qu'aucun comportement de production n'est ajouté sans qu'un test en échec ne l'exige, ce qui n'a rien à voir avec un test par ligne. Le travail exploratoire, les spikes et les prototypes se font mieux sans tests, puis sont jetés ou réécrits en test-first. La configuration, le code généré et la délégation triviale en tirent rarement profit. C'est par dogmatisme que des équipes finissent par tester leurs getters.
- Quelle différence entre l'école de Londres et l'école de Chicago ?
- L'école de Londres (mockiste, outside-in) part de la frontière du système, remplace chaque collaborateur par un mock au moment où elle le découvre et fait descendre la conception à travers des interfaces. L'école de Chicago (classique, inside-out) part des objets du domaine, utilise de vrais collaborateurs dès qu'ils sont bon marché et remonte vers l'extérieur. Londres donne un retour plus rapide sur la structure mais des tests plus fragiles ; Chicago donne des tests plus robustes mais découvre les interfaces plus tard. La plupart des équipes combinent les deux.
- Peut-on appliquer le TDD à du code legacy sans aucun test ?
- Pas directement, car le code legacy ne peut généralement pas être instancié dans un test sans embarquer la moitié de l'application. La démarche classique consiste d'abord à poser un test de caractérisation autour du comportement existant, à la frontière la plus grossière accessible, puis à s'en servir comme filet de sécurité pour introduire une couture (seam) : extraire une interface, ajouter un paramètre au constructeur, casser une dépendance statique. Ensuite, on travaille en test-first à l'intérieur de cette couture. Le filet s'étend une brèche à la fois.