Qu'est-ce qu'un test unitaire ?

nunit.jpgIl s'agit d'un mini programme (en génral, une méthode ou fonction) dont le rôle est de valider un certain comportement attendu d'une portion de code (une ou plusieurs méthodes d'une API par exemple).
Concrètement, un test simule un cas d'utilisation de la portion de code à tester. Et il définit une ou plusieurs assertions. Le test réussit si toutes les assertions qu'il définit sont vérifiées. Il échoue si une assertion n'est pas vérifiée ou si une exception est levée (si celle-ci ne fait pas partie d'une assertion, auquel cas l'exception serait une condition du succès).

Evidemment, l'idée des tests unitaires est de partir des méthodes de base du projet. Chaque test est alors extrêmement simple car il valide une à une les principales méthodes. Puis, au fur et à mesure, on met en place des tests de plus haut niveau jusqu'à tester les cas d'utilisation d'une application, par exemple, à la place d'un utilisateur. Ces derniers sont évidemment les plus long et difficiles à mettre en place.
Personnellement, je considère que les tests du niveau utilisateur (les couches les plus hautes) sont généralement trop long et pas forcément requis. Si un tel niveau de validation est nécessaire dans un projet professionnel, la ressource humaine doit avoir été prévue et des personnes sont spécialisées en la matière. D'ailleurs, on ne parle plus de tests unitaires mais de tests de recette. Ces derniers ne sont pas l'objet de ce billet.
Les tests les plus importants étant ceux des fonctions de base. Par exemple, pour une librairie chargée de convertir des données, on validera toutes les formes de données qu'est censé pouvoir traiter la librairie.

Le fait que les tests les plus bas niveau sont (en général) très simples et très rapides à mettre en place, et que ce sont les plus importants, devrait convaincre tout développeur de les utiliser, surtout après avoir pris conscience de leurs avantages. C'est ce que je tente d'expliquer ci-dessous.

Précision sur l'efficacité des tests unitaires

Bien que simples à mettre en place, les tests unitaires doivent être le résultat d'un minimum de réflexion pour montrer toute leur efficacité. Il faut bien avoir conscience que leur utilisation réfléchie permet d'éliminer un très grand nombre d'erreurs et de bugs mais jamais d'en garantir une absence totale. Cela est purement impossible (sauf pour des programmes excessivement simples pour lesquels les tests perdraient presque leur intérêt!).
Après un petit nombre de tests et lorsqu'on met en évidence ses propres erreurs grâces aux tests, on acquière rapidement les bons réflexes pour créer des tests efficaces.

Petite anecdote personnelle

Pour ma part, j'ai beaucoup travaillé sur des protocoles de communication (TCP/IP, RS-232, données binaires, traitements de bits, manipulation de différents types de valeurs: ushort, byte, double, etc.). Je sais que dans le cas d'une boucle chargée de parser une trame binaire (extraire un certain nombre de valeurs souvent interdépendantes), je devrais tester cette boucle en lui fournissant un lot de données aléatoires. Je sais cela depuis que j'ai eu un cas où le test échouait arbitrairement (et rarement). J'ai donc compris que dans ce cas, non seulement il fallait fournir des données aléatoires à la méthode, mais qu'en plus il fallait faire tourner le test de multiples fois pour maximiser les éventuels cas d'erreur. L'erreur venait du fait que je récupérais une information dans cette trame (la taille de la trame!) et que je partais du principe que la valeur récupérée dans cette portion serait toujours positive. Plus exactement, non: l'erreur venait du fait que je stockais cette information supposée positive dans un type signé: short. Dans le cas où les données reçues respectaient le format de trame spécifié, tout fonctionnait. Sauf qu'avec des données aléatoires, cet axiome se révélait être la source d'un bug qu'il aurait été très long à diagnostiquer (du fait de sa nature apparemment imprévisible). En effet, ceux qui jouent avec les bits savent de quoi je parle. Sinon, il suffit de prendre la calculatrice de Windows pour le voir:

10100000 en base 1 = 160 en base 10.
01100000 en base 1 = -160 en base 10 (sur un octet signé) OU 96 (sur un octet non signé). 

