Conception de tests unitaires avec JUnit

Nos logiciels deviennent de plus en plus complexes et importants. Et malheureusement, aucun d'entre eux ne peut se vanter de ne comporter aucun bug. Il incombe au programmeur la délicate tâche de tester son code avant de le distribuer.

Article lu   fois.

L'auteur

Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Tous les programmeurs le savent, il est d'une importance vitale de rédiger des tests pour attester la validité de leur code. Malheureusement très peu le font. Il s'agit d'un exercice très difficile qui requiert autant d'attention que la phase de spécification. C'est durant cette dernière que doit être, dans le cas idéal, dressée la liste des tests unitaires à implémenter. Ainsi, même lorsque les développeurs pensent à les écrire, ils ne se révèlent pas toujours pertinents. Mais soyons réalistes, car nous le savons tous parfaitement, il y a peu de motivation dans l'écriture de ces tests et nous privilégions bien souvent l'aboutissement de notre travail. Écrire des tests prend du temps. Qui plus est, le bénéfice apporté n'est pas immédiat et encore moins évident. Pourtant, à long terme, la productivité de l'équipe de développement s'en trouvera grandement améliorée. Bien évidemment, votre application gagnera également en stabilité et en fiabilité.
La raison même des tests va beaucoup plus loin que la vérification de la validité du code à un instant donné. Au cours de la durée de vie d'un projet, les opérations réalisées sont susceptibles de changer, sans pour autant que les interfaces ne soient modifiées. Par exemple, nous pourrons commencer par implémenter un ensemble à l'aide d'un Vector puis de décider de n'utiliser que des tableaux d'entiers. Les tests unitaires sont là pour garantir le bon fonctionnement de votre travail à tout instant. Ils seront encore plus importants lorsque l'auteur du code aura quitté le projet. Maintenir et déboguer une application pour laquelle une batterie de tests existe sera beaucoup plus facile pour la personne qui lui succédera. Il existe plusieurs solutions pour convaincre un programmeur de rédiger des tests unitaires. Si nous laissons de côté les menaces et les promesses mielleuses, il nous reste l'utilisation d'outils appropriés, comme JUnit.

II. JUnit

Imaginé et développé en Java par Kent Beck et Erich Gamma, JUnit désigne un framework de rédaction et d'exécutions de tests unitaires. Rappelons que ces deux personnes sont respectivement auteurs des ouvrages "SmallTalk Best Pratice Patterns" et "Design Patterns : Catalogue de modèles de conception réutilisables", le fameux ouvrage du Gang Of Four.
Le but principal de JUnit est d'offrir au développeur un environnement de développement simple, le plus familier possible, et ne nécessitant qu'un travail minimal pour rédiger de nouveaux tests.
Leur écriture ne représente pas le seul intérêt d'un tel framework. Une fois ces tests mis en place, il est très important de pouvoir les exécuter et d'en vérifier les résultats très rapidement. Comme nous l'avons dit, savoir qu'un test marche à un instant donné n'est pas suffisant. Nous devons pouvoir l'exécuter plus tard.
Avant de nous intéresser à l'écriture de tests pour nos propres applications, nous allons examiner l'architecture de JUnit pour bien comprendre de quelle manière nous pourrons les rédiger. Kent Beck et Erich Gamma étant de fins connaisseurs des Design Patterns, nul ne sera surpris d'en trouver une application élégante dans leur travail. L'idée principale de JUnit est de représenter chaque test par un objet à part. Un test correspond souvent à une classe de votre programme. En d'autres termes, un test pourra être composé de plusieurs tests unitaires dont le rôle sera de valider les différentes méthodes de vos classes. La question primordiale est donc de savoir comment JUnit représente un test.
Le Schéma 1 présente le coeur de l'architecture du framework. Nous pouvons remarquer que la plus fine granularité correspond à un test, c'est-à-dire à un agrégat de tests unitaires. L'interface Test matérialise ceci. Néanmoins, le développeur n'aura jamais à s'en servir mais devra s'intéresser aux classes TestCase et TestSuite. La première désigne une concrétisation de nos tests tandis que la seconde permet de les composer. Nous pourrons donc créer une arborescence de tests pour représenter toute l'application. Cette architecture propose quelques autres points intéressants sur lesquels nous nous pencherons plus tard. Bien qu'incomplet, nous ne voyons par exemple aucune indication sur l'exécution automatique des tests, ce schéma présente la plus grande partie des fonctionnalités de JUnit que vous serez amenés à employer dans votre travail. Nous constatons ici que la caractéristique première des frameworks est respectés : le résultat doit être simple mais subvenir aux besoins vitaux de l'utilisateur.

