Publié le 18/02/2020 Par Gaëtan Eleouet

La plus grande difficulté que l’on rencontre en débutant la pratique du TDD est de choisir le nouveau test à écrire. Il faut choisir la fonctionnalité qui va permettre un petit incrément, si possible le plus petit, mais la taille n’est pas le seul paramètre à prendre en compte…

TDD : le cycle Red Green Refactor

Le principe du TDD repose sur le cheminement RED, GREEN, REFACTOR. L’état RED correspond à un test qui ne passe pas, le GREEN représente tous les tests qui passent et le REFACTOR, le moment où, alors que tous les tests passent, on en profite pour refactorer le code, c’est-à-dire le rendre « plus MIEUX. »

De quoi s’agit-il ? Le « plus MIEUX » est quelque peu compliqué à décrire. Il consiste à le rendre plus propre, plus élégant. On peut utiliser quelques métriques comme la complexité cyclomatique, le nombres de lignes, mais ce ne sera pas suffisant. On peut aussi éliminer les “codes smells” que l’on détecte, les “feature envy”, la “duplication”, la “long parameter list”, etc. à l’appréciation du développeur. Il est aussi possible d’optimiser la lisibilité en améliorant le nommage. En réalité, personne n’est vraiment d’accord sur les qualités requises.

Les étapes du TDD

Mais voici un exemple de ce qu’en dit Kent Beck dans son livre Extreme Programming :

  • Valider les tests (Passes the tests)
  • Expliciter l’intention (Reveals intention)
  • Pas de duplication (No duplication)
  • Le moins d’éléments possible (Fewest elements)

Il y décrit une hiérarchie de valeur, depuis la plus importante – à savoir le code passe les tests –, pour ensuite insister sur une notion de lisibilité et, seulement en dernier, une réduction du nombre d’éléments. C’est un point de vue, certes, mais qui offre une feuille de route pour commencer à s’approprier le sujet.

Le déroulement d’une session d’écriture de code peut-être décrite telle que suit par UncleBob (Robert C.Martin) :

TDD:

  • Write no production code without a failing test,
  • Stop writing that test as soon as it fails, or fails to compile,
  • Stop writing production code as soon as the failing test passes,
  • Refactor both and then repeat,
  • Cycle time: ~10-60 seconds,
  • Ne pas écrire de code non motivé par un test qui échoue,
  • Arrêter d’écrire des tests dès qu’un test échoue ou ne compile pas,
  • Arrêter d’écrire du code dès que les tests passent,
  • Refactorer le code et les tests, et recommencer,
  • Le temps d’un cycle doit être inférieur à une minute.

Insistons sur quelques points : on n’écrit qu’un seul test à la fois ; chaque incrément doit être court ; on refactore systématiquement le code ET les tests ; on n’écrit pas de code, si aucun test ne le motive !

Baby Steps

La plus grande difficulté que l’on rencontre en débutant la pratique du TDD est de choisir le nouveau test à écrire. Il faut choisir la fonctionnalité qui va permettre un petit incrément, si possible le plus petit, mais la taille n’est pas le seul paramètre à prendre en compte. Les tests vont être de plus en plus « spécifiques » et le code de plus en plus « générique ». Il est nécessaire de choisir la fonctionnalité qui spécialise le moins les tests existants. Pour résumer, il faut trouver un nouveau cas d’utilisation non encore passant et qui soit le plus proche de l’existant.

Design Code / Algo émergent

Un des avantages de l’approche TDD est de toujours être en mouvement et de découper en petits incréments abordables. L’appréhension de « la page blanche », le fait d’avoir l’ensemble de l’implémentation en tête avant de commencer, est ainsi évité. Toutefois, il est nécessaire de diriger les refactorings et le choix de chaque nouvelle étape pour construire « sa » solution. Régulièrement, il est nécessaire lors de la phase de refactoring de diriger, afin de généraliser l’algorithme, ou d’introduire le design désiré au moment présent.

La phase de refactoring sert à rendre le code propre, mais est également l’emplacement où le développeur (ou la paire de développeurs PairProgramming ++) va introduire le design applicatif et aider l’algorithme si besoin à émerger en généralisant les aspects importants.

Step by Step video

Let’s code -> La vidéo du Kata !!

KATA TDD Roman Converter

N’hésitez pas à effectuer l’exercice avant de visualiser la vidéo ou de lire le reste de l’article 😊

Prérequis, j’ai effectué quelques choix avant le début de l’exercice. J’utilise Junit pour les tests, et AssertJ pour écrire les assertions de manière fluent et élégante (scannability > readability).

