Le TDD attire deux réactions extrêmes. La première : “j’écris déjà des tests, donc je fais du TDD”. La seconde : “écrire les tests avant, c’est inverser l’effort sans gagner grand-chose”. Les deux passent à côté de ce que le TDD est vraiment. Ce n’est ni une question de couverture, ni une simple inversion d’ordre. C’est une discipline de design qui force à expliciter une intention avant d’écrire le code qui la satisfait.

Cet article ouvre une série sur le TDD. Avant de comparer les écoles (Chicago, Londres, ATDD double boucle, TDD strict), il faut poser le tronc commun : le cycle Red-Green-Refactor, la pratique des baby steps, et les propriétés FIRST qui définissent un test qui mérite d’être appelé un test. Les articles suivants partiront de ce socle pour rendre les arbitrages tangibles, exemple à l’appui.

Le cycle Red-Green-Refactor

Kent Beck l’a formulé en trois phases, et l’ordre n’est pas négociable.

  1. Red : écrire un test qui échoue, parce que le comportement qu’il décrit n’existe pas encore.
  2. Green : écrire le minimum de code de production qui fait passer le test.
  3. Refactor : améliorer la structure du code et des tests, sans ajouter de fonctionnalité ni changer le comportement.

Trois minutes par boucle dans le cas idéal, une dizaine quand le test demande de penser à une frontière. Ce qu’on appelle “faire du TDD” n’est rien d’autre que cette boucle répétée des dizaines de fois par jour.

Le piège classique consiste à fusionner Green et Refactor en une seule étape, en se disant : “je vais écrire directement le code propre”. C’est un retour au design upfront déguisé. Le cycle perd sa propriété la plus utile, qui est de pouvoir avancer en sachant qu’on peut casser quelque chose à n’importe quel moment et le détecter en quelques secondes.

Pourquoi Red d’abord

L’ordre “test puis code” pose une question simple avant de toucher l’implémentation : quelle est l’intention exacte de ce comportement ?

def test_facture_avec_remise_superieure_au_montant_devient_gratuite():
    facture = Facture(montant_cents=5000)
    facture.appliquer_remise(remise_cents=6000)
    assert facture.total_cents == 0

Avant ce test, la question “que faire si la remise dépasse le montant ?” n’avait pas de réponse explicite. Le test la fixe. Le code de production n’aura plus à inventer une réponse, il devra satisfaire celle-là.

L’autre bénéfice de Red est qu’il valide le test lui-même. Un test qui passe immédiatement sans implémentation est suspect : il teste peut-être un comportement déjà existant, ou son assertion est trop molle pour distinguer le succès de l’échec. Voir le test échouer pour la bonne raison, c’est vérifier que ce test sera utile demain pour détecter une régression.

Green : le code minimal qui passe

C’est l’étape la plus contre-intuitive. La consigne est d’écrire le code le plus naïf possible qui fait passer le test, même si ce code est manifestement insuffisant pour l’usage réel.

class Facture:
    def __init__(self, montant_cents):
        self.montant_cents = montant_cents
        self.total_cents = montant_cents

    def appliquer_remise(self, remise_cents):
        self.total_cents = 0  # suffisant pour faire passer le test courant

Cette version va faire grimacer. Elle ne gère pas le cas où la remise est inférieure au montant. C’est exactement le but. Le test suivant viendra forcer l’extension du code :

def test_facture_avec_remise_partielle_soustrait_du_montant():
    facture = Facture(montant_cents=5000)
    facture.appliquer_remise(remise_cents=2000)
    assert facture.total_cents == 3000

Maintenant, le self.total_cents = 0 ne suffit plus. Le code doit évoluer pour satisfaire les deux tests. À chaque ajout de test, le code grandit juste de ce qu’il faut. Cette progression par accrétion s’appelle la triangulation : on déduit l’implémentation générale d’une série d’exemples concrets, sans jamais sur-anticiper.

L’opposé, écrire tout de suite le code complet qui anticipe tous les cas, est précisément ce que le TDD cherche à éviter. La sur-ingénierie spéculative est la première cause d’abstractions inutiles dans un projet.

Refactor : l’étape que tout le monde saute

Une fois les tests verts, on a le droit de toucher au code sans changer son comportement. C’est le seul moment où on peut renommer, extraire une fonction, supprimer un duplicat, sans risque. Les tests existants jouent le rôle de filet.

C’est aussi l’étape la plus négligée. Le réflexe est : “les tests passent, je passe à la fonctionnalité suivante”. À court terme, ça avance. À moyen terme, le code accumule de la dette parce que du code écrit pour passer un test n’a aucune raison d’être lisible par défaut. Refactor est l’étape qui transforme du code “qui marche” en code “qu’on peut faire évoluer”.

Ce qu’on refactor pendant cette phase :

  • les noms qui ne disent rien (tmp, data, do_stuff),
  • la duplication entre deux passages de Green successifs,
  • les conditions imbriquées que la triangulation a fait apparaître,
  • les tests eux-mêmes (le code de test est du code, il mérite la même rigueur).

Ce qu’on ne fait pas pendant Refactor : ajouter une abstraction “au cas où”, introduire un pattern parce que c’est élégant, généraliser un cas que le test ne couvre pas. La règle est stricte. Aucune fonctionnalité ne change pendant Refactor. Si on a besoin d’ajouter du comportement, on revient à Red.

Baby steps : la taille du pas compte

