Les tests unitaires en pratique par Patrick Smacchia

Depuis quelques années, la crème des experts mondiaux dans le développement logiciel (John Vlissides, Martin Fowler, Erich Gamma, Grady Booch, Kent Beck…) encourage les développeurs à écrire des tests unitaires destinés à tester leurs classes, avant même que celles-ci soient développées. Cette pratique est appelé Test Driven Development (TDD) ou aussi Test First Development (TFD). Dans cet article, nous allons commencer par expliquer comment mettre en œuvre les principes du TDD sous .NET et quels sont les bénéfices que l’on peut espérer en retirer. Dans un second temps, nous nous appliquerons à exhiber les problèmes rencontrés dans la pratique du TDD ainsi que leurs solutions.

Pré-Requis 1

Introduction au Test Driven Development (TDD) 1

Quel est le principe du TDD ? 2

Qu’est ce qu’un test unitaire ? 2

A quoi servent les test unitaires? 2

Un premier exemple avec l’outil NUnit 2

Les bénéfices évidents 6

Un exemple plus réaliste illustrant la notion d’objet Mock 6

Des bénéfices moins évidents mais très intéressants 8

Les problèmes rencontrés en pratique. 9

Préserver l’encapsulation grâce à la réflexion. 9

Où doit-on garder le code des tests unitaires et des classes mock? 11

L’explosion combinatoire des valeurs des entrées 11

La couverture du code. 12

Tester le code d’une classe abstraite. 13

Les problèmes posés par un environnement multi-threaded. 13

Tester le code impliquant des accès BD. 14

Tester le code impliquant des accès distants 16

Tester le code d’une interfaces graphiques utilisateurs (riches ou légères) 17

Tests unitaires vs. Tests de recette. 18

Adopter les principes TDD sur un projet existant 19

Les coûts des tests unitaires 20

Conclusion. 21

 

Pré-Requis

Cet article ne requiert aucune connaissance technique poussée particulière. Il faut juste que vous ayez déjà pratiqué un minimum dans un langage objet quelconque.

Introduction au Test Driven Development (TDD)

Quel est le principe du TDD ?

Le principe du TDD est très simple : le développeur doit rédiger un ensemble de test unitaires pour chaque classe, avant même que le squelette de celle-ci n’ait été écrit. Cela explique l’appellation Test-First.

Qu’est ce qu’un test unitaire ?

Dans la définition du principe du TDD, seul le terme test unitaire peut éventuellement prêter à confusion. Un test unitaire est un bout de code qui provoque l’exécution d’un autre bout de code et qui en analyse le résultat. Les caractéristiques d’un test unitaire sont les suivantes:

·        Automatique : Un test unitaire s’exécute automatiquement à une certaine étape du cycle du développement, en général juste après la compilation du composant qui contient le code à tester. Cet automatisme se retrouve aussi dans la production des diagnostics des éventuels problèmes détectés.

·        Répétable : Un test unitaire est indépendant de l’environnement dans lequel il est exécuté. Il peut être répété à souhaits sur n’importe quelle machine de développement, par n’importe quel personne impliquée dans le développement.

·        Disponible : Un test unitaire a la même disponibilité que le code source qu’il teste. Si vous avez accès à une partie du code source, même qu’en lecture, vous devez donc être capable d’exécuter le(s) test(s) unitaire(s) associé(s).

En pratique un test unitaire est une méthode d’une classe. Il n’y a pas nécessairement une bijection entre les classes des tests unitaires et les classes de l’application. Cependant, si une classe à tester admet plus d’une classe de tests unitaires, il est probable qu’elle ait trop de responsabilités et que sa conception soit à revoir (principe de la responsabilité unique d’une classe). En revanche, il est courant qu’une même classe de test unitaire teste le code de plusieurs classes d’une application. Dans ce cas, il est souhaitable que ces classes soient dans le même composant (principe de la réutilisation commune).

A quoi servent les test unitaires?