III. Écriture d'un test

Les programmeurs ont recours à de nombreuses méthodes pour déboguer leurs applications, notamment les sorties console, l'évaluation d'expressions dans les débogueurs ou encore les assertions. Toutes ces approches ont un problème majeur. Dans les deux premiers cas, les développeurs doivent interpréter les résultats pour en tirer des conclusions tandis que dans le dernier, le programme est interrompu par une erreur. JUnit apporte une solution efficace à ces problèmes. Il nous suffit d'écrire une classe dérivant de junit.framework.TestCase et d'y ajouter nos tests. Le listing 1 présente un exemple très simple de test portant sur la classe java.io.File de la plateforme Java. Ne vous fiez pas à la simplicité de l'exemple, vos propres tests ne seront pas plus compliqués. Il vous suffira en effet de créer des méthodes publiques de type void contenant des assertions. Dans le paquetage junit.framework, TestCase implémente l'interface Test et dérive en outre de la classe Assert dont les nombreuses méthodes vous permettront de valider votre code. Celles-ci sont présentées dans le tableau 1.
Rien ne vous interdit toutefois d'utiliser plusieurs assertions pour chaque test unitaire. Le listing 2 présente un exemple permettant de vérifier le succès de la méthode clone() pour un objet de type Vector. Nous attendons de cette dernière une copie complète de l'objet et non une référence à l'objet d'origine. Notre test doit donc vérifier que les deux objets sont égaux, par l'entremise de assertEquals(), et que les références sont différentes, grâce à assertNotSame(). Si l'une de ces deux assertions échoue, nous ne disposons pas d'une véritable copie de l'objet.
Lorsque vous rédigerez vos tests unitaires, vous vous apercevrez qu'un grand nombre d'entre eux partage les mêmes objets d'exemple avec d'autres tests. Dans le cas de notre FileTest, nous pourrions écrire un testIsNotFile() en utilisant le même objet dir que celui employé dans testIsDirectory(). Ces objets "fixes" sont connus dans JUnit sous le nom de fixtures. Pour les employer, vous devrez créer une variable d'instance pour chaque fixture. Leur initialisation ne sera effectuée que dans la méthode setUp() et nulle part ailleurs. JUnit l'utilisera avant chaque exécution de test unitaire pour garantir un état stable et connu des attributs. Le listing 3 présente notre FileTest reposant sur une fixture. Si votre setUp() alloue des ressources (comme l'écriture d'un fichier sur le disque), vous pourrez les nettoyer en écrivant le code adéquat dans la méthode tearDown() qui sera invoquée après exécution de chaque test.

listing 1
Sélectionnez
import java.io.*;
import junit.framework.*;
 
public class FileTest extends TestCase
{
  public void testIsDirectory()
  {
    File dir = new File("/etc");
    assertTrue(dir.isDirectory());
  }
}
listing 2
Sélectionnez
public class VectorTest extends TestCase
{
  public void testClone()
  {
    Vector v1 = new Vector(2);
    v1.add("Test");
    v1.add("Case");
    Vector v2 = (Vector) v1.clone();
    assertNotSame(v1, v2);
    assertEquals(v1, v2);
  }
}
listing 3
Sélectionnez
public class FileTest
{
  private File dir;
 
  protected void setUp()
  {
    dir = new File("/etc");
  }
 
  public void testIsDirectory()
  {
    assertTrue(dir.isDirectory());
  }
}

Nous avons deux moyens à notre disposition pour exécuter les tests. Nous pouvons les exécuter chacun à part ou créer des suites de tests. La classe TestCase propose en effet une méthode runTest() évidemment destinée à l'exécution des différents tests d'un objet. Son utilisation place le développeur devant deux nouveaux choix : nous pouvons opter pour une exécution statique ou dynamique. La première demande plus de code mais s'avère plus sûre, tandis que la seconde désigne la solution de facilité. Une exécution dynamique se réalise ainsi :

