Améliorer la qualité des tests unitaires grâce à PEX

12 février 2009 15:00 par Nicolas Van Vooren
Logo PEX PEX (Program EXplorer) est un programme développé par Microsoft Research, simple d’utilisation, qui s’intègre à Visual Studio ; il a pour objectif l’exploration des méthodes de vos classes, selon différentes approches :
• génération “arbitraire” de données ;
• évaluation des chemins conditionnels (path condition) ;
• évaluation des conditions de retour (flipped condition).

Ce programme est donc particulièrement intéressant lorsque, face à une bibliothèque de classes, on hésite sur les tests unitaires à mettre en œuvre pour couvrir ses besoins. En effet, non seulement PEX effectue un travail d’exploration et génère un rapport mettant en lumière d’éventuels dysfonctionnements d’exécution mais il peut également proposer des actions correctives et générer des tests unitaires paramétrables (PUT) qui assurent la couverture du code des méthodes à 100 % ; ces tests sont bien entendu compatibles avec MSTEST (ou d’autres frameworks de test) et peuvent donc être associés à une stratégie de build.

 

Pour comprendre l’intérêt de PEX, je vous propose d’illustrer son fonctionnement à travers un petit cas concret. Le scénario est le suivant : il s’agit de tester un algorithme de conversion d'un nombre romain en nombre arabe (pour plus d'informations sur le principe de construction des nombres romains voir : http://fr.wikipedia.org/wiki/Num%C3%A9ration_romaine). Nous mettons en place une démarche classique de test unitaire :
1) Vérification de la couverture de code à travers deux tests unitaires simples.
2) Adaptation des tests unitaires en associant une source de données à l'une des méthodes.
3) Mise en œuvre de PEX et analyse des résultats.

La version initiale du programme est la suivante :

   1: using System;
   2: using System.Text;
   3:  
   4: namespace Tekigo.Demo.ConvertirNombreRomain
   5: {
   6:     public static class NombreRomain
   7:     {
   8:         /// <summary>
   9:         /// Représenter la séquence des chiffres romains disponibles
  10:         /// </summary>
  11:         /// <remarks>l'ordre est important</remarks>
  12:         private static string _chiffresRomain = "MDCLXVI";
  13:  
  14:         /// <summary>
  15:         /// Convertir un nombre romain en nombre arabe
  16:         /// </summary>
  17:         /// <param name="nombre">nombre romain</param>
  18:         /// <returns>nombre arabe (format entier)</returns>
  19:         public static int ConvertirEnNombreArabe(string nombre)
  20:         {
  21:             int nombreArabe = 0;
  22:             int position = 0;
  23:             int positionFin = nombre.Length - 1;
  24:             int ordre;
  25:  
  26:             //Chaîne vide ?
  27:             if (nombre.Trim() == String.Empty) throw new ArgumentException("Nombre non défini.");
  28:  
  29:             //Convertir la chaîne en majuscule
  30:             nombre = nombre.ToUpper();
  31:  
  32:             //Evaluer les caractères définis dans le nombre romain
  33:             foreach (char c in nombre)
  34:             {
  35:                 position = nombre.IndexOf(c, position);
  36:                 ordre = _chiffresRomain.IndexOf(c);
  37:  
  38:                 switch (c)
  39:                 {
  40:                     case 'M':
  41:                         if ( (position == positionFin) || (ordre <= _chiffresRomain.IndexOf(nombre[position + 1])))
  42:                             nombreArabe += 1000;
  43:                         else
  44:                         {
  45:                             //cas de figure impossible
  46:                             throw new ApplicationException("Nombre romain invalide.");
  47:                         }
  48:                         break;
  49:                     case 'D':
  50:                         if (position == positionFin || ordre <= _chiffresRomain.IndexOf(nombre[position + 1]))
  51:                             nombreArabe += 500;
  52:                         else
  53:                         {
  54:                           //cas de figure impossible
  55:                           throw new ApplicationException("Nombre romain invalide.");
  56:                         }
  57:                         break;
  58:                     case 'C':
  59:                         if (position == positionFin || ordre <= _chiffresRomain.IndexOf(nombre[position + 1]))
  60:                             nombreArabe += 100;
  61:                         else
  62:                             nombreArabe -= 100;
  63:                         break;
  64:                     case 'L':
  65:                         if (position == positionFin || ordre <= _chiffresRomain.IndexOf(nombre[position + 1]))
  66:                             nombreArabe += 50;
  67:                         else
  68:                             nombreArabe -= 50;
  69:                         break;
  70:                     case 'X':
  71:                         if (position == positionFin || ordre <= _chiffresRomain.IndexOf(nombre[position + 1]))
  72:                             nombreArabe += 10;
  73:                         else
  74:                             nombreArabe -= 10;
  75:                         break;
  76:                     case 'V':
  77:                         if (position == positionFin || ordre <= _chiffresRomain.IndexOf(nombre[position + 1]))
  78:                             nombreArabe += 5;
  79:                         else
  80:                             nombreArabe -= 5;
  81:                         break;
  82:                     case 'I':
  83:                         if (position == positionFin || ordre <= _chiffresRomain.IndexOf(nombre[position + 1]))
  84:                             nombreArabe += 1;
  85:                         else
  86:                             nombreArabe -= 1;
  87:                         break;
  88:                     default:
  89:                         throw new ApplicationException("Nombre romain invalide.");
  90:                 }
  91:             }
  92:  
  93:             //Retourner la valeur calculée
  94:             return nombreArabe;
  95:         }
  96:     }
  97: }

