À la découverte du TDD

15 novembre 2021 / Par Yohann

Temps de lecture : 17 min

sumit
sumit

Le TDD : voici quelques idées-clés qui ont jalonné jusqu’ici mon propre parcours de découverte du Test Driven Development. On peut croiser quelques idées reçues plus ou moins pertinentes quand on cherche à s’informer sur ce sujet alors j’espère que ces quelques lignes permettront de cerner rapidement et vraiment les principales caractéristiques de cette méthode.

J’ai reporté un exemple basique d’application du TDD à la fin de l’article. Peut-être préfèrerez-vous commencer par-là… à vous de voir !

Un peu d’histoire

Le développement piloté par les tests ou Test Driven Development ou TDD est une méthode de développement logiciel qui a été conçue il y a environ 20 ans par Kent Beck.

Kent Beck est l’auteur du livre Extreme Programming Explained sorti en 1999 dans lequel, notamment, il présente le principe de l’écriture de tests unitaires automatisés qui précède l’écriture du code qu’ils évaluent. En 2002, Kent Beck sort son Test Driven Development : By Example (TDDBEX), ouvrage que je suis en train de parcourir, dans ma quête d’appropriation de cette méthode étonnante. Etonnante de mon point de vue, le point de vue d’un développeur qui, au gré de sa pratique, faisant ses propres expériences, a adopté progressivement une méthode qui considère la conception et l’architecture comme des étapes préalables nécessaires à la qualité du code.

Les tests unitaires et la qualité du code

Avant de lire TTDBEX j’ai lu Clean Code : A Handbook of Agile Software Craftsmanship, le livre de Robert C. Martins (alias Uncle Bob) où il présente très succinctement le TDD comme une méthode où le code de production et les tests sont rédigés ensemble (en commençant par les tests). Il mentionne le TDD dans un chapitre plus large portant sur les tests qui insiste sur la nécessité de prendre autant soin du code des tests que du code de production.

Dans Clean Code, la qualité du code est présentée comme le résultat d’un processus itératif de constante amélioration. Le code initial implémenté par le développeur ne devrait être considéré que comme sa première version, le plus souvent perfectible, qui a vocation à être réécrit autant de fois que nécessaire, notamment lors d’épisodes de maintenance successifs. La qualité est présentée comme « émergente » et non comme le résultat définitif d’une phase de conception qui précéderait le codage.

Quand le code n’est pas couvert par les tests unitaires, le développeur qui aborde un bloc de code manifestement perfectible, peu lisible, présentant des défauts, maudit d’abord son auteur par une farandole de « What the f*** ! ». Puis, il imagine peut-être les différentes manières d’améliorer ce code, assumant noblement le rôle qu’il a à jouer dans l’émergence de la qualité du code.

Mais il y a un risque pour que ce développeur finisse par opter pour un fatalisme raisonnable en décidant de ne toucher à ces horribles lignes de code sous aucun prétexte. Et en l’absence de tests unitaires, comment l’en blâmer ? Il ne peut pas s’appuyer sur les tests unitaires pour prévenir le risque d’effets indésirables que ses modifications pourraient apporter. L’évaluation des interdépendances est souvent une tâche hors de portée car trop fastidieuse, trop chronophage. Si on considère la qualité comme une notion émergeante, alors c’est ici que la qualité s’arrête (et que commence la dette technique) : quand (ou à chaque fois que) le développeur renonce à améliorer le code qui devrait l’être.
Pouvoir s’appuyer sur les tests unitaires signifie savoir que l’ensemble du code de production du logiciel sur lequel le développeur intervient est, d’une manière générale, couvert par les tests.

C’est surtout sur cet aspect que l’auteur insiste dans Clean Code : si le développeur craint d’apporter des modifications, c’est la qualité qui est directement menacée. Les tests unitaires rendent donc possible la qualité. En cela, le code de test est aussi important que le code de production.

Après la lecture de ce chapitre de Clean Code (qui propose par ailleurs une somme de préconisations sur le sujet plus large des tests unitaires), c’est essentiellement ce que je retenais du TDD : une méthode dont le principal objectif est d’associer étroitement la rédaction du code de production et celle des tests. Une méthode qui libère le développeur de sa crainte de la modification et qui rend ainsi possible l’émergence de la qualité.