code
Sélectionnez
TestCase tc = new VectorTest("testClone");
tc.runTest();

En plaçant ces instructions dans la méthode main() de la classe à tester ou de la classe de test, vous pourrez valider votre travail. Le principe est simple puisque vous devez juste indiquer au constructeur de la classe le nom du test unitaire à vérifier. Attention toutefois, vous ne recevrez aucun message particulier indiquant la réussite du test. Seules des exceptions pourront attester un échec. La solution statique pour sa part ne requiert que quelques lignes supplémentaires ainsi qu'en témoigne le listing 4. Le paramètre du constructeur sert ici à identifier le test par un nom précis en cas d'échec.
Néanmoins, ces systèmes n'ont que peu d'intérêt dans le cadre de grosses applications comprenant des dizaines ou des centaines de tests. JUnit nous permet donc d'automatiser l'exécution d'un ensemble de tests grâce aux suites. Une suite encapsule des objets Test. Nous pouvons donc créer une suite par TestCase puis rassembler tous nos TestCase au sein d'une même suite pour un paquetage donnée, pour les réunir eux-mêmes dans une suite au niveau de l'application. Il nous sera alors possible d'exécuter tous nos tests d'un coup.
La réalisation d'une suite s'effectue dans la méthode statique suite() d'un TestCase. Il n'est pas obligatoire de procéder de la sorte, mais cela nous permettra d'exploiter par la suite des interfaces graphiques fournies avec JUnit pour simplifier la lecture des résultats. Une fois de plus, deux choix s'offrent à nous. Le premier est similaire à ce que nous venons de voir pour les tests simples ainsi que l'atteste le listing 5. Maintenant obsolète pour les TestCase, cette solution permet de composer des suites entre elles, vous permettant par exemple de créer un test pour un paquetage complet de votre application. Le second choix, présenté dans le listing 6, utilise la technique de reflection [NdA: conserver le mot reflection en anglais de préférence] pour découvrir tous les tests unitaires associés à un TestCase. Quelle que soit votre préférence, vous devrez utiliser un objet TestResult pour récupérer les résultats de vos tests :

 
Sélectionnez
TestSuite mysuite = suite();
TestResult result = new TestResult();
mysuite.run(result);

Cet objet vous permet de déterminer le succès ou l'échec de la suite, de connaître le nombre d'erreurs, d'échecs, de tests effectués, de récupérer les messages d'erreurs... Nous vous conseillons vivement de vous reporter à la javadoc de JUnit pour en connaître toutes les caractéristiques. Le listing 7 donne un exemple d'affichage des messages d'erreur. Il serait néanmoins dommage de devoir manipuler nous-mêmes ces résultats. Heureusement, JUnit nous apporte une fois de plus son soutien en proposant des environnements d'exécution pour les tests, des runners dans le jargon du framework.
Par défaut, le développeur peut exploiter trois runners : un en mode texte pour la console, un graphique basé sur le toolkit AWT et un dernier faisant appel à Swing. Vous pouvez les invoquer aussi bien depuis votre code que depuis la ligne de commande de votre système d'exploitation. Appelés depuis le code, les runners peuvent prendre en paramètre une instance de TestSuite ou une classe disposant d'une méthode publique et statique intitulée suite(). Nous pourrons donc rédiger la ligne suivante dans le main() de FileTest :

 
Sélectionnez
junit.textui.TestRunner.run(FileTest.class);

Ce runner en mode texte nous fournira automatiquement un résumé pertinent concernant notre suite de tests. Nous obtiendrons ainsi le temps d'exécution, le nombre d'erreurs, et ainsi de suite. Si vous préférez vous affranchir de nouvelles lignes de code, considérez la ligne de commande suivante :

 
Sélectionnez
java -classpath junit.jar:. junit.textui.TestRunner FileTest

Bien que très utile, ce runner console ne se révèle pas des plus agréables d'emploi. En remplaçant junit.textui par junit.awtui ou junit.swingui, vous obtiendrez une fenêtre vous permettant de choisir la suite à exécuter. Si vous disposez du support Swing avec votre machine virtuelle, préférez le runner correspondant à celui basé sur AWT. Vous bénéficierez d'une interface graphique un peu plus riche, affichant notamment l'arborescence des tests. Quelle soit la fenêtre que vous manipulerez, vous y trouverez une option extrêmement utile : Reload classes on every run. En la cochant, JUnit rechargera vos classes en mémoire, vous permettant de les recompiler entre deux exécutions de tests sans avoir à quitter le logiciel. Après quelques utilisations, il est probable que vous ne pourrez plus vous passer de ces interfaces qui vous permettront de gagner un temps non négligeable durant le développement de vos programmes.