Les boucles RGR doivent être courtes. Pas par dogme, mais parce qu’un cycle long mélange plusieurs décisions et noie le signal en cas d’erreur. Si un test reste rouge pendant vingt minutes, on a déjà perdu la propriété la plus précieuse du TDD : savoir immédiatement quel changement a cassé quoi.

La pratique des baby steps consiste à découper l’avancée en incréments minuscules. Chaque test cible une seule micro-décision. Chaque modification du code de production est le minimum nécessaire pour faire passer le test courant. On peut littéralement avancer par pas de quelques lignes.

L’avantage est double. D’une part, le coût d’un retour arrière est trivial : annuler trente secondes de travail au lieu de trente minutes. D’autre part, la pression cognitive baisse, parce qu’on ne tient en tête qu’un test à la fois et pas une “fonctionnalité” entière.

L’objection courante est qu’on écrit “trop” de tests. C’est confondre quantité et qualité. Trois tests qui isolent trois cas distincts valent mieux qu’un seul test fourre-tout qui teste plusieurs choses et qu’on ne sait plus interpréter quand il casse.

FIRST : les propriétés d’un test qui mérite ce nom

Tous les tests ne se valent pas. Robert C. Martin a résumé en cinq lettres ce qui distingue un test exploitable d’un test décoratif : FIRST.

Fast. Un test doit s’exécuter en millisecondes. Si la suite met cinq minutes à tourner, personne ne la lance entre deux modifications, et le filet disparaît. Toucher la base de données, le réseau ou le système de fichiers est à éviter pour la majorité des tests unitaires. Les tests d’intégration existent pour ces cas, mais ils restent une minorité.

Independent. Aucun test ne doit dépendre de l’ordre d’exécution d’un autre. Si test_b ne passe que parce que test_a a inséré une ligne en base juste avant, l’isolation est cassée. Lancer un seul test au hasard doit produire le même résultat que lancer la suite entière.

Repeatable. Le test produit le même résultat à chaque exécution, sur n’importe quelle machine, dans n’importe quel ordre. Un test qui échoue une fois sur dix à cause d’un datetime.now() mal contrôlé ou d’un asyncio.sleep() n’est pas un test, c’est un piège qu’on finit par ignorer puis par désactiver.

Self-verifying. Le test sait dire seul s’il a passé ou échoué. Pas de lecture humaine d’un log pour interpréter le résultat. Une assertion claire, qui pointe vers la vraie raison de l’échec, pas un print qu’il faut décoder.

Precise. Le test est précis dans son intention. Un test, un comportement. Si une fonction de test contient cinq assertions hétérogènes, sa rupture ne dit pas ce qui a cassé. La précision se gagne aussi sur le nom : test_facture_avec_remise_superieure_au_montant_devient_gratuite est un cahier des charges en une ligne. test_facture_remise ne dit rien.

Ces cinq propriétés sont les critères qu’on doit pouvoir cocher pour chaque test ajouté. Un test qui en viole une ne devient pas un test “à améliorer plus tard”. Il devient un test qui dégrade la suite entière, parce qu’il introduit du bruit que les autres tests doivent compenser.

Ce que TDD n’est pas

Quelques confusions répandues à dissiper avant de fermer ce socle.

TDD n’est pas une métrique de couverture. On peut avoir 100 % de couverture sans avoir fait de TDD, et on peut faire du TDD sans surveiller la couverture. La couverture mesure les lignes traversées, pas la pertinence des assertions. Un test qui exécute du code sans rien vérifier compte pour la couverture mais ne protège de rien.

TDD n’est pas une garantie d’absence de bug. Un test ne peut détecter que les comportements qu’il décrit. Les bugs viennent souvent des comportements qu’on n’a pas pensé à tester, pas de ceux qu’on a testés. Le TDD réduit certaines classes d’erreurs (régressions, désalignement entre intention et implémentation) et ne touche pas les autres (mauvais design produit, mauvaise compréhension du domaine).

TDD n’est pas plus lent. Cette croyance vient d’un biais comptable : on voit le temps passé à écrire les tests, on ne voit pas le temps économisé en debug, en revues de PR et en régressions évitées. Sur un horizon de quelques semaines, le TDD est neutre ou plus rapide dans la majorité des projets non triviaux. Sur un horizon d’un sprint isolé, il peut paraître plus lent, surtout en phase d’apprentissage.

TDD ne dispense pas de penser au design. Le cycle Red-Green-Refactor n’invente pas l’architecture à votre place. Il force à formuler une intention avant de l’implémenter, ce qui aide, mais il ne remplace ni la réflexion sur les frontières du système, ni le choix des bonnes abstractions. C’est précisément là qu’interviennent les écoles TDD, qui se distinguent par où elles font naître ces décisions de design.

Et maintenant : les écoles

Le cycle, les baby steps et FIRST sont communs à toutes les pratiques TDD. Les différences arrivent quand on demande : par où commence-t-on ? Le coeur du domaine, en partant des entités les plus simples (inside-out, école de Chicago) ? La frontière utilisateur, en partant de l’extérieur (outside-in, école de Londres) ? Avec un test d’acceptance comme garde-fou (ATDD double boucle) ? Ou en écrivant directement le code de production dans le corps du test (TDD strict) ?

Chaque école apporte une réponse différente à ces questions, et chaque réponse a des conséquences sur le design final, sur le coût de maintenance des tests, et sur les types de bugs qu’on attrape. Les prochains articles de la série les compareront une par une, sur le même exemple, pour rendre les arbitrages tangibles plutôt que théoriques.