Skip to Content
TestsTDD — Du cycle à la pratique quotidienne

TDD — Du cycle à la pratique quotidienne

Le Test-Driven Development ne se limite pas Ă  Ă©crire un test avant le code. C’est un cadre de design itĂ©ratif qui transforme le dĂ©veloppement en dialogue permanent entre l’API prĂ©vue et l’implĂ©mentation.

Cette page suppose une maĂźtrise de JUnit 5 et Mockito. Pour les patterns de test JVM, voir JUnit 5 & Mockito.

Le cycle complet illustré

Sujet

ImplĂ©menter la validation d’un code promotionnel (10 % de rĂ©duction si le panier dĂ©passe 50 €).

1. Red — le test Ă©choue

Le test doit vérifiablement échouer. Trois signes de validité :

SigneCe que ça veut dire
Erreur de compilationLa mĂ©thode/fonction n’existe pas
Assertion fausseLe comportement attendu diverge de la réalité
TimeoutLa mĂ©thode tourne en boucle (note : attendre d’écrire le code, la plupart du temps)

Si aucun de ces signes ne se produit, le test ne teste rien.

@Test void codeValideApplique10PourCentSiPanierPlusDe50() { var code = new CodePromotion("ETE10", 10, new Regle().minPanier().superieurA(50)); var panier = new Panier(BigDecimal.valueOf(60)); var discount = code.appliquer(panier); assertEquals(6.0, discount.valeur(), 0.01); }

CodePromotion, Regle, Panier, discount n’existent pas — le test ne compile pas. Premiers changements pour le faire compiler (faker le minimum) :

class CodePromotion { public Decote appliquer(Panier panier) { return new Decote(0.0); } }

2. Green — le code minimal

Écrire strictement le minimum pour que le test passe. Pas de cas supplĂ©mentaires, pas de refactor.

public Decote appliquer(Panier panier) { if (panier.total().compareTo(BigDecimal.valueOf(50)) > 0) { return new Decote(panier.total().multiply(BigDecimal.valueOf(0.10)).doubleValue()); } return new Decote(0.0); }

Test vert. PremiÚrement terminé.

3. Refactor — nettoyer avec confiance

Ajouter un second test couvrant le cas “panier infĂ©rieur Ă  50 ” :

@Test void codeInvalideRetourne0() { var code = new CodePromotion("ETE10", 10, new Regle().minPanier().superieurA(50)); var panier = new Panier(BigDecimal.valueOf(30)); var discount = code.appliquer(panier); assertEquals(0.0, discount.valeur(), 0.01); }

Le refactor peut maintenant séparer la logique de décision :

public Decote appliquer(Panier panier) { return regle.satisfait(panier) ? new Decote(panier.total().multiply(ratio).doubleValue()) : new Decote(0.0); }

Tous les tests verts. Le design est meilleur, aucune régression.

Rùgle d’or

Si le refactor pose un doute, Ă©crire d’abord un test qui capture le comportement actuel, puis refactorer. Le test est le filet de sĂ©curitĂ©.

State-based vs behavior-based

Le choix de stratĂ©gie d’assertion impacte directement la robustesse des tests.

State-based

VĂ©rifie l’état final de l’objet testĂ© : un return value, un objet modifiĂ©, une exception lancĂ©e.

@Test void panierVideRetourneZero() { Panier panier = new Panier(); assertEquals(BigDecimal.ZERO, panier.total()); }

Avantages : simple, rapide, indĂ©pendant du contexte. InconvĂ©nient : ne vĂ©rifie pas comment l’état est atteint.

Behavior-based

VĂ©rifie les interactions avec les dĂ©pendances : mĂ©thode appelĂ©e avec quels arguments, nombre d’appels.

@Test void confirmerEnvoieEmailConfirmation() { var repository = mock(PanierRepository.class); var notificateur = mock(Notificateur.class); var service = new ServicePanier(repository, notificateur); service.confirmer(new Panier(1L)); verify(notificateur).envoyerEmail(eq("confirmation")); }

Avantages : détecte les régressions de logique interne. Inconvénient : peut devenir fragile si le comportement interne change sans changer la sémantique.

Guide de décision

SituationStratégie
Méthode pure (sans effets de bord)State-based
Méthode qui modifie un objet/fichier/baseState-based
Méthode qui déclenche un envoi, un flush, un hookBehavior-based
Méthode qui combine les deuxState-based prioritaire, behavior-based en complément

Hiérarchie des doubles légers

TypeRĂŽleExemple concret
DummyObjet passĂ© sans jamais ĂȘtre utilisĂ©ParamĂštre requis par le constructeur (ex. UUID.randomUUID())
StubRépond à des appels avec des valeurs prédéfiniesreturn List.of(p);
FakeImplémentation simplifiée mais fonctionnelleMapPanier au lieu de PanierRepository persistant
SpyObjet réel qui enregistre les appelsMockito.spy(logger)
MockDouble prédressé avec des attentes de comportementverify(service).envoyerEmail(...)

Exemple : naissance progressive du test

