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é :
| Signe | Ce que ça veut dire |
|---|---|
| Erreur de compilation | La mĂ©thode/fonction nâexiste pas |
| Assertion fausse | Le comportement attendu diverge de la réalité |
| Timeout | La 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
| Situation | Stratégie |
|---|---|
| Méthode pure (sans effets de bord) | State-based |
| Méthode qui modifie un objet/fichier/base | State-based |
| Méthode qui déclenche un envoi, un flush, un hook | Behavior-based |
| Méthode qui combine les deux | State-based prioritaire, behavior-based en complément |
Hiérarchie des doubles légers
| Type | RĂŽle | Exemple concret |
|---|---|---|
| Dummy | Objet passĂ© sans jamais ĂȘtre utilisĂ© | ParamĂštre requis par le constructeur (ex. UUID.randomUUID()) |
| Stub | Répond à des appels avec des valeurs prédéfinies | return List.of(p); |
| Fake | Implémentation simplifiée mais fonctionnelle | MapPanier au lieu de PanierRepository persistant |
| Spy | Objet réel qui enregistre les appels | Mockito.spy(logger) |
| Mock | Double prédressé avec des attentes de comportement | verify(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 :
- CrĂ©er un satellite avec un point dâentrĂ©e contrĂŽlĂ© par le legacy.
- Ăcrire la nouvelle implĂ©mentation par TDD sous le satellite.
- Rediriger le traffic graduellement depuis le satellite vers le nouveau service.
- 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Ă©
| Contexte | Pourquoi le TDD est contre-productif |
|---|---|
| Exploration / spike | Le but est dâapprendre, pas de produire du code stable |
| Proof-of-concept | Le code est jetable ; les tests deviennent du code mort |
| Scripting / automation ponctuelle | Un petit script bash/Python a une durée de vie limitée |
| Re-reading un code existant | Passer du temps Ă Ă©crire des tests pour du code quâon ne touchera plus |
| ContrÎleurs 1-liner | Le 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 :
- Un test unitaire sur la fonction métier.
- Pas de test sur les contrÎleurs (gérés par Testcontainers ou intégration légÚre).
- 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
| Outil | Usage |
|---|---|
pt (parakeet) | Mesure du temps de chaque test, déclenche un avertissement si > 2s |
mvn test -pl :module | Lancer uniquement le module de travail |
| Verrouillage de segment | grep -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 :
| Convention | Exemple |
|---|---|
test_methodeCondition_attendu() | test_avecCodeRecharge____100Points() |
Given_When_Then() en mĂ©thode light | Lorsquâ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.javaRĂ©sumĂ©
- Ăcrire un test qui Ă©choue pour trois raisons validĂ©es.
- Ăcrire le code minimum pour passer.
- Refactorer sans peur, les tests protĂšgent.
- Choisir state-based ou behavior-based selon le périmÚtre.
- Sur du legacy : capturer dâabord, dĂ©placer ensuite.
- Garder la boucle sous 2 minutes pour garder le rythme.