Vous avez surement déjà entendu parler des tests unitaires, du débat constant entre leur utilité et la soit disant « perte de temps » pour le développeur. Il est vrai que bien souvent, écrire un test unitaire peut s’avérer long, même si aujourd’hui ce n’est pas les outils qu’il manque avec Visual Studio 2005 et maintenant Visual Studio 2008. Dans cet article nous n’entrerons pas dans le débat concernant l’utilité des tests unitaires, mais nous allons étudier un cas de test excessivement récurrent et voir comment celui-ci peut être rendu générique.
Pour aborder cet article sereinement il faut connaître les principes de base des tests unitaires, avoir des notions sur les mécanismes de reflection, ainsi que l’utilisation des génériques.
Prenons un exemple de test très simple. Vous avez développé une application qui possède une couche d’accès aux données et qui permet d’ajouter, de modifier, de supprimer un client. Vous avez besoin de tester la couche d’accès aux données et notamment le bon fonctionnement de la récupération d’enregistrements en base de données, de la modification, ou encore de l’insertion.
De quelle manière va-t-on procéder ?
Insertion d’un Customer en base de données, puis récupération du Customer à partir de son identifiant et enfin comparaison des 2 objets. De la même manière, modification de l’objet Customer et enregistrement en base de données, puis récupération du Customer à partir de son identifiant et enfin comparaison des 2 objets. On aurait donc quelque chose du genre :
De la même manière, prenons l’exemple d’une fonctionnalité d’import/export. L’utilisateur à la possibilité d’exporter des objets de l’application dans un format propriétaire que vous avez développé. Il a également la possibilité d’importer un fichier au format propriétaire. Cette action à pour effet de reconstruire un graphe d’objets à partir du fichier.
Le test de cette fonctionnalité aurait pour objectif de tester que les objets exportés (les objets de référence) sont identiques aux objets importés (les objets à tester).
De quelle manière procéderions-nous ?
Initialisation d’une liste d’objets, exportation de ces objets, importation des objets, et enfin pour chaque objet on ferait une comparaison de chaque propriété à travers le graphe d’objets. On aurait quelque chose du genre :
Que peut-on retirer de ces exemples ?
Dans ces scénarios, la tache la plus contraignante est la comparaison des objets résultants de la fonctionnalité testée avec les objets de référence. En d’autre terme, nous écrivons un tas de ligne rien que pour comparer 2 objets de même type. De cette manière, le développeur passe du temps à écrire les assertions, ce qui peut engendrer des erreurs dans le code du test (ce serait le comble !) et donc au final on a perdu de vue l’intérêt premier de l’écriture de notre test, à savoir la fonctionnalité d’import/export. C’est la raison principale pour laquelle les développeurs n’aiment pas écrire des tests : c’est lourd à écrire.
Voyons les possibilités qui s’offrent à nous pour comparer 2 graphes d’objets avec le framework :
- Tout d’abord celle que nous venons de voir, qui est de comparer à la mano chaque propriété de l’objet : la plus fastidieuse.
- Si notre objet possède une surcharge de l’opérateur == et qui effectue l’opération que l’on désire, ou encore s’il implémente l’interface IComparer ou IComparable, qui compare toutes les propriétés comme nous le voulons alors ça résout le problème. Mais ne rêvons pas trop, il est rare d’avoir implémenté ce genre de chose pour tous nos objets.
- Une autre possibilité, celle que nous allons mettre en œuvre dans la suite de cet article, qui est d’utiliser la puissance de la reflection pour écrire une méthode générique et réutilisable pour tous les tests de comparaison d’objets.
Nous savons que les mécanismes de reflection sont couteux en performances. Cependant, dans les tests unitaires nous ne recherchons pas la performance, mais la fiabilité, la robustesse et la facilité à les écrire. Les mécanismes de reflection appliqués aux tests unitaires vont permettre de répondre à ces différents critères.
Par reflection, il nous suffit de parcourir les propriétés de l’objet de référence, de récupérer les valeurs associées et de stocker l’ensemble dans un dictionnaire. Il ne faut pas oublier de faire un traitement récursif dans le cas où la propriété est un type complexe (par exemple une commande possède une liste de ligne de commande). On fait la même chose pour l’objet résultant de la fonctionnalité testée. A ce moment là, nous nous retrouvons avec 2 dictionnaires, avec potentiellement les mêmes clés. Il ne reste plus qu’à comparer le contenu des 2 dictionnaires.
Reprenons l’exemple du test de l’import/export. Et voyons dans un premier de quelle manière nous voudrions écrire notre test, avec le moins d’effort possible :
En voyant ceci on remarque tout de suite la simplicité d’écriture du test. En effet, toute la complexité de validation du test a été déportée dans la classe TestHelper, de manière générique, afin d’être réutilisable pour n’importe quelle comparaison d’objet.
Dans un premier temps, voyons le contenu de la méthode Compare<T>(T reference, T aTester) :
Cette méthode récupère dans 2 dictionnaires les valeurs des propriétés de chacun des objets passés en paramètres. La présence du compteur permet d’être certain d’avoir une clé unique par élément ajouté dans les dictionnaires (les clés des dictionnaires sont obtenues en concaténant le nom de la propriété et du compteur). Une fois que les 2 dictionnaires sont constitués, il suffit de les comparer grâce à la méthode CompareDictionnary<K,V> décrite ci-dessous :
Cette méthode vérifie que tous les éléments présents dans le dictionnaire de référence sont présents dans le 2ème dictionnaire et que les valeurs sont identiques. La méthode concatène toutes les différences et retourne ce résultat, ce qui nous permettra de l’afficher dans le rapport d’erreurs du test.
Nous allons maintenant regarder le contenu de la méthode GetPropertiesValues(object o) car c’est ici que tout se passe :
Tout d’abord nous ne traitons pas l’objet si c’est une liste ou si ce n’est pas un type complexe. En effet, d’une part si l’objet à comparer est de type entier ou string, alors pas besoin de faire appel à ce mécanisme. D’autre part, si l’objet à comparer est une liste, alors il suffit de faire appel au mécanisme pour chaque élément de la liste.
Une fois que l’on est certain de ne pas être dans l’un de ces cas, alors on récupère la liste des propriétés et nous la parcourons. Si la propriété est un type valeur ou string, alors on peut ajouter dans le dictionnaire la valeur de la propriété. Si c’est un type complexe, on commence par récupérer la valeur. Ensuite plusieurs cas se présentent :
- Soit la valeur est une liste, et dans ce cas, pour chaque élément on ajoute sa valeur dans le dictionnaire si c’est un type valeur ou string, ou alors on fait un appel récursif à la méthode et nous ajoutons dans le dictionnaire les éléments résultants de l’appel récursif grâce à la méthode AddRange.
- Dans le cas contraire, nous faisons tout simplement un appel récursif avec la valeur de la propriété et là aussi nous ajoutons dans le dictionnaire les éléments résultants de l’appel récursif grâce à la méthode AddRange.
- Pour information, il faudrait également traiter le cas où la propriété est de type IDictionary.
Voici le code de la méthode IsComplexType(Type t) :
La méthode AddPropertyValue(PropertyInfo prop, object val, Dictionary<string, string> dico) ci-dessous permet d’ajouter une clé unique dans le dictionnaire, composée du nom de la propriété et du compteur, et pour cette clé on ajoute la valeur de la propriété sous forme de chaine de caractères :
La méthode AddRange reprend le même principe que la méthode AddRange des types List<T>. Elle ajoute dans un dictionnaire les éléments d’un autre dictionnaire :
Il ne reste plus qu’une chose à définir. Dans la méthode GetPropertiesValues, pour chaque propriété nous faisons appel à la méthode MatchProperty en lui passant l’objet PropertyInfo courant. Cette méthode permet de savoir si pour une propriété donnée nous voulons ou non qu’elle soit traitée.
- La première raison est la suivante : les propriétés pour lesquelles il n’existe pas d’accesseur get ne peuvent pas être traitées.
- La seconde raison : bien souvent, lorsqu’on écrit un test et que nous comparons 2 objets, nous ne comparons pas certaines propriétés. Par exemple dans notre cas, nous ne voulons pas tester l’égalité des identifiants. Pour un test de base de données, insertion, mise à jour… nous ne voudrions pas tester l’égalité des dates de modification des objets par exemple. Donc cette méthode doit être développée selon les besoins du test.
Dans le code que nous avons écrit, l’appel à la méthode MatchProperty est statique et appellera toujours cette même implémentation. On pourrait se dire que pour chaque test que l’on écrit, la condition codée dans la méthode MatchProperty est différente. Nous pourrions très facilement y remédier en utilisant un délégué. Il suffirait d’implémenter, pour un test donné, la méthode MatchProperty adéquate et d’ajouter un paramètre de type delegate qui pointerait sur la méthode MatchProperty précédemment développée.
En quelques lignes de codes, nous venons donc de créer une méthode qui va simplifier l’écriture de beaucoup de tests. Ceci permet au développeur de se recentrer sur la problématique de base qui est la fonctionnalité à tester et non l’écriture du code du test.
Dans mon prochain article, nous reprendrons ce code que nous porterons sur le framework 3.5. Le but sera de découvrir quelques nouveautés de C# 3 que nous mettrons en pratique en refactorant le maximum de code de cette solution.