13 novembre 2011

Mockito ou les limites de Java de l'intérieur

Mockito est un framework utilisé la plupart du temps au sein des tests unitaires permettant de créer et configurer des bouchons à partir de n'importe quelle classe ou interface Java.

Pour ceux qui ne connaissent ni le fonctionnement, ni la syntaxe Mockito, je vous invite à vous rendre sur le site du projet pour voir ce qu'il est possible de faire.

Je vais vous présenter ici la solution à un problème que j'ai rencontré dans l'utilisation de cet outil. Lors de la création d'un test unitaire utilisant des @Spy je suis tombé sur un os auquel on ne s'attend pas au premier abord.

Prenons pour exemple une classe :
public class Classe{

   private Collection<Eleve> eleves; //pas d'initialisation

   public Classe(){}

   public Classe(Eleve...eleves){
      eleves = new HashSet<Eleve>(Arrays.asList(eleves));
   }
  
   private Integer capacite = 0;
  
   public boolean placeDisponible(){
      return nombreEleves() < capacite();
   }

   public Integer capacite(){
      return capacite;
   }

   public Integer nombreEleves(){
      return eleves.size();
   }
}

Si l'on veut tester uniquement placeDisponibles() en choisissant nous-mêmes ce que retournent les méthodes capacite() et nombreEleves() il suffit a priori d'utiliser mockito comme ceci :

@RunWith(MockitoJunitRunner.class)
public ClasseTest {
   
   @Spy private Classe classe = new Classe();

   @Test
   public void ilyaUnePlaceDansLaClasse(){
      when(classe.capacite()).thenReturn(35);
      when(classe.nombreEleves()).thenReturn(34); //Fail !

      assertTrue(classe.placeDisponible());
   }
}

On lance le test et là boom ! Exception sur le deuxième when(...) mais que se passe-t-il en fait ? Rien de plus simple, lors de la configuration du @Spy la ligne when(class.nombreEleves()).thenReturn(34) fait un réel appel à classe.nombreEleves() ce qui provoque une NullPointerException.

Pour éviter ces appels en erreur, on serait tenter de faire en sorte que l'appel "passe" en modifiant le contenu de la méthode elle-même (la rendre tolérante au null en quelque sorte) mais il existe heureusement une solution plus simple qui se base sur les API Mockito.

En effet, on peut remplacer n'importe quel :

when(mockOrSpy.method()).thentReturn(result)
par un :
doReturn(result).when(mockOrSpy).method()
qui ne générera aucune erreur.

On obtient donc un test comme celui là, toujours aussi simple à lire et ne posant pas de problème à l'exécution

@RunWith(MockitoJunitRunner.class)
public ClasseTest {
   
   @Spy private Classe classe = new Classe();

   @Test
   public void ilyaUnePlaceDansLaClasse(){
      doReturn(35).when(classe).capacite();
      doReturn(34).when(classe).nombreEleves();

      assertTrue(classe.placeDisponible());
   }
}

Un peu plus loin dans ce cas de figure avec les ArgumentMatcher

Peut-être n'avez-vous jamais rencontré ce cas de figure ? Il est possible de rencontrer ce problème sans l'utilisation de @Spy mais avec de simple @Mock (aucune implémentation) configurés avec des ArgumentMatcher.

public class Personne{
   public Genre genre;
}

public enum Genre{
   HOMME,FEMME;
}

public interface Insee{
   Integer esperanceDeVie(Integer annee, Personne personne);
}
@RunWith(MockitoJunitRunner.class)
public StatistiquesTest {
   
   @Mock private Insee insee;

   private ArgumentMatcher<Personne> femmeMatcher = new ArgumentMatcher<Personne>(){
      public boolean matches(Object object){
         return ((Personne)object).genre == Genre.FEMME;
      }
   }

   private ArgumentMatcher<Personne> hommeMatcher = new ArgumentMatcher<Personne>(){
      public boolean matches(Object object){
         return ((Personne)object).genre == Genre.HOMME;
      }
   }

   @Test
   public void unTestAvecDesStatistiquesFictives(){
      when(insee.esperanceDeVie(eq(2011), argThat(femmeMatcher))).thenReturn(82);
      when(insee.esperanceDeVie(eq(2011), argThat(hommeMatcher))).thenReturn(75); //Fail ! 

      //action & tests ...
   }
}

La première configuration se passe bien dans notre cas mais la seconde échoue lamentablement. Cela a pour origine le même phénomène que précédemment, en regardant de plus prêt on se rend compte que le deuxième appel à when(...) (ligne 21) fait en réalité un réel appel sur le @Mock insee et déclenche ainsi un passage dans le matcher déjà enregistré.

Pour éviter cela, la même solution s'applique :

@RunWith(MockitoJunitRunner.class)
public StatistiquesTest {
   
   @Mock private Insee insee;

   private ArgumentMatcher<Personne> femmeMatcher = new ArgumentMatcher<Personne>(){
      public boolean matches(Object object){
         return ((Personne)object).genre == Genre.FEMME;
      }
   }

   private ArgumentMatcher<Personne> hommeMatcher = new ArgumentMatcher<Personne>(){
      public boolean matches(Object object){
         return ((Personne)object).genre == Genre.HOMME;
      }
   }

   @Test
   public void unTestAvecDesStatistiquesFictives(){
      doReturn(82).when(insee).esperanceDeVie(eq(2011), argThat(femmeMatcher));
      doReturn(75).when(insee).esperanceDeVie(eq(2011), argThat(hommeMatcher));

      //action & tests ...
   }
}

Une limitation du langage ?

On pourrait se demander pourquoi le framework Mockito ne gère pas ces cas de figure de manière automatique, et bien tout simplement parce que la grammaire du langage Java l'en empêche !

Ne reste plus qu'à espérer que ces problèmes puissent être résolus de manière native avec Java 8 ou alors à se mettre à utiliser dans langages/framework de la JVM n'ayant pas ce problème(comme le couple groovy/spock)

Aucun commentaire:

Enregistrer un commentaire