Mais le TDD ce n’est pas exactement ou pas seulement ça.

La vocation du TDD

Comme son nom l’indique, le TDD est effectivement une méthode de développement. C’est une méthode qui propose une marche à suivre pour programmer. C’est une méthode à destination du développeur dont l’objectif est la production du code, la production d’un code propre qui fonctionne.

Dans le cadre du TDD, les tests ne sont pas l’objectif mais l’outil du développeur, la marche à suivre.

Le dialogue orchestré par le TDD entre les tests et le code implémenté est motivé par la maitrise qualitative du code : le code produit est systématiquement évalué. Les tests rythment et guident le processus d’implémentation du code qui se trouve par les mêmes tests systématiquement, complètement et durablement évalué.

Dans une équipe de développement pratiquant le TDD, n’importe quel développeur interrogé à n’importe quel moment peut dire “il y a une minute, la totalité du code du programme fonctionnait” (la dernière minute étant relative à l’étape de développement en cours).

Le développeur ne rédige pas une ligne de code sans être couvert par un test. Cela lui permet de produire du code sans jamais s’écarter de l’objectif concret de la résolution du problème exprimé par le test. C’est donc un processus de développement sécurisant pour le développeur.

Kent Beck présente aussi le TDD comme une méthode permettant de maitriser la peur pendant le développement, la peur et ses effets néfastes : la difficulté à communiquer clairement avec l’équipe, l’hésitation, la crainte d’avancer, les approximations.

Une méthode incrémentale de programmation

Pour illustrer le principe de programmation incrémentale du TDD, Kent Beck prend l’image d’un mécanisme à cliquets où chaque dent représente une nouvelle étape dans la résolution globale du problème. Chaque dent franchie stabilise un état dans la progression en empêchant le retour en arrière. De la même manière, avec le TDD, chaque test validé garantit la progression vers la résolution globale qui se trouve ainsi sécurisée. Dans le mécanisme à cliquets, la dent suivante est proche, facile à atteindre. De même, avec le TDD, chaque nouvel objectif, chaque nouveau test choisi doit représenter un petit pas vers la résolution générale du problème.

On répète le cycle suivant jusqu’à la résolution complète du problème, ces étapes composent le micro-cycle, le “mantra du TDD” :

1) RED : ajouter un test qui échoue

  • Ajouter un test unitaire qui valide une nouvelle étape, un nouveau pas (plus ou moins minuscule) dans la résolution globale du problème.
  • Constater que ce test échoue (il se peut que faute d’implémentation, la compilation elle-même échoue à ce stade, cela doit être considéré comme un échec du test).

2) GREEN : faire passer le test

  • Ajouter le minimum de code utile à la validation du test. Ici, l’unique objectif est de faire réussir le test le plus rapidement possible pour sécuriser la progression.
  • Constater que le test est réussi.

3) REFACTOR : Procéder au refactoring du code et notamment éliminer la duplication et la dépendance entre le code de test et le code production car effectivement, l’étape précédente de codage est de nature à en générer beaucoup en raison de son objectif : faire passer le test le plus basiquement et le plus rapidement possible.

Produire du code propre qui fonctionne

Dans ces étapes (red, green, refactor) on sépare dans le temps la réalisation de deux objectifs : produire un code qui fonctionne et produire un code propre. Le TDD suppose effectivement de ne pas se préoccuper des deux objectifs en même temps. Le premier passe toujours avant le second sans pour autant que le second ne soit jamais écarté. Une fois le test validé, on se situe dans un état où l’objectif fonctionnel est atteint. Il est non seulement atteint mais il est également verrouillé puisque sa vérification fait l’objet d’un test. Fort de cette situation, on peut s’attaquer au refactoring en toute sérénité car si les modifications entreprises dans cette dernière étape mettent en péril le premier objectif ou un autre objectif atteint précédemment alors les tests échoueront.

Le TDD est notamment fondé sur l’idée que concevoir une solution dans sa globalité qui réponde à la double exigence de production d’un code propre et de production d’un code qui fonctionne, est très difficile pour la plupart des développeurs, et souvent même pour les meilleurs d’entre eux.

Clean code that works is out of the reach of even the best programmers some of the time, and out of the reach of most programmers (like me) most of the time.
Kent Beck – Test-Driven Development By Example