Tout développeur sait bien que plus un bug est détecté tôt dans le cycle du développement, moins il faudra d’énergie pour le corriger. Une fois le bug identifié et reproduit, sa correction prend en général quelques minutes au développeur qui en est responsable. Ainsi, la quasi-totalité de l’énergie nécessaire pour venir à bout d’un bug est consommée dans la détection du bug, sa reproduction ainsi que dans le déploiement du correctif associé. Dans ce contexte, les tests unitaires sont les meilleures candidats pour aider un développeur à détecter et à corriger les bugs dans son code, avant même que ceux-ci aient été sauvegardés dans la base de code commune à l’équipe.

Tout cela sonne bien marketing alors vite, passons au concret.

Un premier exemple avec l’outil NUnit

L’outil de choix à l’heure actuelle pour pratiquer le TDD sur vos projets .NET se nomme NUnit. Le plug-in VSNUnit permet d’utiliser NUnit directement à partir de Visual Studio .NET. Il est regrettable que Microsoft ne propose aucun équivalent de NUnit, et (à ma connaissance) ne projette pas d’en fournir un.

NUnit se présente sous la forme d’une DLL managée nunit.framework.dll que vous devez référencer à partir des assemblages contenant le code des tests unitaires. L’outil NUnit comprend aussi deux exécutables permettant d’exécuter les tests unitaires, simplement en spécifiant le nom des assemblages contenant le code des tests unitaires. nunit-gui.exe est un exécutable pourvu d’une interface graphique évoluée alors que nunit-console.exe et un exécutable en mode console, très pratique pour être lancé à partir d’un script. Les copies d’écran de cet article sont réalisées à partir de VSNUnit.

Voici un bref aperçu du fonctionnement de NUnit :

·        NUnit sait qu’une classe contient des tests unitaires car elle est marquée avec l’attribut NUnit.Framework.TestFixtureAttribute. Dans une telle classe, un test unitaire est une méthode publique non statique marquée avec l’attribut NUnit.Framework.TestAttribute. A chaque exécution des tests unitaires, NUnit instancie les classes marquées avec TestFixtureAttribute et exécute les tests unitaires un à un, dans leur ordre de déclaration dans la classe. Notez que le nom d’une méthode marquée avec l’attribut NUnit.Framework.TestAttribute constitue aussi le nom du test unitaire. Vous devez donc porter une certaine attention à ce nom. Notez aussi que NUnit reconnaît et exécute les tests unitaires grâce au mécanisme de réflexion.

·        Le code d’un test unitaire contient en général l’appel aux méthodes des classes à tester ainsi que des assertions. En effet, NUnit propose la classe NUnit.Framework.Assert qui présente les méthodes IsTrue(), IsFalse(), IsNull(), IsNotNull(), ReferenceEqual(), Equals(), AreEqual(), AreSame(), Fail(). Un test unitaire est considéré comme concluant si durant son exécution aucune assertion n’est violée et aucune exception n’est lancée et non rattrapée.

·        Comme on peut aussi vouloir tester si une exception est bien lancée lors de l’exécution d’un test unitaire, l’attribut NUnit.Framework.TestAttribute peut être paramétré avec l’attribut NUnit.Framework.ExpectedExceptionAttribute qui prend en argument un type d’exception. Le test est alors considéré comme concluant seulement si une exception de ce type est lancée et non rattrapée durant son exécution.

·        Le principe du TDD étant d’écrire les tests unitaires avant le code à tester, il y a forcément un laps de temps durant lequel des tests unitaires sont supposés tester du code non encore écrit. Pour ne pas être gêné par les échecs de tels tests, l’attribut NUnit.Framework.TestAttribute peut être paramétré avec l’attribut NUnit.Framework.IgnoreAttribute qui prend en argument une chaîne de caractères censée décrire la future condition de validité du test.

·        Bien souvent, les tests unitaires contenus dans une même classe ont du code d’initialisation et de finalisation similaire. NUnit vous offre la possibilité de factoriser le code de l’initialisation dans une méthode publique non statique marquée avec l’attribut NUnit.Framework.SetUpAttribute. Bien évidemment cette méthode est appelée automatiquement avant toute exécution des tests unitaires. Le code de finalisation peut être factorisé dans une méthode publique non statique marquée avec l’attribut NUnit.Framework.TearDownAttribute. Cette méthode est naturellement appelée automatiquement après les exécutions de tous les tests unitaires de la classe concernée.

