JUnitX how to

Un article de Agile-Swiss.

Jump to: navigation, search

Sommaire

JUnitX: "Ouvrez, ouvrez la cage aux méthodes privées..."

english version

    Il s'agit, dans ce petit tutorial, de combler, aussi humblement que possible, le manque de documentations concernant JUnitX ( http://www.extreme-java.de ). Découvert, en ce qui me concerne, en 2001, cet outil est une extension à JUnit me permettant de déjouer les restrictions d'accès et de pouvoir tester des méthodes privées (ou protected) comme des méthodes d'inner classes privés (ou protected elles aussi évidemment). Tout ceci sans avoir à polluer le code de la classe à tester. Evidemment, il ne faut pas abuser de ce genre de tests : il arrive simplement qu'il soit génant de passer une méthode en accès public pour la tester alors que l'on veut juste se rassurer sur son efficacité (et ce même si l'on teste unitairement la méthode publique qui l'utilise).

    Alors, je vous vois venir (et il est vrai que j'ai tendu un peu la perche), vous allez me dire: "Si une méthode privée a besoin de tests unitaires, cette méthode pourrait probablement être mieux implémentée et être publique". J'ai eu aussi été défenseur de cette thèse et n'ai pas encore vraiment choisi mon camp mais le sujet du tutorial n'est pas là: nous allons détailler les moyens d'utiliser JUnitX si vous en avez besoin, pas le "Pourquoi" de son utilisation. Prêts?...


Principe

    Cette API use de la réflection à tout va. Pourquoi? Simplement parce que sur les objets de type Class, des méthodes telles que getDeclaredMethod, getDeclaredClasses et getDeclaredFields ouvrent la porte à pas mal de choses. Pour info, ''getDeclaredMethods fournit les noms de TOUTES les méthodes d'une classe, PAS uniquement les publiques. Cette méthode nous rend un beau tableau Method[] avec tous les possibilités habituelles que l'on a sur de tels objets, en l'occurrence celle qui nous intéresse; l'invocation. Quant à getDeclaredClasses, elle founit les noms de TOUTES les inner classes/interfaces déclarées dans la classe sur laquelle on l'appelle, que celles-ci soient public, protected, default (package) access, ou privées.

    Vous devinez la vocation de la méthode getDeclaredFields et imaginez les possibilités qui s'en suivent en mixant les appels à ces trois méthodes! On peut se poser la question du pourquoi du comment de cette "faiblesse" de la reflection qui autorise des "interdits" du langage (volonté des créateurs du langage? effet de bord?), mais il faut avouer qu'en ce qui concerne les tests unitaires cela nous rend un fier service.

    Reste que pour ne pas avoir à modifier le code des classes à tester il faut quand même penser à généraliser ce genre d'appel. Les auteurs de JUnitX sont partis sur le principe d'avoir un proxy dans le même package que ces classes pour accéder aux parties protégées de celles-ci. Les constructions/invocations nécessaires dans le test-case se font via ce proxy.

Tentons un petit schéma pour mieux visualiser l'architecture de JUnitX (qui, soit dit en passant, tient en très peu de classes)


Image:JUnitXArch.png


Après le fonctionnement interne de JUnitX, voyons son utilisation...


Le cas simple: une méthode privée sans argument

    Supposons le cas ci-dessous:


Image:JunitX_ex1.png


    La méthode isElvisAlive doit rendre le booléen 'true' (!!), et pour des raisons que seuls les auteurs de cette classe connaissent, cette méthode sera privée de manière à cacher la vérité à ceux qui seraient tentés de la connaître. Même s'il semblerait que ces auteurs en connaissent long sur pas mal de choses, ils n'en restent pas moins des développeurs sujets aux erreurs: ils écrivent donc de suite le test . Mais comment faire pour tester ce...secret?

Première chose: ils créent une classe TestProxy (Listing 1.1) dans le même package que la classe à tester (les malheureux n'utilisent pas encore JUnitX 5.2, cf. la section Complément d'enquête).

Listing 1.1

   package com.mib.xfiles;
   
   import junit.framework.*;
   import junitx.framework.*;
   
   public class TestProxy extends junitx.framework.TestProxy{
   
     public Object newInstance (Object[] anArgList) throws TestAccessException {
       try {
         return getProxiedClass().getConstructor (anArgList).newInstance (anArgList);
       }catch (Exception e){
         throw new TestAccessException ("could not instantiate " + getTestedClassName(), e);
       }
     }
   
     public Object newInstanceWithKey (String aConstructorKey, Object[] anArgList)throws TestAccessException{
       try{
         return getProxiedClass().getConstructor (aConstructorKey).newInstance (anArgList);
       }catch (Exception e){
         throw new TestAccessException ("could not instantiate " + getTestedClassName(), e);
       }
     }
   }


Le code de cette classe est le même pour toutes les classes TestProxy dont ils ont besoin, pour n'importe quel package que ce soit. Evidemment, ils veillent à ne pas déployer ces classes! Sinon, les secrets seraient mal gardés...

Le Listing 1.2 décrit la classe de test MusicWorldTest:

Listing 1.2

   package com.mib.xfilestests;
   
   import junit.framework.*;
   import junitx.framework.*;
   
   import com.mib.xfiles.MusicWorld;
   
   public class MusicWorldTest extends PrivateTestCase {
       
       public MusicWorldTest(java.lang.String testName) {
           super(testName);
       }
       
       public void testElvisAlive()throws TestAccessException{
           MusicWorld musicWorld = new MusicWorld();
           boolean veritas = asBoolean(invoke(musicWorld, "isElvisAlive", NOARGS));
           assertTrue(veritas);
       }
       
   }


Détaillons la ligne :

boolean veritas = asBoolean(invoke(musicWorld, "isElvisAlive", NOARGS));

    invoke prend en premier argument l'objet (musicWorld) sur lequel on veut appeler la méthode dont le nom est le deuxième argument ("isElvisAlive"). Les arguments de cette méthode isElvisAlive, s'il y en avait, seraient en troisième argument de invoke. Ici, il n'y a, comme la constante l'indique, aucun argument. Rien de bien compliqué non? Ok, il reste le asBoolean. Cette méthode permet de wrapper un type primitif et son équivalent objet. Invoke rend toujours un Object. Si la méthode à tester rend un objet (ie qui hérite d'Object), un simple cast fera l'affaire, s'il s'agit d'un type primitif, à vous d'utiliser le wrapper correspondant.

Notre équipe de développement du MiB est maintenant rassurée: après avoir développé la méthode isElvisAlive et vérifié que le test précédemment écrit passait, la vérité et son secret sont désormais garantis.

Une méthode privée AVEC arguments

Arguments de type Objet

A peine est-elle satisfaite de son isElvisAlive que l'équipe complète son jeu de tests avec un test portant sur une méthode au caractère tout aussi secret que la précédente:


Image:JunitX_ex2.png



    Cette méthode getAlienThatLiveIn (qui donnera des infos capable de briser les illusions de moultes fans...) prend en paramètre une chaîne de caractère. Le test écrit (Listing 2.1) diffère donc un peu du cas vu précédemment. Il s'agit juste de passer un tableau d'objets (ici des String) en dernier argument de la méthode invoke. Notez aussi que cette fois, l'équipe de dev retrouve le résultat de la méthode en castant simplement en String la valeur retournée puisque le type retourné est un type objet. Pas besoin ici d'utiliser de méthode asQuelquechose pour wrapper cette valeur.


Listing 2.1

   public void testAliens()throws TestAccessException{
       MusicWorld musicWorld = new MusicWorld();
       String alienName = (String) invoke(musicWorld, "getAlienThatLiveIn", new Object[]{"Elvis"});
       assertEquals("Ublitonum Frividous (from Planet Astra V12)", alienName);
   }


Attention, notez bien que pour utiliser invoke avec des arguments, il faut que ceux-ci soient tous de type objet! Dans le cas où au moins un argument est de type primitif il faut utiliser une autre méthode. Heureusement, nos developpeurs du MiB ont des méthodes à revendre et nous donnent un exemple de suite...


Arguments de type primitif

Pour chaque planète il y a un ambassadeur alien sur Terre déguisé en artiste. Les MiB ont donc une méthode prenant en argument un code de planète et rendant le nom de l'artiste-alien ambassadeur de celle-ci.


Image:JunitX_ex3.png


    Le test (Listing 2.2) doit donc prendre en compte l'argument de type primitif (int). Tout d'abord la méthode à utiliser est invokeWithKey et non plus invoke. Ensuite, un type primitif est wrappé par son type objet, c'est pourquoi l'équipe passe en argument un tableau d'objets avec pour seul élément un Integer. Attention, cela ne suffit pas! Regardez bien le code de nos collègues: le nom de la méthode passé en second argument de invokeWithKey est différent de ce que vous avez vu jusqu'à présent: le type primitif est spécifié juste derrière. Ne l'oubliez pas!


Listing 2.2

   public void testAmbassadorFromPlanetCode()throws TestAccessException{
       MusicWorld musicWorld = new MusicWorld();
       String peopleName = (String) invokeWithKey(musicWorld, "getAmbassadorComingFromPlanetCode_int", new Object[]{new Integer(12)});
       assertEquals("Elvis", peopleName);
       peopleName = (String) invokeWithKey(musicWorld, "getAmbassadorComingFromPlanetCode_int", new Object[]{new Integer(10)});
       assertEquals("We don't know anyone coming from this planet!", peopleName);        
   }


Notez que si la méthode getAmbassadorComingFromPlanetCode avait eu deux arguments, un de type primitif et un de type String (pour préciser la situation par exemple), l'équipe aurait dû spécifier les deux types derrière le nom de la méthode et nous aurions eu par exemple l'appel suivant:

String peopleName = (String) invokeWithKey(musicWorld, "getAmbassadorComingFromPlanetCode_int_java.lang.String", new Object[]{new Integer(12), "GloubiWay"});


Une dernière remarque: si les méthodes précédentes avaient été protected, notre équipe du MiB aurait testé le tout de la même manière. Mais paranoïaques comme ils le sont, le private est de mise; ces méthodes ne sont probablement utilisées qu'à des fins statistiques très vagues qui ne donneront de publique que des rapports incompréhensibles pour les communs des terrestres que nous sommes ;)


Attention acrobatie: accès aux inner-classes protected et à leurs méthodes privées

    Cette équipe est encore plus tordue que vous ne pouvez l'imaginer: ils auraient voulu tester des méthodes privées d'inner classes privées. Mais il s'avère impossible de faire cela avec JUnitX (voire simplement en réflection): une inner class privée est des plus innaccessible (c'est peut-être bien la seule chose que l'on ne peut pas atteindre, en tout cas l'équipe de dev du MiB n'a toujours pas trouvé, si vous savez comment faire, transmettez moi le message et je ferais suivre à l'un d'eux la manipulation à effectuer dans une enveloppe anonyme ;) ).

Ils se sont donc résolu à passer leurs inner-classes protected et à passer les méthodes de celles-ci private.

Vous avez remarqué, dans les exemples précédents, que l'objet à tester était instancié de manière classique puisque cet objet était atteignable de partout (classe publique):

MusicWorld musicWorld = new MusicWorld();

Mais lorsqu'il s'agit d'instancier un objet protected encapsulé dans un objet publique, le cas est tout autre. Par exemple, la classe MusicWorld contient un objet bien précis d'un monde à part: RapWorld (Listing 3.1); une inner class protected. Une méthode privée de cette classe est chargée de percer le secret de 2pac.

Listing 3.1

   public class MusicWorld {
       
       protected class RapWorld{
           
           public RapWorld(){
           }
       
           public RapWorld(String side){
               System.out.println("side is " + side);
           }
       
           private boolean isTupacAlive(){
              return true;
           }       
       
       }
   
       /** Creates a new instance of MusicWorld */
       public MusicWorld() {
       }

       //les autres méthodes de MusicWorld vues précedemment... 
      
   }


A première vue, atteindre cette méthode tient de la pirouette extra-terrestre mais finalement la partie la plus difficile est l'instanciation de RapWorld (Listing 3.2), l'invocation reste similaire à ce que l'on a déjà vu.

Listing 3.2

   public void testTupacAlive()throws TestAccessException{
       Object[] outer = {new MusicWorld()};
       Object   inner = newInstance("com.mib.xfiles.MusicWorld$RapWorld", outer);        
       assertTrue(asBoolean (invoke (inner, "isTupacAlive", NOARGS)));        
       
       outer = new Object[]{new MusicWorld(), "West side"};
       inner = newInstanceWithKey("com.mib.xfiles.MusicWorld$RapWorld", "_com.mib.xfiles.MusicWorld_java.lang.String", outer);
       assertTrue(asBoolean (invoke (inner, "isTupacAlive", NOARGS)));       
   }

Détaillons les arguments des newInstance et newInstanceWithKey afin de vous mettre à niveau égal avec ces prétentieux du MiB.

Concernant newInstance:

  • Le premier argument représente le nom complet de l'inner class à tester (écrite sous la forme package.ClasseParente$ClasseFille).
  • Il faut ensuite savoir que le compilateur ajoute comme premier argument au constructeur de l'inner class l'instance de la classe mère. Ce qui explique l'utilisation d'un tableau ne contenant qu'une instance de MusicWorld comme second argument de newInstance.

Concernant newInstanceWithKey:

  • Le premier argument représente le nom complet de l'inner class à tester (écrite sous la forme package.ClasseParente$ClasseFille).
  • Le second argument représente la clé du constructeur à utiliser (clé JUnitX qui est composée des types des paramètres préfixés par '_', y compris le type de la classe mère, type du premier argument).
  • Et le dernier argument représente la liste des valeurs des paramètres de ce constructeur, incluant le fameux premier paramètre ajouté au constructeur à la compilation, ie l'instance de la classe mère.


Et voilà ce qu'obtient l'équipe du labo après ces quelques lignes:


Image:junitguijunitx.png


...de quoi dormir sur leurs deux oreilles (ou trois? ou quatre? Il paraît qu'il y aurait des développeurs du MiB pas très terrestres...)


Complément d'enquête

    Une utilisation quotidiennne de JUnitX révèle des cas de tests dans lesquels on se perd facilement dans les méandres de la reflexion si l'on n'est pas un tantinet guidé. Dans cette section, nous tentons de détailler ces cas-ci, tout du moins ceux que nous avons déjà rencontré...


Interface ou Superclasse en argument de méthode

    Nous avons précédemment vu (Listing 2.2) que invokeWithKey permettait d'appeler une méthode innaccessible prenant en paramètre au moins un type primitif. Il existe un autre cas où ce type d'appel peut s'avérer utile. Prenons le cas suivant:

Image:Interfaceorsuperclassjunitx.png

    Voyons la manière de tester la méthode getMostFamousForWorld. Le soucis principal est qu'un appel classique à invoke ne suffit pas. En effet la réflexion se base sur le type de plus bas niveau de l'argument passé. Si nous passons l'argument new Object[]{new MusicWorld()} à la méthode invoke, la réflexion va chercher la méthode de signature getMostFamousForWorld(com.mib.xfiles.MusicWorld world) alors que l'on veut appliquer la méthode getMostFamousForWorld(com.mib.xfiles.MediaWorld world).

La manière de contourner ce soucis est de forcer la signature voulue en utilisant la méthode invokeWithKey avec la signature getMostFamousForWorld_com.mib.xfiles.MediaWorld en argument: le tour est joué! (cf. Listing 4.1). Votre réflexion a pris le dessus sur celle de java... Désolé, je sors.

'Listing 4.1'

   public void testGetMostFamousForWorld() throws TestAccessException{
       MibHq mibHQ = new MibHq();
       MusicWorld musicWorld = new MusicWorld();
       //the below line doesn't work because the reflexion expects a MusicWorld object as argument
       //assertEquals("Elvis", (String) invoke(mediaWorld, 
       //                                      "getMostFamousForWorld", 
       //                                      new Object[]{musicWorld}));
       assertEquals("Elvis", (String) invokeWithKey(mibHQ,  
                                                    "getMostFamousForWorld_com.mib.xfiles.MediaWorld", 
                                                    new Object[]{musicWorld}));
   }


Tester la valeur d'un champ privé SANS accesseur

    Ne me demandez pas pourquoi cela peut arriver mais cela peut arriver. Mettons que vous vouliez tester la couleur d'arrière plan d'un JButton que vous n'avez aucun raison de rendre accessible en dehors de la classe à tester. Par exemple, les MIB, après avoir développé une interface pour un questionnaire téléchargeable par les aliens, veulent vérifier que l'état initial de leur interface est bien d'avoir un JLabel contenant la question "Sur la déclaration universelle des gloubibouldroits j'affirme être...", un bouton vert pour la réponse "un gentil alien" et un bouton rouge pour "un dangereux psychopathe d'alien".

L'accès à un de ces champs privés se fait via la méthode get disponible dans toute classe étendant PrivateTestCase.

Dans le cas d'une variable d'instance:

   get(Object anInstance, String fieldName) : le premier argument est l'instance 
                                                  dont on veut avoir un pointeur 
                                                  sur le champ fieldName

Dans le cas d'une variable de classe statique

   get(String aClassName, String fieldName) : le premier argument est le nom complet 
                                                  (package inclus) de la classe dont on veut 
                                                  avoir un pointeur sur le champ de nom fieldName

Dans notre cas, si le bouton vert est déclaré comme ceci dans la classe WhatAlienAmIDialogBox

   JButton btGoodAlien;

on pourra tester la valeur de sa couleur de fond avec

   assertEquals(Color.GREEN, 
                ((JButton) get(whatAlienAmIDialogBoxInstance, "btGoodAlien")).getBackground());

Evidemment, avec l'expérience sur JUnitX acquise tout au long de cet article, vous avez deviné que si on accède à un champ privé de type primitif, on se sert alors des méthodes asXXX() pour en connaître la valeur.

   boolean myPrivateBooleanValue = asBoolean( get(whatAlienAmIDialogBoxInstance, "myPrivateBoolean")) );

Tester une méthode statique privée

    Qui dit méthode statique dit inutilité d'une instance. Avec JUnitX <= 5.1, il est cependant nécessaire de créer une instance pour invoquer le genre méthode. Un problème se pose lorsque la classe à tester ne contient aucun constructeur publique ou aucune factory-method publique.

    La version JUnitX 5.2 (le jar seul est disponible ici) rend possible l'invocation de méthodes statiques sans créer d'instance de la classe testée .

Il suffit d'appeler invokeStatic ou invokeStaticWithKey qui s'utilisent de la même manière que invoke et invokeWithKey, si ce n'est que leur premier argument n'est non plus l'instance mais le nom de la classe à tester (l'objet de type Class) .

Par exemple, pour vérifier que la méthode getRandomNote de MusicWorld rend bien une note parmi celles connues (une liste contenant "A", "B", "C", "D", "E", "F" et "G" par exemple):

   assertTrue( notesList.contains(invokeStatic(MusicWorld.class, "getRandomNote", NOARGS)) );

Remonter les exceptions

    Il se peut que des méthodes privées que l'on teste jettent des RuntimeException. Si une de celle-ci est jetée lorsque JUnit tourne (Listing 4.2 et 4.3), l'exception remontée ne sera qu'une TestAccessException et la fenêtre des failures de JUnit ne donnera qu'un minimum d'infos, du type:

   junitx.framework.TestAccessException: could not invoke com.mib.xfiles.MusicWorld.throwARuntimeException , 
                                         reason: java.lang.reflect.InvocationTargetException


Si vous voulez avoir des infos sur le réel problème qui a eu lieu, une bonne pratique de JUnitX est décrite dans le listing Listing 4.4. A savoir qu'il faut catcher les exceptions spécifiques à JUnitX (ie les TestAccessException), en extraire les raison et cause et propager cette dernière.


Listing 4.2 (méthode à tester)

   public boolean throwARuntimeException(){
       if(true){
           throw new RuntimeException("you should read this RuntimeException message");
       }
       return true;
   }


Listing 4.3 (mauvaise propagation de l'exception)

   public void testRuntimeException() throws TestAccessException{
       MusicWorld musicWorld = new MusicWorld();
       assertTrue(asBoolean(invoke(musicWorld, "throwARuntimeException", NOARGS)));
   }


Listing 4.4 (bonne pratique pour propager l'exception jetée par la méthode privée)

   public void testRuntimeException() throws Throwable{
       try{
           MusicWorld musicWorld = new MusicWorld();
           assertTrue(asBoolean(invoke(musicWorld, "throwARuntimeException", NOARGS)));            
       }catch(TestAccessException tae){
           if(tae.getReason()!=null){
               throw tae.getReason().getCause();
           }else{
               throw tae;
           }
       }      
   }  

Et vous vous retrouvez avec la belle trace suivante dans votre fenêtre de logs des assertions:

   java.lang.RuntimeException: you should read this RuntimeException message

avec toute la StackTrace évidemment. Bref, tout ce qu'il vous faut!

JUnitX 5.2 -> plus de TestProxy !

    Et oui, on a attendu jusqu'ici pour vous le dire... JUnitX 5.2 (jar seul disponible ici) permet de ne plus avoir de classes TestProxy qui polluent vos packages fonctionnels ! Il s'agit en fait de la dernière version des sources de JUnitX disponibles sur www.extreme-java.de que j'ai patché.

    La version 5.2 de JUnitX utilise javassist pour ne plus avoir à créer ces classes: elles sont automatiquement créées, compilées et placées dans le classpath des tests au runtime. Aucun fichier n'est créé sur le disque, même temporairement, tout se passe dans la JVM.

    Cette version est compatible avec les précédentes: si vous avez des classes TestProxy existantes dans vos divers packages, celles-ci sont utilisées. Mais vous pouvez désormais les supprimer ;)

Important: N'oubliez pas d'inclure le jar de javassist dans votre classpath sinon le comportement de JUnitX sera le même qu'auparavant, ie il s'attendra à trouver un TestProxy dans chaque package à tester, et s'il n'en trouve pas, sera incapable d'en créer au runtime.


Notez que le projet JUnitX semblait avoir été abandonné, c'est pourquoi nous en livrons la nouvelle version sur xp-swiss.org. Après avoir essayé plusieurs fois de contacter Andreas Heilwagen sans succès (afin de lui envoyer notre patch pour les méthodes statiques et la création des classes TestProxy), nous avons finalement décidé de le rendre disponible sur ce site.

Conclusion

    Dans cet article, vous avez vu le principal pour ne pas être freiné dans votre entrain de test driven developer. Si vous avez absolument besoin de tester une méthode privée et que le simple fait de penser à la passer publique vous donne de l'urticaire, vous savez désormais quoi faire. Il est évident que tous les cas n'ont pas été traités ici et que vous trouverez d'autres types d'arguments, d'autres méthodes qui vous donneront du fil à retordre. A vous de découvrir les limites de JUnitX et de la reflection. N'oubliez pas que la meilleure documentation est le code source de JUnitX associé à celui de ses tests unitaires. Eh oui, encore un exemple où les tests jouent le rôle parfait de manuel pour une API ;)


( quelques sources issus de l'article )