La marche à suivre suggérée par le TDD, son mantra “red, green, refactor” en séparant la réalisation des deux objectifs, permet de rassurer le développeur dans sa progression.

Une méthode empirique de programmation

Le TDD s’oppose à l’approche “architecture first” qui veut concevoir, échafauder, modéliser avant d’implémenter. Il s’agit de reconnaitre la relative faillite de cette dynamique traditionnelle de programmation qui plonge le développeur dans de sombres et angoissantes périodes d’intense réflexion pendant lesquelles il cherche laborieusement l’ultime modèle, l’ultime solution, à la fois esthétique et performante, au problème ambitieux qui lui est donné à résoudre.

Le TDD propose au contraire de tâtonner et d’expérimenter en codant. L’orchestration du TDD segmente des phases successives de refactoring pendant lesquelles le développeur peut essayer des solutions, évaluer des hypothèses sans que cela ne nuise jamais à l’objectif fonctionnel puisque la progression vers cet objectif fonctionnel est garantie par les tests.

Pour autant, et même si le TDD suppose une certaine forme de docilité de la part du développeur, le TDD ne fait pas le deuil de son intelligence, ni de son intuition. Il propose seulement d’impliquer ses capacités selon un rythme dans lequel il peut, au lieu de se perdre en conjectures stratosphériques, proposer concrètement (en les implémentant) des hypothèses et obtenir un retour rapide sur les conséquences fonctionnelles grâce aux tests.

Le TDD transforme la phase d’évaluation traditionnelle du code

Traditionnellement, quand le développeur a terminé d’implémenter une partie du code, il exécute le programme en le débuguant, éventuellement en déroulant certaines parties du code en pas à pas pour vérifier que le programme se comporte conformément à ses attentes.

Avec le TDD, cette phase disparait à peu près complètement. Cette vérification du code se trouve assumée, avec le TDD, par les tests unitaires écrits en même temps que le code. L’immense avantage qui en découle est que cette vérification n’a pas lieu une seule fois après l’implémentation mais est intégrée à l’ensemble des tests unitaires qui sont rejoués fréquemment, sans limite, notamment après chaque modification du code. Elle se trouve donc pérennisée.

La disparition de cette phase de débogage est à considérer dans la balance avantages / inconvénients au moment où l’équipe de développement étudie la possibilité de passer au TDD.

Quelques idées reçues sur le TDD

On peut confier la rédaction des tests unitaires à un développeur et l’implémentation du code testé à une autre personne.

Non, on ne peut pas, sauf dans le cadre particulier du pair programming. Il faut comprendre que dans le TDD la création des tests est intrinsèquement liée à la dynamique du développement, du codage, de l’implémentation. Pratiquer le TDD c’est programmer en faisant dialoguer les tests et le code. Les tests sont trop intriqués dans cette dynamique pour pouvoir être délégués à un autre acteur que le développeur lui-même.

C’est ce qui empêche notamment le TDD d’être confondu avec une politique de couverture de tests unitaires dont l’objectif serait uniquement d’évaluer plus généralement l’adéquation de la solution implémentée avec les attentes fonctionnelles.

Cependant, dans le cadre du pair programming, étant donné que deux développeurs déroulent ensemble le même fil de développement, on peut pratiquer un jeu de rôles où un développeur se dédie à l’expression des tests et l’autre à l’implémentation du code y répondant. Cela permet notamment de séparer les deux préoccupations dans deux cerveaux distincts.

Le TDD c’est facile. Démarrer en TDD se fait naturellement.

Le TDD n’est pas facile en soit. Il peut même être considéré à certains égards comme une méthode contre-intuitive en ce qu’elle interdit cette tendance du développeur à échafauder une solution globale pour un problème donné.

S’en tenir au micro-cycle Red Green Refactor peut s’avérer en premier lieu assez frustrant pour un développeur habitué à concevoir une solution dans sa globalité. Bien des développeurs sont habitués à procéder selon une méthode basée sur “l’architecture d’abord”. Le TDD relève d’une dynamique à peu près inverse : pas de conception globale préalable, pas de mise en perspective initiale, mais une progression concrète pas à pas.

Le TDD est à éviter par des développeurs débutants.