·        Enfin, nous souhaitons souligner que vous avez le choix de garder le code de vos tests unitaires dans le même composant que le code à tester, ou dans un autre composant. Nous discutons un peu plus loin des différentes motivations guidant ce choix.

Nous sommes maintenant fin prêt pour illustrer toutes ces possibilités par un exemple concret. Supposons que vous deviez développer une classe qui valide des adresses e-mail. Il y a en fait deux types de validations : la validation syntaxique qui sera faite grâce à une expression régulière et la validité de l’existence de l’adresse e-mail, en questionnant directement le serveur par le protocole SMTP.

Ne vous inquiétez pas, il ne sera pas nécessaire de rentrer dans les détails obscurs du protocole SMTP puisque Franklin.NET fournit ce code tout prêt (http://www.franklins.net/dotnet/MailChecker.zip). De plus, nous allons nous intéresser dans un premier temps à tester unitairement le code de la validité syntaxique d’une adresse e-mail. Pour cela il faut prévoir :

·        un test unitaire nommé TestEmailSyntaxArobas pour tester les problèmes syntaxiques liés au @,

·        un test unitaire nommé TestEmailSyntaxExtension pour tester les problèmes syntaxiques liés à l’extension (.com ; .org ; .fr …),

·        un test unitaire nommé TestEmailSyntaxEntreeNulle pour tester le fait que le code de la validité syntaxique est supposé renvoyé une exception de type NullReferenceException si on lui fournit une référence nulle en entrée (n’oubliez pas que le type String est un type référence).

Puisque chacun de ces tests unitaires nécessite une instance de la classe EmailAddressValidator, il est judicieux de factoriser le code de la création d’une telle instance dans une méthode marquée avec l’attribut NUnit.Framework.SetUpAttribute. Notez aussi que nous prévoyons un test unitaire nommé TestEmailExistence qui testera le code de test de l’existence de l’adresse e-mail. Voici le code :

using System;

using NUnit.Framework;

using System.Collections;

using System.Text.RegularExpressions; 

 

//----------------------- la classe à tester -----------------------

class EmailAddressValidator

{

   public bool CheckEmailAddressSyntax(string sEmailAddress)

   {

      if(sEmailAddress == null ) throw new NullReferenceException();

      return Regex.IsMatch(sEmailAddress, @"^[\w\.\-]+@[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]{1,})*(\.[a-zA-Z]{2,3}){1,2}$");

   }

   public bool CheckEmailAddressExistence(string sEmailAddress)

   {

      // TODO à implémenter

      return false;

   }

}

 

//----------------------- les tests unitaires -----------------------

[TestFixture]

public class TestEmailValidator

{

   EmailAddressValidator EAV;

 

   [SetUp]

   public void SetUp()

   {

      EAV = new EmailAddressValidator ();

   }

   [TearDown]

   public void TearDown()

   {

      // rien à faire ici

   }

 

   [Test]

   public void TestEmailSyntaxArobas()

   {

      Assert.IsTrue(EAV.CheckEmailAddressSyntax ("Nom@entreprise.com"));

      Assert.IsFalse(EAV.CheckEmailAddressSyntax ("Nom@@entreprise.com"));

      Assert.IsFalse(EAV.CheckEmailAddressSyntax ("Nomentreprise.com"));

      Assert.IsFalse(EAV.CheckEmailAddressSyntax ("@entreprise.com"));

      Assert.IsFalse(EAV.CheckEmailAddressSyntax ("Nom@"));

   }

 

   [Test]

   public void TestEmailSyntaxExtension()

   {

      Assert.IsTrue(EAV.CheckEmailAddressSyntax ("Nom@entreprise.com"));

      Assert.IsFalse(EAV.CheckEmailAddressSyntax ("Nom@entreprise.comcom"));

      Assert.IsFalse(EAV.CheckEmailAddressSyntax ("Nom@entreprise.c"));

      Assert.IsFalse(EAV.CheckEmailAddressSyntax ("Nom@entreprise."));

   }

 

   [Test,ExpectedException(typeof(NullReferenceException))]

   public void TestEmailSyntaxEntreeNulle()

   {

      EAV.CheckEmailAddressSyntax (null);