Je vais également nommer ma classe de test en la suffixant par « Should » afin d’avoir des tests nommés comme une phrase. Cela nécessite avec Maven de lui indiquer le pattern particulier utilisé :

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M3</version>
<configuration>
<includes>
<include>**/*Should</include>
</includes>
</configuration>
</plugin>

Je préfère également utiliser le GWT (Given, When,Then) que le AAA (Arrange, Act, Assert) dans les commentaires à l’intérieur des méthodes de tests. Il favorise en effet plus facilement une écriture en langage naturel, et il incite davantage à penser au « pourquoi » lors de l’écriture du test, à réfléchir en termes de fonctionnalités plutôt qu’en terme de lignes de code (ce n’est pas faire du BDD, le test est ici écrit par le développeur, en code et en même temps que le code de production, et non par un représentant métier dans un langage dédié en tant que spécification exécutable.). Ces commentaires sont systématiques, pour en faire une habitude, à la fois pour moi mais aussi pour ceux qui liront le code.

Comment concrètement appliquer la méthode TDD

Mains sur le clavier et c’est parti ! On commence par écrire une classe de tests. Ici, on choisit déjà un certain nombre de points : un nom de package, de classe, la façon d’appeler, les types que l’on souhaite, etc. Le premier test ne compilera pas !

@Test 
public void convert_1_into_I() {
 // GIVEN 
 int input = 1; 
 String expected = "I"; 
 RomanConverter converter = new RomanConverter();  

 // WHEN 
 String result = converter.convert(input);   

 // THEN 
 assertThat(result).isEqualTo(expected); 
} 

Retourner une constante

La première implémentation, la plus simple qui permet de valider le test, est de retourner simplement une constante. L’intérêt fonctionnel d’un tel code est léger, mais déjà le code peut être embarqué dans un système et permettre des premières « techniques » d’intégration.

À chaque étape, on cherche à conserver le code le plus simple, en autorisant la transformation la moins « chère » possible. Ce concept de coût de transformation a été décrit en liste par Robert C.Martin. En voici une interprétation simplifiée :

  • Pas de code -> constante
  • Constante simple -> variable
  • Statement -> bloc de statements
  • Pas de condition -> if
  • Variable -> tableau
  • Tableau -> structure
  • Statement -> recursion
  • if -> while
  • Expression -> fonction
  • Variable -> assignation

Cette ébauche d’échelle de coût est une aide pour décider de ce que doit être l’implémentation la plus simple qui correspond à la validation d’un nouveau test. Les tests I, II, III sont d’abord implémentés par des « if ». Ensuite, un refactoring va introduire une boucle « for ».

public String convert(int nombre) {  
 if (nombre > 2) {  
   return "III";  
 } else if (nombre > 1) {  
   return "II";  
 }  
   return "I";  
}  

devient

public String convert(int nombre) { 
 StringBuilder resultat = new StringBuilder();   

 for (int i = 0; i < nombre; i++) { 
   resultat.append("I"); 
 } 

 return resultat.toString(); 
} 

Un embranchement des possibles

L’étape suivante correspond à un embranchement des possibles. Résumons. On peut choisir le IV, mais on doit l’écarter car il introduit un test beaucoup trop spécifique par rapport à l’avancée du code actuel. En effet, il introduit :

  • Un nouveau symbole : le X
  • Une nouvelle représentation : la demi-unité
  • Et un nouveau concept : la notation soustraction

On écarte, pour des raisons similaires, le V qui introduit un nouveau symbole et la représentation en demi-unité. Je choisis le X.

public String convert(int nombre) {  
 StringBuilder resultat = new StringBuilder();    

 if (nombre >= 10) {  
   return "X";
 } else {  
   for (int i = 0; i < nombre; i++) {  
     resultat.append("I");  
   }  
 }  
return resultat.toString();  
}  

La répétition des symboles

À l’étape suivante, on est face à deux options : le XX ou le XI. Je cherche à orienter sur la répétition des symboles. D’abord, je choisis donc d’écrire le test du XX.

public String convert(int nombre) { 
 StringBuilder resultat = new StringBuilder();   

 if (nombre >= 10) { 
   for (int i = 0; i < nombre/10; i++) { 
     resultat.append("X"); 
   }  
 } else { 
   for (int i = 0; i < nombre; i++) { 
     resultat.append("I"); 
   } 
 } 

 return resultat.toString(); 
} 

La notion de reste

L’étape délicate suivante se passe lors d’un refactoring quand on a validé le XI. Il faut reconnaître la division euclidienne classique et introduire la notion de reste. Cette étape implique la transformation des boucles « for » en « while ».

public String convert(int nombre) { 
 StringBuilder resultat = new StringBuilder();   

 for (int i = 0; i < nombre / 10; i++) { 
   resultat.append("X"); 
 } 
 for (int i = 0; i < nombre % 10; i++) { 
   resultat.append("I"); 
 }  

 return resultat.toString(); 
} 

On transforme donc les for en while :

public String convert(int nombre) { 
 StringBuilder resultat = new StringBuilder();   

 int reste = nombre;   

 while (reste >= 10) { 
   resultat.append("X"); 
   reste -= 10; 
 } 
 while (reste >= 1) { 
   resultat.append("I"); 
   reste -= 1; 
 }  

 return resultat.toString(); 
} 

J’introduis ensuite un « enum » qui fait le lien entre un symbole et sa valeur. On itère sur les éléments de l’énumération :

static enum RomanLiteral { 
 I(1), 
 X(10), 
 ;   

 int value; 

 private RomanLiteral(int value) { 
   this.value = value; 
 } 
} 

On utilise l’enum dans la méthode :

public String convert(int nombre) { 
 StringBuilder resultat = new StringBuilder();   

 int reste = nombre;   

 List literals = Arrays.asList(RomanLiteral.values()); 
 Collections.reverse(literals);   

 for (RomanLiteral literal : literals) { 
   while (reste > literal.value) { 
     resultat.append(literal.name()); 
     reste -= literal.value; 
   }
 }  

 return resultat.toString(); 
} 

On sépare les abstractions en extrayant l’appel à la liste ordonnée des valeurs de l’enum :

public static List literalsDecrementOrder() { 
 List literals = Arrays.asList(RomanLiteral.values()); 
 Collections.reverse(literals); 
 return literals; 
} 

L’algorithme n’est alors plus itératif, suite de boucles et de conditions, mais déclaratif, par construction de l’enum.

static class ResultatBuilder { 
 int reste; 
 StringBuilder resultat = new StringBuilder(); 

 public ResultatBuilder(int reste) { 
   super(); 
   this.reste = reste; 
 }   

 public void append(String name) { 
   resultat.append(name); 
 }   

 public String format() { 
   return resultat.toString(); 
 }   

 public void compute(RomanLiteral literal) { 
   while (reste >= literal.value) { 
     append(literal.name()); 
     reste -= literal.value; 
   } 
 } 
} 

La construction du résultat

Le refactoring suivant introduit une nouvelle structure qui va encapsuler la construction du résultat et la gestion du reste :

La méthode principale ne comporte plus que l’ordonnancement :

  • Je crée le builder de résultat
  • Pour chaque symbole de notation romaine du plus grand au plus petit, j’applique l’algorithme
  • Je retourne le résultat
public String convert(int nombre) { 
  ResultatBuilder resultat = new ResultatBuilder(nombre); 
  for (RomanLiteral literal : RomanLiteral.literalsDecrementOrder()) { 
    resultat.compute(literal); 
  } 
  return resultat.toString(); 
} 

Analyse critique

Doit-on s’arrêter là ?

L’algorithme ainsi construit correspond à un dictionnaire de symboles, dans lequel les valeurs IV et V sont présentes. La notion de soustraction n’a pas été abordée, mais évitée par une « constante » dans le dictionnaire. Doit-on continuer les refactorings pour faire émerger ce concept ? Je pense personnellement que cela n’est pas nécessaire. Le code passe les tests, les intentions sont explicites et il y a peu de duplication.

Dois-je refactorer les tests ?

On doit refactorer les tests en même temps que le code. Ici, il n’y a pas d’étape où je refactore les tests, les structures sous-jacentes n’évoluant pas.

Tests paramétrés ou tests autonomes

Je préfère des tests autonomes quand les fonctionnalités sont autonomes. C’est-à-dire : je sépare les fonctionnalités par des cas, donc je sépare les tests. Je pourrais en revanche utiliser un test paramétré quand le cas d’utilisation est décrit d’une manière différente (ex : write_a_I_for_each_unit).

Duplication ou initialisation dans un @before ?

Je privilégie la duplication du corps de la méthode de test en général : je préfère en effet que la lecture du test soit suffisante à la compréhension de ce qu’il fait et qu’il puisse indiquer clairement son intention.

Suivi de la technique, step par step sans porter beaucoup d’attention au domaine

Cette critique est moins agréable mais très pertinente. L’exercice tel que décrit dans la vidéo est très technique. Il présente les concepts du TDD et un exemple de suivi de la méthode mais il ignore complétement le domaine.

Eclipse

Oui.

Autre cheminement

En s’intéressant plus au domaine et à ce que représente une notation de nombres, la description des tests sera différente.

On commencera par expliquer que la notation commence par des traits tracés et que leur nombre donne la valeur. Ensuite, on ajoutera un regroupement de symboles (des remplacements sur la chaîne de caractères construite) ou un regroupement de valeur (division euclidienne). Le cheminement peut donc être le même ou très différent. Et si vous m’expliquiez votre manière de faire à vous ?

Conclusion

Un kata est un exercice que l’on répète pour acquérir des réflexes. Dans cet exercice, on insiste principalement sur le choix du test suivant et sur un refactoring systématique pour faire émerger la solution. D’autres cheminements sont possibles et peuvent aboutir à des codes très différents ou parfois très proches. Le principal est de recommencer. C’est le principe même du Kata.

Retrouver le code produit ici

Pas encore de commentaires

Publier un commentaire

Gaetan Eleouet 2024

Auteur

Gaëtan Eleouet

Gaëtan est un développeur passionné, il s’intéresse particulièrement à tout ce qui a trait à l’écosystème Java, à l’intelligence artificielle et aux pratiques de développement.
Il a commencé sur des interfaces graphiques en Java, dans l’industrie puis en développement rapide en salle de marché. Il a ensuite consolidé les aspects professionnels du développement dans des grands groupes en finance et est maintenant également enseignant en école d’ingénieur.