La classe de test unitaire contient initialement deux méthodes :

   1: using System;
   2: using Tekigo.Demo.ConvertirNombreRomain;
   3: using Microsoft.VisualStudio.TestTools.UnitTesting;
   4:  
   5: namespace Tekigo.Demo.TestProjectConversion
   6: {
   7:     [TestClass()]
   8:     public class NombreRomainTest
   9:     {
  10:         private TestContext testContextInstance;
  11:         public TestContext TestContext
  12:         {
  13:           get { return testContextInstance; }
  14:           set { testContextInstance = value; }
  15:         }
  16:  
  17:         /// <summary>
  18:         ///Test n°1 pour ConvertirEnNombreArabe, avec une valeur
  19:         ///</summary>
  20:         [TestMethod()]
  21:         public void ConvertirEnNombreArabeTest()
  22:         {
  23:             string nombre = "MCMLXXXIV";
  24:             int expected = 1984;
  25:             int actual;
  26:             actual = NombreRomain.ConvertirEnNombreArabe(nombre);
  27:             Assert.AreEqual(expected, actual);
  28:         }
  29:  
  30:         /// <summary>
  31:         ///Test n°2 pour ConvertirEnNombreArabe, avec levée d'exception
  32:         ///</summary>
  33:         [TestMethod(), ExpectedException(typeof(ArgumentException), "Nombre non défini.")]
  34:         public void ConvertirEnNombreArabeTestWithException()
  35:         {
  36:             string nombre = "";
  37:             int actual;
  38:             actual = NombreRomain.ConvertirEnNombreArabe(nombre);
  39:         }
  40:     }
  41: }

 

Si l’on vérifie la couverture de code de ces tests, on s’aperçoit qu’elle est insuffisante (un peu inférieure à 70 %).

Couverture code

Pour améliorer cette couverture, la méthode ConvertirEnNombreArabeTest va maintenant être pilotée par une source de données (un fichier XML contenant une série de valeurs à convertir et le résultat attendu).

   1: /// <summary>
   2: ///Test n°1 pour ConvertirEnNombreArabe, avec une source de données
   3: ///</summary>
   4: [DataSource("Microsoft.VisualStudio.TestTools.DataSource.XML", "|DataDirectory|\\romain.xml", "valeur", DataAccessMethod.Sequential), DeploymentItem("TestProjectConversion\\romain.xml"), TestMethod()]
   5: public void ConvertirEnNombreArabeTest()
   6: {
   7:     string nombre = testContextInstance.DataRow["nombre"].ToString();
   8:     int expected = Convert.ToInt32(testContextInstance.DataRow["attendu"]);
   9:     int actual;
  10:     actual = NombreRomain.ConvertirEnNombreArabe(nombre);
  11:     Assert.AreEqual(expected, actual);
  12: }