listing 4
Sélectionnez
TestCase tc = new VectorTest("test de clone()")
{
  public void runTest()
  {
    testClone();
  }
};
tc.runTest();
listing 5
Sélectionnez
public static Test suite()
{
  TestSuite suite = new TestSuite();
  suite.addTest(new FileTest("testIsDirectory"));
  suite.addTest(new FileTest("testIsFile"));
  return suite;
}
listing 6
Sélectionnez
public static Test suite()
{
  return new TestSuite(FileTest.class);
}
listing 7
Sélectionnez
Object error  = null;
for (Enumeration e = result.errors();
  e.hasMoreElements(); error = e.nextElement())
{
  System.out.println("ERROR: " + error);
}

Bien que simple, JUnit constitue un framework de qualité dont la simplicité d'emploi saura, nous l'espérons, vous séduire. L'écriture de tests unitaires, très rébarbative à priori, devient beaucoup plus agréable avec cet outil, notamment en ce qui concerne leur exécution et la lecture des résultats.

IV. Méthodes d'assertion

Méthode Rôle
assertEquals Vérifie que deux objets sont égaux
assertFalse Vérifie que l'expression est fausse
assertNotNull Vérifie que l'objet n'est pas nul
assertNotSame Vérifie que deux références ne sont pas les mêmes
assertNull Vérifie qu'un objet est nul
assertSame Vérifie que deux références sont les mêmes
assertTrue Vérifie que l'expression est vrai
fail Provoque l'échec du test

V. Screenshots

Image non disponible
Schéma 1. Architecture principale de JUnit.
Image non disponible
L'interface texte est certes spartiate mais néanmoins indispensable.
Image non disponible
L'interface Swing vous permet d'explorer l'arborescence de test en quelques clics.
Image non disponible
Moins évoluée, l'interface AWT suffit néanmoins à identifier rapidement les réussites et les échecs des tests.
Image non disponible
L'échec d'un test s'accompagne toujours d'un texte d'erreur et de la pile d'exécution.
Image non disponible
La documentation JUnit est simple à assimiler et vous permettra de réaliser des tests efficaces.
Image non disponible
Le succès de JUnit a fait des émules comme PyUnit pour Python ...
Image non disponible
...ou encore CppUnit pour le C++.
Image non disponible
Kent Beck possède une grande expérience dans le domaine des design patterns des tests unitaires.
Image non disponible
Erich Gamma n'est plus à présenter tant son ouvrage fait figure de référence chez les développeurs.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Articles et tutoriels Java
L'essentiel de Java en une heure
L'API java.nio du JDK 1.4
Inversion de contrôle en Java
L'introspection
Le Java Community Process
Conception de tests unitaires avec JUnit
Les Strings se déchaînent
Présentation de SWT
La programmation réseau en Java avec les sockets
Du bon usage de l'héritage et de la composition
Les références et la gestion de la mémoire en Java
Constructeurs et méthodes exportées en Java
Les membres statiques, finaux et non immuables en Java
Les classes et objets immuables en Java
Comprendre et optimiser le Garbage Collector
Les principes de la programmation d'une interface graphique
Les opérateurs binaires en Java
Prenez le contrôle du bureau avec JDIC
Les Java Data Objects (JDO version 1.0.1)
La persistance des données avec Hibernate 2.1.8
Journalisation avec l'API Log4j
Java 5.0 et les types paramétrés
Les annotations de Java 5
Java 1.5 et les types paramétrés
Créer un moteur de recherche avec Lucene
Articles et tutoriels Swing
Threads et performance avec Swing
Rechercher avec style en utilisant Swing
Splash Screen avec Swing et Java3D
Drag & Drop avec style en utilisant Swing
Attendre avec style en utilisant Swing
Mixer Java3D et Swing
Articles et tutoriels Java Web
Redécouvrez le web avec Wicket
  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 Romain Guy. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.