Pour adopter le TDD et lui donner toutes ses chances, mieux vaut ne pas faire du TDD le combat de trop à mener dans une bataille déjà confuse. En d’autres termes, le développeur qui ne maitrise pas encore le langage, le framework, l’écosystème dans lequel il doit développer sera certainement trop préoccupé par sa capacité à produire pour essayer la marche à suivre du TDD.

Mais cela ne relève pas directement du niveau d’expérience du développeur. Un développeur débutant, dès lors qu’il a acquis un minimum d’aisance avec son langage sera à certains égards plus apte à s’approprier le TDD qu’un développeur expérimenté car ce dernier devra pour sa part lutter contre une somme de réflexes et d’habitudes de programmation. Le TDD implique une vraie remise en question des pratiques du développeur expérimenté, de sa démarche de programmeur. Il exige une forme de docilité.

Procéder par pas minuscules tel que le TDD le propose est une perte de temps.

Kent Beck répond à cette question dans son TDDBEX : ce qui est important n’est pas tant de procéder systématiquement par pas minuscules mais de savoir qu’en cas de besoin on peut revenir à cette manière de procéder, à cette décomposition des étapes d’implémentation :

“Do these steps seem too small to you? Remember, TDD is not about taking teensy tiny steps, it’s about being able to take teensy tiny steps. Would I code day-to-day with steps this small? No. But when things get the least bit weird, I’m glad I can. Try teensy tiny steps with an example of your own choosing. If you can make steps too small, you can certainly make steps the right size. If you only take larger steps, you’ll never know if smaller steps are appropriate.”

Kent Beck – Test-Driven Development By Example

En d’autres termes, si la décomposition en petits pas semble inutile et gratuite et qu’une solution plus complète ou plus esthétique semble évidente alors implémentez-là. Mais vous savez qu’en cas de besoin ou simplement si un doute s’installe, vous pouvez revenir à une décomposition plus fine de votre progression. Procéder par pas minuscules permet de linéariser et de sécuriser la progression.

Les tests à écrire dans le cadre du TDD correspondent aux attentes exprimées dans le cahier des charges ou dans l’expression de besoin.

Non. Les tests qui doivent être écris dans le cadre du TDD sont ceux qui sont jugés utiles par le développeur lui-même au travers de son travail d’implémentation et qui viendront rythmer ce processus.

Les tests du TDD ne sont donc pas une transposition programmatique des exigences d’un cahier des charges. Concevoir des tests en vue d’évaluer que la solution implémentée réponde aux attentes exprimées dans le cahier des charges est certainement une bonne chose mais ce n’est pas, à proprement parlé, la vocation du TDD.

Par ailleurs, le TDD, de par la définition même de son périmètre d’application, c’est-à-dire le code produit par le développeur, s’avèrera parfois incapable de produire les tests utiles à une évaluation complète de la solution (dépendance de la solution envers des périmètres qui ne sont pas produits par le développeur : base de données, services distants…).

Le TDD c’est écrire les tests en premier.

Pas complètement faux, pas complètement vrai non plus, certainement très réducteur. C’est un raccourci qui peut être employé en toute bonne foi par des développeurs familiers de cette méthode et qui connaissent la somme des préoccupations que ce raccourci recouvre. Le problème c’est qu’il peut véhiculer plusieurs idées fausses à propos du TDD auprès d’une population peu avertie :
– L’objectif du TDD est la couverture par les tests unitaires et/ou l’évaluation fonctionnelle de la solution implémentée.
– En termes de méthode : il s’agit de retranscrire les besoins exprimés dans le cahier des charges sous la forme de tests unitaires généraux avant de passer à la phase d’implémentation du code.
– Il est assez facile d’apprendre et d’appliquer le TDD : il suffit de créer des tests avant de taper le code.

Ce raccourci ne dit pas grand-chose de la marche à suivre que le développeur est censé appliquer avec le TDD.

Peut-on toujours faire du TDD ?

La typologie du logiciel peut empêcher le TDD : si le code à implémenter est trop dépendant de périmètres externes, l’évaluation par les tests unitaires peut être difficile ou empêchée.

La composition de l’équipe de développement jouera aussi : si les membres de l’équipe ne parviennent pas encore à s’approprier les technologies avec lesquelles ils doivent produire (le langage en premier lieu), ils risquent fort d’avoir du mal à accepter cette méthode qui requière une bonne ouverture d’esprit et une réelle disponibilité mentale.