La couverture est effectivement améliorée (presque 90 %). En étudiant les blocs non couverts, on détecte deux cas qui illustrent une erreur dans notre algorithme, et qu’il convient de corriger par une levée d’exception : 
cover2

Cette correction n’augmente pas pour autant la couverture. Que faire alors pour atteindre les 100 % ? La solution peut passer par un accroissement du jeu de données mais, dans cette approche, suis-je capable de déterminer toutes les valeurs nécessaires ? Et, de manière plus générale, mes tests sont-ils suffisants ? C’est ici que l’utilisation de PEX prend tout son sens.

Utilisons donc PEX pour explorer notre méthode de conversion ; il suffit pour cela de lancer le programme d’exploration sur la méthode :

Lancement PEX

Au fur et à mesure de son exploration, PEX dresse un tableau des cas de test qu’il a identifié :

Exploration PEX

Analysons, lorsque l’exploration est terminée, les résultats disponibles :

pex3

1) 226 exécutions de notre méthode ont été effectuées.
2) 13 tests sont considérés comme ayant échoué.
3) 4 limites ont été atteintes (il s’agit notamment de temps d’exécution jugés trop longs).
4) pour chaque test conservé, il est possible de voir son détail.

On peut aussi visionner un rapport au format HTML à partir du menu “Views/Open Report” (au préalable, il faut activer l’option Reports de la section Pex dans les options de configuration de Visual Studio). Ce rapport vous donne accès à différentes données : couverture, exceptions levées, limites atteintes, environnement d’exécution, etc.

Rapport PEX

Parmi les éléments mis en avant par l’exploration, on note une NullReferenceException (cas assez classique) du fait de l’absence de test du paramètre string passé à la méthode. PEX nous propose une action corrective qui, si elle est validée, vient directement s’insérer dans le code.

Correction

Certains cas de test ont fait apparaître des exceptions (en gras dans le tableau) qui appellent soit une correction soit une acceptation par PEX ; ainsi, elles ne seront plus considérées comme des anomalies par l’explorateur.

AllowException

A ce stade, vous pouvez décider de conserver le ou les cas de test qui vous paraissent compléter utilement vos propres tests unitaires (voire même les remplacer). Là encore, l’opération est très simple puisqu’il suffit de sélectionner ces cas dans le tableau des résultats d’exploration puis que cliquer sur l’option Save. Après validation, PEX crée son propre projet de test la solution.

Save tests

Chaque cas de test est stocké, sous forme d’un test unitaire simple, dans le fichier .g.cs. Ils peuvent désormais être intégré au processus de test ou de build (intégration à faire par l’intermédiaire d’une liste de tests).

L’intégration au suivi d’activités dans Team System n’a pas été oubliée puisqu’il est possible d’associer un résultat d’exploration à un élément de travail :

WorkItem

En conclusion, il apparaît clairement que l’arrivée de PEX offre des perspectives très intéressantes dans l’amélioration de la qualité des tests unitaires par sa capacité d’exploration (y compris sur des méthodes manipulant des objets, des interfaces, etc.). Son environnement d’exécution est paramétrable, notamment sur la gestion des “limites” (boundaries), un aspect particulièrement nécessaire pour tenir compte des phénomènes liés aux boucles par exemple, et éviter ainsi des explorations interminables. Bien que parfaitement intégré à Visual Studio, PEX peut aussi être lancé en ligne de commande.

Notons enfin que PEX a quelques limitations :
• exploration uniquement sur du code managé (il s’appuie sur l’IL) ;
• pas de support d’exécution concurrentielle (multi-threading) ;
• pas d’exploration sur des programmes non déterministes (ça peut se comprendre !) ;
• le code généré pour les tests est écrit uniquement en C#.

Reste cependant un point à ne pas négliger (principe aussi applicable avec n’importe quel framework de test), ce n’est pas parce qu’un cas de test produit par PEX s’exécute correctement que ce dernier ne correspond pas à une anomalie de l’algorithme ! L’exemple ci-dessous montre en effet deux résultats “valides” alors que les valeurs en entrée sont des formes d’écriture impossibles en réalité. La vigilance reste donc de mise.

pex8

Adresse de téléchargement : PEX
Extensions PEX pour d’autres frameworks de test : http://www.codeplex.com/Pex