class ServicePanierTest { @Test void calculerLivraisonReussie() { // Dummy — l'adresse est requise mais pas lue dans ce test Adresse dummy = new Adresse("10", "Rue X", "75001", "FR"); Panier panier = new Panier(dummy); panier.ajouter(new Ligne(new Produit("A"), 2)); var resultat = panier.livraison(); assertNotNull(resultat); } @Test void calculerLivraisonZoneFranceMetropolitaine() { // Stub — le calculateur de frais renvoie une valeur fixe CalculateurFrais calculateur = Mockito.mock(CalculateurFrais.class); when(calculateur.frais("FR")).thenReturn(5.5); var service = new ServicePanier(calculateur); var frais = service.calculerFrais("FR", BigDecimal.TEN); assertEquals(5.5, frais.doubleValue(), 0.01); } @Test void livraisonAvecFakePanier() { // Fake — map en mĂ©moire qui implĂ©mente PanierRepository PanierRepository fake = new PanierRepositoryInMemory(); fake.sauvegarder(new Panier(1L, "Alice")); var service = new ServicePanier(fake); var panier = service.obtenir(1L); assertEquals("Alice", panier.client().nom()); } @Test void sauvegarderAppeleRepository() { PanierRepository repo = Mockito.mock(PanierRepository.class); var service = new ServicePanier(repo); var panier = new Panier("Bob"); service.sauvegarder(panier); verify(repo).sauvegarder(argThat(p -> p.client().nom().equals("Bob"))); } }

PiĂšge courant : mock sur-le-gon

@ExtendWith(MockitoExtension.class) injecte des @Mock actifs. CrĂ©er un mock dans le corps du test (au lieu de l’injecter) lĂšve des erreurs silencieuses sur les mĂ©thodes non-stubbing :

// ❌ Mauvais : le mock n'est pas initialisĂ© par MockitoExtension var repo = new Mock().for(PanierRepository.class);

TDD sur du code legacy

Test-and-modify (truffle testing)

Avant de toucher la logique, capter le comportement actuel :

// 1. Observer le comportement actuel du code existant // (souvent via un vrai appel intĂ©gration) // 2. Écrire un test qui l'capture @Test void creerPanierPourClientExistantRetourne201() throws Exception { mockMvc.perform(post("/api/paniers") .contentType(MediaType.APPLICATION_JSON) .content(""" {"client":"C123","articles":[{"id":"A","qty":1}]} """ )) .andExpect(status().isCreated()); }

Une fois ce test vert obtenu, on peut refactorer le code existant avec confiance.

Strangler Fig

Remplacer progressivement le legacy :

  1. CrĂ©er un satellite avec un point d’entrĂ©e contrĂŽlĂ© par le legacy.
  2. Écrire la nouvelle implĂ©mentation par TDD sous le satellite.
  3. Rediriger le traffic graduellement depuis le satellite vers le nouveau service.
  4. Supprimer le satellite quand tout est migré.
┌─[Legacy Service]──────────────────┐ │ │ [Client] ────▶ [API Gateway] ───▶ [Legacy Controller] │ │ â–Č │ │ dĂ©cide selon la ressource │ │ │ │ â–Œ │ │ ┌─[New Service]──────────────────┐│ │ │ BĂąti par TDD ││ │ │ TestĂ© unitairement & intĂ©grĂ© ││ │ └────────────────────────────────┘│ └────────────────────────────────────┘

PiĂšge du mock excessif

Mocker la couche métier retire la protection du TDD : si on mock tout ce qui est testé, le vert ne signifie plus grand chose.

Exception : les interactions avec des systÚmes externes (Stripe, microservice tiers). Les mocks ici isolent les tests, pas le code métier.

Quand TDD n’est PAS adaptĂ©

ContextePourquoi le TDD est contre-productif
Exploration / spikeLe but est d’apprendre, pas de produire du code stable
Proof-of-conceptLe code est jetable ; les tests deviennent du code mort
Scripting / automation ponctuelleUn petit script bash/Python a une durée de vie limitée
Re-reading un code existantPasser du temps Ă  Ă©crire des tests pour du code qu’on ne touchera plus
ContrÎleurs 1-linerLe test colle au framework, pas à la logique métier

Approche alternative : TDD light

Pour les cas oĂč le TDD complet est trop lourd, la variante TDD light conserve seulement la boucle :

  1. Un test unitaire sur la fonction métier.
  2. Pas de test sur les contrÎleurs (gérés par Testcontainers ou intégration légÚre).
  3. Pas de red/green/refactor parfait — Ă©crire les tests avant le code constitue dĂ©jĂ  un niveau nettement supĂ©rieur Ă  l’absence de tests.

Garder la boucle courte

La valeur du TDD diminue avec la vitesse de la boucle. Objectif : passer du rouge au vert en moins de 2 minutes.

Outils de mesure

OutilUsage
pt (parakeet)Mesure du temps de chaque test, déclenche un avertissement si > 2s
mvn test -pl :moduleLancer uniquement le module de travail
Verrouillage de segmentgrep -n dans les fichiers pertinents uniquement

Naming et découpage

Un test lisible guide le refactor :

// ✅ DĂ©coupage fonctionnel + prĂ©fixe de classe comme catĂ©gorie // PanierTest.TestSiInscription → isolĂ© des autres catĂ©gories @Test void totalVideValideZero() { var panier = new Panier(); assertEquals(0.0, panier.total().doubleValue(), 0.01); }

Structure de noms :

ConventionExemple
test_methodeCondition_attendu()test_avecCodeRecharge____100Points()
Given_When_Then() en mĂ©thode lightLorsqu’un test fait beaucoup de setups

Organiser les fichiers de test

src/test/java/com/maison/app/ ├── service/ │ ├── ServicePanierTest.java ← tests "snappy" │ ├── ServicePanierIntegrationTest.java ← avec Testcontainers │ └── ServicePanierValidationsTest.java ← instanciĂ© par @Nested ├── controller/ │ └── PanierControllerTest.java └── tdd/ ← exemples TDD optionnels └── TddCycleIllustrationTest.java

Résumé

  1. Écrire un test qui Ă©choue pour trois raisons validĂ©es.
  2. Écrire le code minimum pour passer.
  3. Refactorer sans peur, les tests protĂšgent.
  4. Choisir state-based ou behavior-based selon le périmÚtre.
  5. Sur du legacy : capturer d’abord, dĂ©placer ensuite.
  6. Garder la boucle sous 2 minutes pour garder le rythme.