Et ensuite ? 

Le but ici n’est pas de fournir une vision exhaustive de tous les tenants et aboutissants du sujet. Il resterait évidemment encore beaucoup à évoquer sur le sujet du TDD.

Notamment :

  • Quelle est la popularité du TDD à ce jour et les statistiques d’adoption au sein des équipes IT ?
  • Quels sont les arguments des détracteurs du TDD ?
  • Quels sont les effets de la pratique du TDD sur les modèles élaborés, la conception, l’architecture ?
  • QUID de la question “Le TDD est-il mort ?“ lancée en 2014 par David Heinemeier Hansson (créateur de Ruby on Rails, fondateur de Basecamp).

Un exemple basique 

///////////////////////////////////////
/// RED : un premier test basique qui ne prétend pas évaluer la solution complète, mais qui exprime juste un premier pas.
/// Pour commencer on oublie que dans le monde il y a d’autres pays que la France.
/// Mais puisque cette idée m’est venue, je la note sur une petite TODO liste ! “Supporter d’autres pays que la France”
/////////////////////////////////////// Test
[Fact]
public void ShouldBeMajorIfAge20()
{
var majorityEvaluator = new MajorityEvaluator();
bool isMajor = majorityEvaluator.IsMajor(20);
Assert.Equal(isMajor, true);
}

///////////////////////////////////////
/// GREEN : Du code basique et idiot MAIS qui valide le test !
/////////////////////////////////////// Code
public class MajorityEvaluator
{
public MajorityEvaluator()
{
}

public bool IsMajor(int age)
{
return true; // J’extrapole pour mieux illustrer
}
}
///////////////////////////////////////
/// REFACTOR : Si je retourne TRUE dans le code précédent c’est que je sais que pour valider le test il faut obtenir TRUE.
/// Le test est passé, c’était l’objectif.
/// Maintenant il faut casser la dépendance entre le test et le code : Retourner TRUE valide mon test parce que le teste donne 20 en argument “age”.
/// On peut passer par différentes étapes pour résoudre cette dépendance :
/// Explicitons la déduction dans le code : TRUE == 20 > 18.
/// En effectuant ce remplacement on peut constater que le test continue de passer. Pour autant la dépendance entre le test et le code demeure, elle apparait maintenant nettement (la valeur 20).
/// Remplaçons 20 par l’argument correspondant : age > 18. Voilà, la duplication et la dépendances sont résolues.
/// On peut éventuellement terminer cette phase de refactoring en remplaçant le 18 hardcodé par l’utilisation d’une constante MajorityAge et on obtient finalement le code qui suit.
/// Entre chaque étape de cette phase de refactorisation on a pu réexécuter les tests et vérifier que notre test continue de passer.
/////////////////////////////////////// Code
public class MajorityEvaluator
{
private const int MajorityAge = 18;
public MajorityEvaluator()
{
}

public bool IsMajor(int age)
{
return (age > MajorityAge);
}
}

///////////////////////////////////////
/// RED : Ajoutons un test qui permettra de vérifier qu’à 17 ans on est pas majeur (toujours en france)
/// Coup de chance : la phase de refactoring précédente m’a permis de couvrir ce cas. J’ai précédé la résolution du test ! Que Kent Beck me sauve !
/////////////////////////////////////// Test
[Fact]
public void ShouldNotBeMajorIfAge16()
{
var majorityEvaluator = new MajorityEvaluator();
bool isMajor = majorityEvaluator.IsMajor(17);
Assert.Equal(isMajor, false);
}
///////////////////////////////////////
/// RED : Ajoutons un test qui permettra de vérifier qu’à 18 ans on est majeur en France.
/// Remarque : avec un framework tel que xUnit on peut optmiser l’écriture de ces 3 premiers tests, mais ce n’est pas le sujet.
/// Cette fois j’ai bien un test en échec.
/////////////////////////////////////// Test
[Fact]
public void ShouldBeMajorIfAge18()
{
var majorityEvaluator = new MajorityEvaluator();
bool isMajor = majorityEvaluator.IsMajor(18);
Assert.Equal(isMajor, true);
}
///////////////////////////////////////
/// GREEN : me laissant guider par le test, je réalise que l’implémentation actuelle souffre d’une petite insuffisance et je remplace l’opérateur > par >=
/// Victoire ! le test passe.
/////////////////////////////////////// Code
public class MajorityEvaluator
{
private const int MajorityAge = 18;
public MajorityEvaluator()
{
}

public bool IsMajor(int age)
{
return (age >= MajorityAge);
}
}

