juillet
2012
Nous allons parler aujourd’hui du TDD ou Test Driven Development (Développement dirigé par les tests). Cette pratique est apparue dans le courant des années 2000 au sein d’une méthodologie Agile appelée Xtreme Programming (XP). Avant de rentrer dans le vif du sujet, je vais rappeler un peu le pourquoi des méthodologies Agiles et nous verrons ensuite tout ce qui concerne le TDD.
Petites précisions avant de commencer :
L’article ci-dessous n’est qu’une introduction à une méthodologie vaste et complexe. Vous ne trouverez donc dans cet article que des notions de base et des explications simples. Des articles concernant les Mocks et d’autres pratiques plus avancées seront écrits plus tard.
Les exemples écrits dans le cours seront basés sur C#, Visual Studio et le Framework .NET mais les explications sont valables pour tous les langages objets. De plus, le C# ayant une syntaxe relativement claire, il n’y a pas besoin d’être initié au langage pour pouvoir comprendre les exemples et les reproduire dans d’autres langages et/ou IDE.
1. L’Agile, qu’est ce que c’est ?
Les méthodes Agiles ont été créées pour répondre à une problématique simple : Comment répondre réellement et efficacement au besoin du client ? Dans un modèle de développement classique, les architectes passent des mois voire des années à définir les spécifications techniques d’un projet avant de se mettre à écrire la moindre ligne de code. Différentes problématiques ont été soulevées par cette manière d’appréhender la conception de solutions :
- Premièrement, au bout d’un an passé à établir les spécifications, le besoin peut avoir changé du tout au tout. Résultat, si l’on va jusqu’au bout du projet, le livrable sera juste inutile pour le client.
- Deuxièmement, après avoir passé tout ce temps à écrire les spécifications, on a pas forcément envie de les changer tous les deux mois. Résultat, si les développeurs doivent changer quelque chose, ils ne s’embêtent plus à taper dans les spécifications et écrivent des « rustines » qui ne seront au final pas documentées. C’est comme ça que l’on se retrouve avec une usine à gaz sur les bras qui a une documentation complètement obsolète.
C’est la raison pour laquelle dix-sept grands noms du développement se sont mis d’accord au début des années 2000 pour écrire le « Manifeste pour le développement Agile de logiciels » (que vous pouvez retrouver ici). C’est un mini recueil au sein duquel ils ont rassemblé douze des pratiques les plus importantes à leurs yeux pour développer de manière Agile.
Si l’on résume un peu, l’idée nouvelle est de placer le client au centre du processus de développement afin d’avoir le plus de retours possibles. C’est cela qui va nous permettre d’être sûrs que l’on écrit les fonctions dont il a besoin au lieu de foncer à l’aveugle. Chacun des feedbacks reçu permet de mieux cerner le besoin du client et d’adapter la solution en fonction.
Il faut se mettre dans l’esprit que 90% des clients (Magnifique chiffre sorti de mon imagination mais qui ne doit pas être loin de la réalité) n’ont aucune idée du produit dont ils ont besoin. Ils savent qu’ils ont besoin de quelque chose, ils pensent savoir de quoi ils ont besoin mais quand ils se retrouvent en face du dit produit, même si c’est exactement ce qu’ils ont demandé, ils se rendent compte que ce n’est pas du tout ce dont ils ont réellement besoin. Et mieux vaut qu’ils s’en rendent compte après deux mois qu’après deux ans, non ?
2. Le Test Driven Development
Je parle de TDD depuis le début sans expliquer exactement ce que c’est et à quoi ça sert. C’est ce que je vais faire dans ce chapitre. Malgré que TDD soit une composante d’XP à la base, il est maintenant devenu courant de l’utiliser sans XP en tant que méthodologie indépendante. Je pense qu’il est même possible de l’utiliser uniquement pour soi sur un projet classique totalement non-Agile afin de pouvoir écrire un code clair, propre et que l’on puisse refactoriser sans soucis.
Le principe de TDD est des plus simples : Au lieu d’écrire du code, puis les tests correspondants, nous allons faire l’inverse, c’est à dire des tests puis uniquement le code qui permet de les valider. Expliqué comme ça, cela parait un peu fou et l’intérêt que cela apporte ne saute pas forcément aux yeux.
Réfléchissons un peu sur l’utilité des tests unitaires. Ces tests permettent de vérifier que nos méthodes font exactement ce que l’on souhaite et ont un comportement de sortie défini selon l’entrée que l’on leur donne. Un test doit être déterminant, c’est à dire que si on lance le test 1000 fois, il doit être validé 1000 fois.
Si l’on teste une méthode Additioner() et qu’on lui donne 2 et 3 en paramètre, il parait normal qu’elle doit renvoyer 5 à chaque fois. C’est le même principe avec toutes les fonctions.
Les étapes du TDD sont les suivantes :
- Ecriture d’un premier test (sans aucun code préalable)
- Vérifier qu’il échoue car il ne teste aucun code
- Ecrire juste le code suffisant pour passer le test
- Vérifier que le test passe
- Refactoriser le code (L’améliorer, le rendre plus lisible, retirer le code dupliqué, tout en s’assurant qu’il fonctionne toujours grâce aux tests)
L’avantage des tests unitaires est qu’ils sont très rapides. On peut donc les lancer à chaque fois que l’on fait une modification dans le code, même mineure (Que l’on ne peut faire qu’à la suite de l’écriture d’un nouveau test si vous avez bien suivi :p).
Avant de passer au projet exemple, je vais vous faire un petit exemple avec notre fonction Additioner() histoire que vous compreniez un peu. Je vais écrire du code sans expliquer comment le faire dans l’IDE, c’est juste pour la compréhension globale. Nous verrons comment faire tout ça un peu plus tard.
Si l’on suit le raisonnement du TDD, il faudrait écrire un test qui valide le comportement de la méthode. Il faudrait par exemple un test qui vérifie que 2 + 2 = 4, c’est à dire que Additionner(2,2) renvoie bien 4. Pour ceci, nous allons utiliser les assertions. C’est un ensemble de fonction qui ne font rien si le test passe et qui renvoient une exception si celui ci échoue.
Exemple :
int result = MyClass.Additionner(2,2);
Assert.AreEqual(4, result);
Il faut savoir que le premier paramètre pris par les assertions est le paramètre attendu (Ici on attend que la fonction renvoie 4) et le second le paramètre à comparer (Ici le retour de fonction). La fonction d’assertion est assez transparente, il s’agit de vérifier si la variable « result » est bien égale à 4. Si vous avez bien suivi la pratique du TDD, vous ne pouvez pas compiler (On a dit qu’on ne l’écrivait pas dans l’IDE donc d’un côté c’est normal :D).
Pourquoi cela ? Tout simplement car nous n’avons pas écrit notre classe MyClass et encore moins notre méthode Additionner() ! Visual Studio fait donc son boulot, il colorie tout ça en rouge et nous gronde un peu. Résultat, pour faire notre test, il faut écrire le code minimal, c’est à dire créer la classe et la fonction (Pas trop dur encore, ça va aller).
public class MyClass
{
public static int Additionner(int a, int b)
{
return 0;
}
}
A noter que j’ai mis return 0 car il fallait bien retourner une valeur, donc j’ai pris la plus neutre possible. Rien n’empêchait de retourner 8, 13 ou 157438. Une fois que la base est écrite, nous pouvons lancer notre test pour la première fois. Vous devez vous douter qu’il va échouer, étant donné que l’assertion attend 4 et que la méthode renvoie 0.
Quelle serait alors la manière la plus simple de faire passer le test ? Tout simplement retourner 4 ! Ca peut paraître très bête mais avec cela le test va passer ! On est pas aussi bêtes que cela donc une fois que notre test passe, on juge que ça n’est pas représentatif et l’on écrit donc un autre test :
int result = MyClass.Additionner(4,4);
Assert.AreEqual(8, result);
Une fois celui ci écrit, on lance tout nos tests. Bizarrement, le premier passe mais pas celui ci ! Il faut donc modifier le code de manière à ce que tous les tests passent. Je vous passe le fait de mettre return 8 et de se rendre compte que cela ne fonctionne pas. Par contre, on pourrait essayer d’être très bête et de mettre un return qui fonctionne sans pour autant correspondre à la méthode.
public static int Additionner(int a, int b)
{
return a * 2;
}
Les deux tests passent. Super, on a écrit la méthode Additionner de la meilleure manière qui soit ! Nous continuons donc notre projet, jusqu’à ce que l’on rencontre un bug. C’est bizarre, quand je debug je m’aperçois que quand je fais Additionner(3,2) cela me renvoie 6 au lieu de 5 ! Je fais donc ce que demande le TDD quand je rencontre un bug, c’est à dire que je le reproduis en test. Comme ça, dès que tous les tests passent, cela signifie que j’ai réglé le bug sans casser le reste du code !
int result = MyClass.Additionner(3,2);
Assert.AreEqual(5, result);
Il faut donc que je change ma méthode pour passer le test :
public static int Additionner(int a, int b)
{
return a + b;
}
Waouh ! Qui aurait cru que le moyen le plus simple pour faire une addition serait … de faire une addition ! Il est vrai que l’exemple si dessus est vraiment très basique mais c’était un moyen de voir comment des tests successifs impactaient le contenu d’une méthode et sa transformation au fur et à mesure. C’est ce que nous allons voir un peu plus en détail dans notre projet exemple.
3. Le TDD par l’exemple
Pour illustrer cette longue explication théorique, je vous propose de recréer pas à pas une classe ainsi que les tests qui vont avec. Ou plutôt, si vous avez bien suivi, écrire la classe qui va avec les tests :p.
Ne sachant pas trop quoi prendre, j’ai décidé de reprendre un pattern des plus simples mais qui possède de multiples implémentations : La Factory. Pour ceux qui ne connaîtraient pas les Designs Patterns, je les invite tout d’abord à lire ceci. Si vous ne comprenez pas tout, ne vous en faites pas, nous n’en aurons pas besoin dans la suite, c’était juste un peu de culture :D.
Pour résumer le principe, notre Factory sera une classe qui créera des objets de différents types en fonction d’un chaîne de caractère qu’on lui passera.
Nous allons commencer en créant un projet de type bibliothèque de classes que nous appellerons « ExempleTDD ».
Nous rajoutons ensuite à notre solution un projet de test que nous appellerons « ExempleTDD_Tests ». En général, il est mieux de créer un projet de test par projet à tester, ainsi qu’une classe de test pour chaque classe à tester.
Maintenant que nous avons nos projets crées, il ne reste plus qu’à les remplir ! Mais attention, n’oubliez pas qu’avant de faire n’importe quelle opération, nous avons d’abord besoin d’un test qui échoue ! Je vous propose donc de créer notre première classe de test que nous appellerons « Factory_Test.cs ». Pour cela, il suffit d’ajouter une simple classe a notre projet de test puis nous la décorons avec quelques attributs :).
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ExempleTDD_Tests
{
[TestClass]
public class Factory_Tests
{
}
}
Pour commencer, n’oubliez pas de mettre le using : C’est dans cet espace de noms que nous allons trouver toutes les méthodes et attributs pour réaliser nos tests. Si vous êtes débutant, le [TestClass] au dessus de la déclaration de classe a du vous interpeller.
C’est ce que l’on appelle un attribut. En gros, nous allons nous servir des attributs pour informer Visual Studio de certaines choses. Evidemment, les attributs ont bien d’autres utilités mais nous ne verrons pas cela dans ce cours ;). Ici, nous informons Visual Studio que la classe qui est définie juste en dessous est une classe de test et donc qu’il peut venir chercher les méthodes de tests ici.
Et c’est à ce niveau que les attributs deviennent supers : On peut très bien créer une méthode privée qui sert à nos tests sans forcément qu’elle soit prise en compte comme méthode de test. Vous verrez par la suite qu’on peut faire une bonne quantité de chose grâce à ces attributs et que ça va nous faciliter la vie dans nos tests :). Créons donc notre première méthode de test :
[TestMethod]
public void CreatePompier()
{
PersonFactory factory = new PersonFactory();
IPerson pompier = factory.Create("pompier");
Assert.IsInstanceOfType(pompier, typeof(Pompier));
}
Pour commencer, ne vous affolez pas si tout est devenu tout rouge ! C’est normal ! Je vais expliquer au fur et à mesure tout ce que je viens de vous montrer. On va commencer dans l’ordre avec l’attribut [TestMethod]. Comme vous l’avez sans doute deviné, celui ci permet de prévenir Visual Studio qu’il doit considérer cette méthode comme un test. Ensuite, nous créons la méthode CreatePompier() dans laquelle nous allons mettre notre test de création d’un pompier.
Ici, je crée un objet de type PersonFactory sur lequel j’appelle ensuite la méthode Create avec « pompier » comme paramètre qui me renvoit un objet de type IPerson. La dernière ligne que je vais un peu plus détailler est en fait le test qui doit vérifier si mon objet IPerson est bien un objet Pompier.
Si vous n’avez encore jamais entendu parlé de la classe Assert, ne vous inquiétez pas ! Ici, Assert est une classe du framework de test de Microsoft qui propose des méthodes statiques (Accessibles en faisant Assert.quelquechose) afin de réaliser des assertions. Vous vous rappelez, ce sont ces méthodes qui ne font rien quand le test est bon mais qui lancent une exception si le test échoue. C’est ensuite interprété par le lanceur de tests de Visual Studio qui en conclura les tests qui ne sont pas validés.
On arrive à la dernière partie de cette méthode : Pourquoi tout est devenu rouge ? En fait, c’est vraiment bateau si on y réfléchit, je crée des objets et j’appelle des méthodes dont je n’ai même pas écrit les classes. Ca ne peut pas marcher ! Mais en écrivant mes tests d’abord, je vais les écrire exactement comme je souhaiterai utiliser mon objet dans mon application. Je ne me retrouve pas avec des classes écrites sans vraiment y réfléchir et qui ne sont pas pratique à utiliser.
Nous allons donc faire le strict minimum pour compiler, c’est à dire créer les classes et interfaces qui nous manquent : L’interface IPerson ainsi que les classes PersonFactory et Pompier. Par contre, nous allons également écrire le strict minimum à l’intérieur, ce qui donnerait :
public interface Iperson
{
}
public class Pompier
{
}
public class PersonFactory
{
public Iperson Create(string toto)
{
return null;
}
}
Attention à cette partie, elle est très importante. Ici, j’ai juste crée le strict minimum pour que mon programme compile, c’est à dire qu’en aucun cas Pompier doit implémenter IPerson. J’arrive très bien à compiler sans et il n’y a aucune raison pour que je l’écrive. Ah j’oubliais, pour que cela fonctionne, n’oubliez pas de rajouter une référence de votre projet .dll dans votre projet de test et de faire les using, sinon il ne pourra pas connaître les noms.
Maintenant que notre programme compile, nous allons lancer notre test ! Pour cela, vous devez avoir un petit bouton dans la barre de Visual Studio qui permet de lancer tous les tests de la solution. Regardez le bien, il va devenir votre meilleur ami !
Lançons maintenant tous les tests en appuyant sur ce bouton et nous devrions voir apparaître cette fenêtre :
Chose assez logique, notre test échoue. Nous devons donc écrire le strict minimum pour qu’il puisse passer. Dans le message d’erreur du test, il est dit qu’il s’attend à recevoir un objet de type Pompier mais qu’il reçoit null. Nous changeons donc notre méthode Create pour qu’elle renvoit un objet de type Pompier :
public Iperson Create(string toto)
{
return new Pompier();
}
Si le compilateur râle encore, c’est normal ! Pompier n’est pas un objet de type IPerson, il faut donc que la classe Pompier implémente IPerson.
public class Pompier : IPerson
{
}
On compile, on relance les tests et ô miracle : Ca devient vert ! Félicitation, votre premier test vient de réussir :). Il faut donc en créer un autre pour rajouter les fonctionnalités que l’on souhaite. Par exemple, que notre Factory crée également des Etudiants.
[TestMethod]public void CreateEtudiant()
{
PersonFactory factory = new PersonFactory();
IPerson etudiant = factory.Create("etudiant");
Assert.IsInstanceOfType(etudiant, typeof(Etudiant));
}
Un beau copié collé du test précédent, mis à part que je m’attend à un objet de type Etudiant cette fois. Il faut donc créer la classe Etudiant, mais ne pas la faire implémenter IPerson. On lance ensuite les tests une fois que ça compile et paf, c’est vert pour le premier mais celui la est rouge.
On joue le jeu à fond et on devient très bête pendant cinq minutes : « On n’a qu’à faire comme avant, on a juste a faire ‘return new Etudiant();’ ! ». On fait donc implémenter IPerson à la classe Etudiant et on fait un return tout bête. Le second test passe mais le premier redevient rouge. C’est parfait, nos tests commencent à servir à quelque chose. On va donc écrire la méthode normalement mais le plus simplement du monde :
public Iperson Create(string objectType)
{
if(objectType == "pompier")
return new Pompier();
return new Etudiant();
}
On compile, on relance les tests c’est super, notre Factory fonctionne comme on le souhaiterait. Pour le moment, pas vraiment d’intérêt au TDD mais c’est maintenant que ça va devenir génial. La partie qui pose le plus de problème aux développeurs, ce sont les mauvais cas d’utilisations la plupart du temps : Comment réagir si l’utilisateur passe une chaîne vide, un objet null ou même un type inconnu à notre méthode Create() ?
L’aventage du TDD, c’est que le comportement de la classe dans un cas comme celui ci sera écrit noir sur blanc et sera clair. Si du jour au lendemain, on décide d’arrêter de lever une exception en cas de type inconnu et de renvoyer null à la place, au lieu d’avoir une documentation qui ne sera pas à jour, les tests vont crasher et devoir être refaits. C’est l’intérêt principal du TDD : Etre une documentation évolutive et toujours à jour de la classe qui lui correspond.
Voyons donc comment gérer nos cas d’erreurs. Commençons par les types qui n’existent pas :
[TestMethod]public void Create_WithUnknownType()
{
PersonFactory factory = new PersonFactory();
IPerson person = factory.Create("totopouet");
Assert.IsNull(person);
}
Nous voulons ici que la Factory renvoie un null si le type est inconnu. Comme d’hab, la routine : Nous lançons le test, il échoue et nous implémentons la solution :
public Iperson Create(string objectType)
{
switch(objectType)
case "pompier":
return new Pompier();
case "etudiant":
return new Etudiant();
default:
return null;
}
}
Je ne vais pas finir pas à pas l’exemple que j’ai commencé mais je pense que vous pouvez deviner la suite. Pour ceux qui voudraient un petit exercice, voici ce qu’il faut implémenter en plus : La Factory renvoie une ArgumentNullException quand on passe un objet null, elle renvoie une ArgumentException quand on passe une chaine vide et on implémente un mini pattern singleton pour en avoir une seule instance (Pas besoin de lock, une simple méthode statique suffira). Bien sur, ce dernier test va demander de changer ceux d’avant, mais quoi de plus normal pour une documentation qui évolue ;).
Avant de terminer ce chapitre, je tenais à parler de certains attributs : [TestInitialize], [TestCleanup], [ClassInitialize], [ClassCleanup], [AssemblyInitialize] et [AssemblyCleanup]. Le premier est à mettre au dessus d’une méthode qui n’est pas un test et permettra de l’executer avant le lancement de chaque test. A l’inverse, le second permet d’executer une méthode à la fin de chaque test. Les autres servent à la même chose mais se déclenchent avant les tests de la classe ou de l’assembly concernées.
Ces attributs se révèlent vraiment pratique lorsque l’on doit lancer des routines avant ou après les tests. Cela permet d’enlever le code dupliqué des méthodes de test ou de réaliser des actions nécessaires qui n’ont pas forcément de lien avec les tests en eux-même.
Correction de l’exercice (Dont je peux fournir les sources si nécessaire) :
public class PersonFactory
{
private static PersonFactory _instance = new PersonFactory();
private PersonFactory()
{}
public Iperson Create(string objectType)
{
switch(objectType)
case "pompier":
return new Pompier();
case "etudiant":
return new Etudiant();
default:
return null;
}
}
public static PersonFactory GetInstance()
{
return _instance;
}
}
[TestClass]public class Factory_Tests
{
[TestMethod]
public void CreatePompier()
{
IPerson pompier = PersonFactory.GetInstance().Create("pompier");
Assert.IsInstanceOfType(pompier, typeof(Pompier));
}
[TestMethod]
public void CreateEtudiant()
{
IPerson etudiant = PersonFactory.GetInstance().Create("etudiant");
Assert.IsInstanceOfType(etudiant, typeof(Etudiant));
}
[TestMethod]
public void Create_WithUnknownType()
{
IPerson person = PersonFactory.GetInstance().Create("totopouet");
Assert.IsNull(person);
}
[TestMethod]
[ExpectedException(typeof(ArgumentException))]
public void Create_WithEmptyString()
{
IPerson person = PersonFactory.GetInstance().Create("");
}
[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void Create_WithNull()
{
IPerson person = PersonFactory.GetInstance().Create(null);
}
[TestMethod]
public void IsFactoryUnique()
{
PersonFactory factory1 = PersonFactory.GetInstance();
PersonFactory factory2 = PersonFactory.GetInstance();
Assert.AreSame(factory1, factory2);
}
4. Conclusion
On pourrait croire au premier abord que développer de cette manière prend beaucoup plus de temps. C’est totalement faux. Le temps que l’on pense perdre à écrire les tests, on le gagne en relecture, correction de bugs et autres activités ennuyeuses et chronophages. Le TDD permet également en pratique de réaliser une documentation claire, concise et toujours à jour ainsi qu’un couplage faible entre les classes (Grâce aux interfaces et aux Mocks, que nous verrons dans un autre article).
Vous l’aurez compris, le développement dirigé par les tests permet d’aborder la manière de concevoir les application d’un autre angle et facilite grandement la maintenabilité et la réutilisation du code écrit. Pour conclure, je dirai que le meilleur moyen de se faire une opinion est d’essayer, je vous conseille donc de vous faire un avis par vous même.
L’essayer, c’est l’adopter !
A vos claviers !
Très bon article sur le TDD.
Ceci dit le TDD est « indépendant » des méthodes Agile. Rien n’empêche de faire du TDD avec (Rational) Unified Process. (Même si le TDD viens de l’xP qui est Agile… mais bon :D) C’est une « méthode de programmation » qui s’intègre a n’importe quel autre type de méthodes, l’ayant moi même implémenté dans RUP avec succès. Et idem pour les langages, ai vu il n’y a pas longtemps un framwork TDD pour du C pure… oui il y a des malades. (Toutes personne ayant écrit une fois dans sa vie des unit test en C sait de quoi je parle)
Malheureusement les gens confondent souvent les différentes strates de méthodologies et TDD est considéré à tort comme une méthode « globale ». Beaucoup de petits dev(seul ou groupe de + de 5) ont tendance a adopter TDD qui est vendu comme une sorte de solution all in one alors qu’il n’y a rien sur les requirements, l’analyse et le design (pour reprendre la structure RUP) dans TDD. TDD ne touche que les couches Implémentation et Test. Et ne prend pas a compte l’évolution dans le temps non plus (Inception, Elaboration, Construction et Transition pour reprendre en RUP). Et même les méthodes XP ou Scrum qui sont plus globales, n’ont pas grand chose non plus sur ces sujets la par rapport à des methodo’s concurrentes.
C’est malheureusement le problème avec les méthodes dites « Agile », beaucoup de gens les « connaissent » et en font n’importe quoi. (Pour cela que j’aime ce vieux mammouth de RUP, au moins ceux qui parlent savent en générale ce qu’ils font)
Pour fini, merci, car cet article présent bien TDD tel qu’il est et non tel que certains voudraient le voir comme dans beaucoup d’articles, ça change