Quand j'écris un test, je veux qu'il soit isolé autant que possible, à savoir qu'il teste une méthode sans que les résultats des méthodes d'autres objets dont elle dépend et leurs erreurs éventuelles ne viennent perturber ses résultats. Celles-là feront l'objet d'autres tests.
Pour cela j'utilise des mocks pour remplacer les dépendances. Je ne cherche pas à lancer une discussion sur le bienfondé de cette manière de travailler, ni sur ce qu'il convient ou non de mocker. Non, mon but ici est de présenter Mockito, la librairie que j'utilise pour cela.
Pourquoi ai-je préféré Mockito à une autre librairie? Pour plusieurs raisons. Sans prétendre avoir fait une recherche exhaustive sur le sujet, je trouve que Mockito est assez légère (par rapport à EasyMock par exemple) et puis j'aime beaucoup sa manière de configurer des mocks avec un DSL Java.
Pour exécuter les exemples qui suivent, je vous conseille de créer un projet Maven et d'ajouter dans ses dépendances:
<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-all</artifactId> <version>1.9.5</version> <scope>test</scope> </dependency>Comme il s'agit d'une dépendance utilisée pour des tests unitaires, le scope sera bien entendu "test". A noter que j'utilise aussi TestNg (ce qui n'est pas nécessaire pour Mockito... mais j'ai mes habitudes).
Les tests porteront sur une classe toute simple, une interface en fait, que je crée dans un package quelconque de src/main/java. Il ne faut pas y chercher d'autre logique que de pouvoir tester le fonctionnement de Mockito.
public interface SimpleClass { Integer methodWithInteger(String message); int methodWithInt(String message); boolean methodWithBoolean(String message); Boolean methodWithBooleanWrapper(String message); String methodWithString(String message); Object methodWithObject(String message); }
Création d'un mock
Pour tester le fonctionnement de Mockito, je vais écrire des tests unitaires (TestNg), qui seront évidemment dans src/test/java. Voici le premier:
import static org.mockito.Mockito.*; import static org.testng.Assert.*; public class TestMock { @Test public void testNotImplementedResults(){ SimpleClass mock = mock(SimpleClass.class); assertEquals(mock.methodWithInteger("hello"), Integer.valueOf(0)); assertEquals(mock.methodWithInt("hello"),0); assertFalse(mock.methodWithBoolean("hello")); assertEquals(mock.methodWithBooleanWrapper("hello"),Boolean.FALSE); assertNull(mock.methodWithString("hello")); assertNull(mock.methodWithObject("hello")); } }Les imports statiques permettent d'utiliser très simplement les méthodes statiques de la classe Mockito (le reste est là pour testng.Assert). Dans le cas présent, nous utilisons la méthode "mock" qui crée un "mock" de notre interface/classe de base.
L'exécution de ce test (qui réussit), nous montre plusieurs choses:
- les méthodes existent (avec d'autres frameworks, l'appel d'une méthode sans l'avoir définie au niveau du mock aurait provoqué une exception. Il y a du pour et du contre.)
- il n'y a aucune implémentation de l'interface, c'est Mockito qui fournit l'implémentation. Si SimpleClass était une classe concrète, avec des implémentations de ses méthodes, aucune ne serait de toute façon appelée. Le mock de Mockito intercepte tous les appels.
- la valeur renvoyée dans tous les cas est nulle ou équivalente. Pour un type primitif ou son wrapper, c'est 0 (false pour un booléen). Si c'est un objet (autre qu'un Wrapper), c'est null qui est renvoyé.
Vérification des appels
Ce simple mock permet en fait de vérifier qu'une méthode est appelée ou non. Le test suivant le montre:
@Test public void testVerifyCalledMethod(){ SimpleClass mock = mock(SimpleClass.class); mock.methodWithInteger("hello"); verify(mock).methodWithInteger("hello"); }Cela signifie donc que la méthode "methodWithInteger" a bien été appelée.
Par contre, le test suivant échoue:
@Test public void testVerifyNotCalledMethod(){ SimpleClass mock = mock(SimpleClass.class); mock.methodWithInteger("hello"); verify(mock).methodWithString("hello"); }avec la sortie console suivante (les numéro de ligne peuvent être différents):
FAILED: testVerifyNotCalledMethod Wanted but not invoked: simpleClass.methodWithString("hello"); -> at mockito.TestMock.testVerifyNotCalledMethod(TestMock.java:37)La méthode verify vérifie donc bien que la méthode "surveillée" a été appelée, mais il y a mieux.
Le test suivant échoue:
@Test public void testVerifyCalledMethodButWithDifferentArgument(){ SimpleClass mock = mock(SimpleClass.class); mock.methodWithInteger("hello"); verify(mock).methodWithInteger("toto"); }avec la sortie console:
FAILED: testVerifyCalledMethodButWithDifferentArgument Argument(s) are different! Wanted: simpleClass.methodWithInteger("toto"); -> at mockito.TestMock.testVerifyCalledMethodButWithDifferentArgument(TestMock.java:45) Actual invocation has different arguments: simpleClass.methodWithInteger("hello");Donc la méthode verifiy vérifie que la méthode du mock a été appelée, mais aussi avec quels arguments. Nous verrons dans un instant qu'elle en fait fait un peu plus.
Cette fonctionnalité est utile pour vérifier qu'une méthode d'une dépendance de l'objet testé, dépendance remplacée par un mock, a bien été appelée, avec les bons paramètres.
En attendant, nous avons éventuellement un problème.
Vérification par type de paramètre
Si nous souhaitons vérifier qu'une méthode est appelée avec un paramètre bien défini, comme ci-dessus, c'est bien. Cependant, la String exacte passée en paramètre n'est parfois pas connue avant le test ou sans importance. Comment vérifier que la méthode a été appelée avec une String, quelle qu'elle soit?
Comme ceci:
@Test public void testVerifyCalledMethodWithAnyParameterValue(){ SimpleClass mock = mock(SimpleClass.class); mock.methodWithInteger("hello"); verify(mock).methodWithInteger(anyString()); }"anyString()" est un "matcher" (et d'une certaine manière, on pourrait dire qu'un paramètre tout simple - une String dans notre cas - est une forme de matcher).
Il en existe d'autres semblables à anyString(): anyInt(), anyFloat(), anyObject(), any()... Voyez les méthodes statiques des classes org.mockito.Matchers et org.mockito.AdditionalMatchers pour un aperçu de toutes les possibilités.
Le code suivant est équivalent au précédent:
verify(mock).methodWithInteger(any(String.class));Un autre exemple de matcher:
@Test public void testVerifyCalledMethodWithStringValueBeginningWith(){ SimpleClass mock = mock(SimpleClass.class); mock.methodWithInteger("bonsoir"); verify(mock).methodWithInteger(startsWith("bon")); }fonctionne pour "bonjour", "bonsoir" mais pas "au revoir"...
Et on peut en plus créer ses propres matchers en étendant la classe org.hamcrest.BaseMatcher:
public class SalutationMatcher extends BaseMatcher<String>{ public boolean matches(Object item) { boolean match = false; if(item instanceof String){ String s = (String)item; match = s.startsWith("b") && s.endsWith("r"); } return match; } public void describeTo(Description description) { description.appendText("String qui commence par 'b' et se termine par 'r'"); } }dans laquelle:
- "matches" effectue la vérification (ici, la String doit commencer par un "b" et se terminer par un "r").
- "describeTo" permet de décrire le matcher, c'est-à-dire le texte qui sera affiché si le matcher n'est pas "matché".
@Test public void testVerifyCallWithSalutation(){ SimpleClass mock = mock(SimpleClass.class); mock.methodWithInteger("bonsoir"); verify(mock).methodWithInteger(argThat(new SalutationMatcher())); } @Test public void testVerifyWithNotASalutation(){ SimpleClass mock = mock(SimpleClass.class); mock.methodWithInteger("bon appétit"); verify(mock).methodWithInteger(argThat(new SalutationMatcher())); }Le premier réussit. Il aurait aussi réussi avec "bonjour".
Le deuxième échoue, avec cette indication dans la console:
FAILED: testVerifyWithNotASalutation Argument(s) are different! Wanted: simpleClass.methodWithInteger( String qui commence par 'b' et se termine par 'r' ); -> at mockito.TestMock.testVerifyWithNotASalutation(TestMock.java:78) Actual invocation has different arguments: simpleClass.methodWithInteger( "bon appétit" );Ce qui était "wanted" correspond au texte qu'on a écrit dans la description.
Vérification du nombre d'appels
Peut-être avez-vous déjà essayé quelque chose comme ceci:
@Test public void testSomethingGoesWrongWhenVerifyingCallsOnMethodCallTwice(){ SimpleClass mock = mock(SimpleClass.class); mock.methodWithInteger("hello"); mock.methodWithInteger("bonjour"); verify(mock).methodWithInteger(anyString()); }Ce test echoue:
FAILED: testSomethingGoesWrongWhenVerifyingCallsOnMethodCallTwice org.mockito.exceptions.verification.TooManyActualInvocations: simpleClass.methodWithInteger(C'est parce que la méthode "verify" vérifie que la méthode a bien été appelée, mais aussi qu'elle a été appelée une et une seule fois.); Wanted 1 time: -> at mockito.TestMock.testSomethingGoesWrongWhenVerifyingCallsOnMethodCallTwice(TestMock.java:87) But was 2 times. Undesired invocation: -> at mockito.TestMock.testSomethingGoesWrongWhenVerifyingCallsOnMethodCallTwice(TestMock.java:86)
Il peut en effet être intéressant de vérifier qu'une méthode a été appelée un certain nombre de fois.
@Test public void testVerifyNumberOfInvocations(){ SimpleClass mock = mock(SimpleClass.class); mock.methodWithInteger("bonjour"); mock.methodWithInteger("bonsoir"); verify(mock,times(2)).methodWithInteger(anyString()); verify(mock,atLeastOnce()).methodWithInteger(anyString()); verify(mock,times(1)).methodWithInteger("bonjour"); verify(mock,atLeastOnce()).methodWithInteger("bonjour"); verify(mock).methodWithInteger("bonsoir"); verify(mock,never()).methodWithInteger("au revoir"); }Tout ce qui précède fonctionne:
- le premier verify vérifie que la méthode a été appelée exactement deux fois avec n'importe quelle String;
- le deuxième que la méthode a été appelée au moins une fois avec n'importe quelle String;
- le troisième que la méthode a été appelée une et une seule fois avec "bonjour";
- le quatrième que la méthode a été appelée au moins une fois avec "bonjour";
- le cinquième fait comme le troisième, mais avec "bonsoir", autrement dit "times(1)" est optionnel;
- le sixième vérifie que la méthode n'a jamais été appelée avec "au revoir" comme paramètre (ce qui équivaut à "times(0)")
Les stubs
Jusqu'à présent, nous avons vérifié l'appel des méthodes et nous ne nous sommes pas intéressés à ce qu'elles renvoyaient, d'autant plus que, jusqu'à présent elles ne renvoyaient que null ou ses équivalents.
Mais un grand intérêt des mocks dans des tests est de leur faire renvoyer des valeurs que nos "Classes Under Test" utiliseront.
Pour cela, il faut définir des "stubs" pour ces méthodes.
Voici un test qui montre le fonctionnement:
@Test public void testSimpleStubbing(){ //Création d'un mock SimpleClass mock = mock(SimpleClass.class); //Définition des stubs when(mock.methodWithInteger("bonjour")).thenReturn(Integer.valueOf(1)); when(mock.methodWithInteger("bonsoir")).thenReturn(Integer.valueOf(2)); //Test assertEquals(mock.methodWithInteger("bonjour"),Integer.valueOf(1)); assertEquals(mock.methodWithInteger("bonsoir"),Integer.valueOf(2)); assertEquals(mock.methodWithInteger("hello"),Integer.valueOf(0)); }La paramètre du stub est évidemment un "matcher" et on peut donc écrire:
when(mock.methodWithInt(anyString()).thenReturn(3);On peut aussi réutiliser le matcher que nous avons écrit plus haut:
when(mock.methodWithString(argThat(new SalutationMatcher()))).thenReturn("hello");Cela ne vous satisfait pas? Vous voulez une réponse calculée depuis la valeur du paramètre? Nous verrons plus loin l'implémentation partielle d'une méthode . Dans l'immédiat, la solution consiste à utiliser une "Answer".
@Test public void testStubbingWithAnswer(){ SimpleClass mock = mock(SimpleClass.class); when(mock.methodWithInteger(anyString())).thenAnswer(new Answer<integer>(){ public Integer answer(InvocationOnMock invocation) throws Throwable { String param = (String) invocation.getArguments()[0]; if(param.startsWith("bon")) return Integer.valueOf(2); else return Integer.valueOf(1); }} ); assertEquals(mock.methodWithInteger("bonjour"),Integer.valueOf(2)); assertEquals(mock.methodWithInteger("bonsoir"),Integer.valueOf(2)); assertEquals(mock.methodWithInteger("hello"),Integer.valueOf(1)); }Il est aussi possible de donner une liste de réponses qui seront renvoyées à chaque appel:
@Test public void testStubbingWithListOfAnswers(){ SimpleClass mock = mock(SimpleClass.class); when(mock.methodWithInt(anyString())).thenReturn(1,2,3); assertEquals(mock.methodWithInt("hello"),1); assertEquals(mock.methodWithInt("hello"),2); assertEquals(mock.methodWithInt("hello"),3); assertEquals(mock.methodWithInt("hello"),3); }S'il y a plus d'appels que la liste ne contient de valeurs, la dernière sera renvoyée pour tous les appels subséquents.
Parfois, un mock devra lancer une exception. Par exemple:
@Test public void testStubbingWithException(){ SimpleClass mock = mock(SimpleClass.class); when(mock.methodWithInt(anyString())).thenReturn(1); when(mock.methodWithInt("bye bye")).thenThrow(new RuntimeException("pas bye bye")); assertEquals(mock.methodWithInt("hello"),1); try{ mock.methodWithInt("bye bye"); fail("Une exception doit avoir été lancée"); }catch(RuntimeException e){ assertEquals(e.getMessage(),"pas bye bye"); } }L'exception définie doit être compatible avec la signature de la méthode stubbée. Ici, comme la signature de methodWithInt ne déclare aucune exception (throws Exception, par exemple), je ne peux utiliser qu'une exception de type RuntimeException.
L'exemple ci-dessus montre aussi qu'il est possible de surcharger le comportement d'un stub. Dans un premier temps, un résultat pour anyString est défini. Ensuite, un résultat spécifique à "bye bye" est défini.
Implémenter partiellement une méthode
Ajoutons une classe basique:
public class Personne { private String nom; public Personne(String nom){ this.nom = nom; } public String getNom() { return nom; } public void setNom(String nom) { this.nom = nom; } }et une méthode dans SimpleClass:
int compteEtCapitalise(Personne p);Son contrat spécifie qu'elle capitalise le nom d'une personne et renvoie le nombre de lettres dans son nom.
Bon, ça n'a aucun intérêt dans le monde réel. Imaginons cependant que notre classe testée dépende de SimpleClass et utilise cette méthode compteEtCapitalise. L'effet "de bord" (la mise en capitales du nom), elle s'en moque, mais elle dépend du résultat renvoyé, lequel doit être cohérent par rapport à l'objet Personne envoyé.
Or, ce n'est pas facilement le cas.
Cette version n'est pas satisfaisante:
@Test public void testCheckAnswerCoherence(){ SimpleClass mock = mock(SimpleClass.class); when(mock.compteEtCapitalise(any(Personne.class))).thenReturn(4); assertEquals(mock.compteEtCapitalise(new Personne("toto")),4); assertEquals(mock.compteEtCapitalise(new Personne("marcel")),4, "Et non 6..."); }La valeur renvoyée est fixée à 4 et ne sera généralement pas correcte. Soyons clair, il y a moyen de contourner ce problème, au prix d'une configuration plus lourde: une pour "toto" et une autre pour "marcel". Mais si le nom de la Personne passée en paramètre est imprévisible (même si pour moi, c'est un problème de qualité du test, mais c'est une autre histoire), ça ne fonctionne pas.
doAnswer
Ce dont nous avons besoin, c'est d'implémenter partiellement la méthode.
@Test public void testCheckAnswerCoherence(){ SimpleClass mock = mock(SimpleClass.class); when(mock.compteEtCapitalise(any(Personne.class))).thenAnswer(new AnswerOn peut se poser la question de l'utilité du mock si dans les faits on doit implémenter (même partiellement) une ou plusieurs de ses méthodes. Ce cas de figure est à réserver aux cas où la méthode "stubbée" et implémentée sera beaucoup plus basique que la méthode réelle et qu'il est absolument nécessaire que certaines opérations qu'elle ferait en réalité soient effectuées.(){ public Integer answer(InvocationOnMock invocation) throws Throwable { Personne p = (Personne)invocation.getArguments()[0]; //p.setNom(p.getNom().toUpperCase()); //A décommenter si l'effet de bord est important return p.getNom().length(); } }); assertEquals(mock.compteEtCapitalise(new Personne("toto")),4); assertEquals(mock.compteEtCapitalise(new Personne("marcel")),6); }
Néanmoins, il est peut-être plus logique de créer une implémentation "mock" de l'interface sans passer par Mockito. C'est à voir...
My name is Bond
Si vous avez bien suivi, le test suivant ne vous pose aucun problème:
@Test public void testAnotherAnswerIncoherence(){ List<String> mock = mock(List.class); mock.add("hello"); mock.add("bonjour"); verify(mock,times(2)).add(anyString()); assertEquals(mock.size(),0); }Malgré l'ajout de deux String à mon mock, l'appel de size renvoie 0. C'est normal: sans autre indication, un stub renvoie toujours une valeur nulle.
N'imaginez même pas utiliser la méthode doAnswer pour renvoyer un résultat cohérent, mais un espion peut vous aider.
@Test public void testSpyBehaviour(){ List<String> liste = new ArrayList<String>(); List<String> spy = spy(liste); spy.add("hello"); spy.add("bonjour"); verify(spy,times(2)).add(anyString()); assertEquals(spy.size(),2); }Ces deux exemples montre le fonctionnement différent des mocks et des spies. En pratique, on utilisera un spy lorsque l'instance existe déjà, et que l'on souhaite vérifier (verify) si elle a été appelée, mais aussi éventuellement modifier son comportement (stub).
Car l'espion n'est pas un mock mais il est possible de "stubber" ses méthodes:
@Test public void testSpyAndStub(){ List<String> liste = new ArrayList<String>(); List<String> spy = spy(liste); when(spy.size()).thenReturn(100); spy.add("hello"); spy.add("bonjour"); verify(spy,times(2)).add(anyString()); assertEquals(spy.size(),100); }Cette fois, le résultat affiché est 100. La méthode size réelle a été remplacée par un stub qui renvoie toujours 100. Intérêt? Aucun dans le cas présent...
Attention
Essayons ceci:
@Test public void testIllegalStubbingOnSpy(){ List<String> liste = new ArrayList<String>(); List<String> spy = spy(liste); when(spy.get(0)).thenReturn("hello"); assertEquals(spy.get(0),"hello"); }Ce test échoue en provoquant un IndexOutOfBoundsException sur le ligne du "when", car la méthode "get(0)" que l'on essaye de "stubber" est appelée sur une instance d'ArrayList réelle au moment où on essaye de la stubber, alors que la liste ne contient encore aucun élément. D'où l'exception.
La solution consiste à utiliser une autre forme de stubbing, le doReturn:
@Test public void testDoReturnStubbingOnSpy(){ ListCette manière de stubber est totalement différente, puisque get(0) n'est pas appelé sur la liste au moment de la définition du doReturn.liste = new ArrayList (); List spy = spy(liste); doReturn("hello").when(spy).get(0); assertEquals(spy.get(0),"hello"); }
On pourra aussi "stubber" une méthode qui renvoie void.
@Test public void testDoNothingOnSpy(){ ListIci, la méthode clear n'a pas été appelée sur le spy (et donc la classe réelle) lors de la déclaration du stub et donc elle n'est pas appelée réellement. Quant au stub, il ne fait rien et la liste espionnée n'est pas nettoyée.liste = new ArrayList (); List spy = spy(liste); spy.add("hello"); spy.add("bonjour"); doNothing().when(spy).clear(); spy.clear(); assertEquals(spy.size(),2); }
Pour terminer, un dernier test:
@Test public void testHowChangeEverything(){ final List<String> liste = new ArrayList<String>(); List<String> spy = spy(liste); doAnswer(new Answer<Object>(){ public Object answer(InvocationOnMock invocation) throws Throwable { String s = (String)invocation.getArguments()[0]; liste.add(s.toUpperCase()); return null; }} ).when(spy).add(anyString()); spy.add("bonjour"); assertEquals(liste.get(0),"BONJOUR"); }Ce test réécrit le comportement d'une méthode, à savoir add, pour qu'elle ajoute sur la liste espionnée, non pas la String originale, mais la String mise en majuscule.
Amusant, mais il y a d'autres moyens plus simples pour arriver au même résultat, sans passer par Mockito.
Moralité: il faut faire preuve de discernement.
Ca me rappelle ce développeur, dont les tests avec Mockito mockaient une interface, stubbaient ses méthodes et vérifiaient que les stubs renvoyait les réponses qu'ils avaient définies. Ils n'échouaient jamais...