///////////////////////////////////////
/// REFACTOR ? non. Je ne vois rien de plus à améliorer et je n’ai ajouté aucune dépendance entre le test et le code.
/////////////////////////////////////// Code

///////////////////////////////////////
/// RED : Dans ma liste de micro-objectifs que j’avais rédigé au début je lis “Supporter d’autres pays que la france”.
/// Allons courageusement exprimer cet objectif sous la forme d’un nouveau test !
/// Le test échoue.
/////////////////////////////////////// Test
[Fact]
public void ShouldBeMajorIfAge16InScotland()
{
var majorityEvaluator = new MajorityEvaluator(Countries.Scotland); // Instancions un évaluateur de majorité pour un pays donné
bool isMajor = majorityEvaluator.IsMajor(17);
Assert.Equal(isMajor, true);
}
///////////////////////////////////////
/// GREEN : Implémentons le code qui manque pour faire passer le test.
/////////////////////////////////////// Code

public enum Countries
{
Scotland
}
public class MajorityEvaluator
{
private const int MajorityAge = 18;

private const int MajorityAgeInScotland = 16; // J’ajoute l’age de la majorité écossaise dont j’ai besoin pour valider mon nouveau test.
private readonly Countries country;

public MajorityEvaluator(Countries country) // Je décide qu’instancier un évaluateur de majorité sans fournir le pays n’a pas de sens. Je supprime le constructeur par défaut au profit du constructeur exigeant un pays en argument.
{
this.country = country;
}

public bool IsMajor(int age)
{
if(country == Countries.Scotland) // Pour valider mon test de majorité en écosse j’ajoute cette condition
return (age >= MajorityAgeInScotland);

return (age >= MajorityAge);
}
}
///////////////////////////////////////
/// Ici, les tests précédents échouent : c’est à dire que l’on écoppe d’une erreur de compilation en raison de la suppression du constructeur par défaut de la classe MajorityEvaluator.
/// Avant de continuer il faut résoudre ce problème.
/// Je n’avais pas anticipé l’interface dont j’allais avoir besoin (le constructeur MajorityEvaluator avec argument country), et ce n’est pas grave.
/// Je reviens sur les tests en question pour préciser l’argument France, j’en profite pour renommer les méthodes de test pour exprimer cette nuance.
/////////////////////////////////////// Code

[Fact]
public void ShouldBeMajorIfAge20InFrance()
{
var majorityEvaluator = new MajorityEvaluator(Countries.France);
bool isMajor = majorityEvaluator.IsMajor(20);
Assert.Equal(isMajor, true);
}

[Fact]
public void ShouldNotBeMajorIfAge16InFrance()
{
var majorityEvaluator = new MajorityEvaluator(Countries.France);
bool isMajor = majorityEvaluator.IsMajor(17);
Assert.Equal(isMajor, false);
}

[Fact]
public void ShouldBeMajorIfAge18InFrance()
{
var majorityEvaluator = new MajorityEvaluator(Countries.France);
bool isMajor = majorityEvaluator.IsMajor(18);
Assert.Equal(isMajor, true);
}

public enum Countries
{
Scotland,
France // j’ajoute l’item France dans l’enum Countries
}

public class MajorityEvaluator
{
private const int MajorityAgeInFrance = 18; // Je renomme ma constante MajorityAge pour exprimer le rapport au pays
private const int MajorityAgeInScotland = 16;
private readonly Countries country;

public MajorityEvaluator(Countries country)
{
this.country = country;
}

public bool IsMajor(int age)
{
if (country == Countries.Scotland)
return (age >= MajorityAgeInScotland);

return (age >= MajorityAgeInFrance);
}
}
///////////////////////////////////////
/// Ici, tous les tests passent. Bravo.
/// Fort de cette situation, on peut améliorer le code en appliquant certaines bonnes pratiques…
/// On ne crains rien, les tests nous couvrent !
/// Si on casse le premier objectif les tests nous le diront.
///////////////////////////////////////