Dans mon cas, il a fallut remplacer le type de la variable short par ushort. En effet, comme il s'agissait du nombre d'octets attendus dans la trame complète, j'effectuais une condition du type si le nombre d'octets reçus est supérieur ou égal à la taille de la trame [...]; ce qui est toujours vrai avec une taille négative. La boucle levait alors une exception de type index en dehors de la plage car je me basais sur cette valeur (négative!) pour extraire la trame d'un tableau d'octets.

Avantages des tests unitaires

Pour le développeur

1. Gain de temps dans le développement

Contrairement à ce qu'on peut penser, cela est une évidence pour les tests les plus simples (sur les méthodes de base): les erreurs sont détectées plus rapidement et sont donc corrigées en l'espace de quelques secondes ou minutes. Pour les tests plus longs à mettre en place, le gain de temps se fait sentir lorsque ces tests permettent de corriger des erreurs qui seraient apparues bien plus tard dans le processus de développement. La correction aurait nécessité un temps nécessairement plus long.

Par ailleurs, nombre de développeurs sur des langages dits de haut niveau comme VB ont pour raccourci de développer une interface graphique pour tester une fonctionnalité. Le test unitaire permet de faire précisément la même chose, et il est incomparablement plus rapide à coder.

2. Valeur illustrative

Coder les tests qui valident une librairie, par exemple, c'est coder les exemples du bon usage (et éventuellement du mauvais usage) de cette librairie. Ces exemples peuvent s'avérer être une bonne source de référence pour des développeurs qui réutiliseront cette librairie ou même pour celui qui a codé les tests et qui revient sur son code quelques années plus tard.
Ceci dit, il ne faut pas tomber dans le travers de produire des tests à pure valeur d'exemple, en commentant (dans l'entête du test) l'objectif de la méthode testée. Ce n'est pas dans le test unitaire qu'il faut faire cela mais dans la classe de la librairie.

Pour l'utilisateur

1. Fiabilité de son produit

Il ne le sait peut-être pas, son produit a été testé, et c'est un gage de fiabilité. Plus le produit sera sujet à des mises à jour, plus ce sera évident. L'utilisateur ne devrait pas avoir à se sentir bêta-testeur.

2. Cas d'utilisation testés

Comme je le disais en introduction, l'automatisation de ces tests est généralement longueà mettre en place et ce n'est pas vraiment le rôle du développeur du programme de les réaliser. Néanmoins, les tests de plus haut niveau permettent de garantir le déterminisme d'un programme dans une situation donnée, si cette dernière a fait l'objet d'un test. S'il n'est pas possible de garantir l'absence totale de bug dans un programme, il est ainsi possible de garantir (en théorie) l'absence de bugs pour un scénario donné.

Dans le processus de développement

1. Evite les régressions (tests de régression)

Théoriquement, dès qu'on met à jour le code d'un programme, il y a toujours la possibilité plus ou moins évidente de créer des effets de bord. Et bien sûr, le nouveau code doit être lui même validé. Les tests unitaires permettent tout cela: les anciens tests valident une non régression; les nouveaux valident le nouveau code.
Ainsi, lors d'une mise à jour, la validation du projet ne prend pas plus de temps que la validation de la modification. Ca, c'est la théorie. Si les tests unitaires sont bien conçus, la pratique ne devrait pas être éloignée de cette théorie.

2. Permet un suivi de l'état d'avancement du projet

Cela est vrai si on applique les principes du TDD à la lettre. Ce paradigme dicte de coder tous les tests avant même de coder l'implémentation concrète. Dans ce cas, l'outil qui lance les tests permet de voir l'état d'avancement du projet (les tests échoués par rapport aux tests réussis). Un article de Patrick Smacchia (l'auteur de l'excellent libre Pratique de .NET 2.0 et de C# 2.0) met en évidence cet avantage.

Voilà, j'espère vous avoir convaincu à vous essayer aux tests unitaires. Les prochains billets vous aideront à sauter le pas.

Pour le début: Commencer avec NUnit

A voir aussi

http://www.dotnetguru.org/articles/dossiers/testunitaires/UnitTest.htm
http://www.c2.com/cgi/wiki