Cet article est une version mise à jour d'un article initialement paru sur le Coin de l'architecte Java.
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é.
Vous me direz: "voilà un mock qui ne fait pas grand-chose". Vraiment? En fait, il en fait déjà pas mal.
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é".
Voici deux tests qui utilisent ce matcher:
@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();
Wanted 1 time:
-> at mockito.TestMock.testSomethingGoesWrongWhenVerifyingCallsOnMethodCallTwice(TestMock.java:87)
But was 2 times. Undesired invocation:
-> at mockito.TestMock.testSomethingGoesWrongWhenVerifyingCallsOnMethodCallTwice(TestMock.java:86)
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.
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)")
Il existe d'autres possibilités: voir les méthodes statiques de la classe
org.mockito.Mockito.
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 Answer(){
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);
}
On 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.
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(){
List liste = new ArrayList();
List spy = spy(liste);
doReturn("hello").when(spy).get(0);
assertEquals(spy.get(0),"hello");
}
Cette 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.
On pourra aussi "stubber" une méthode qui renvoie void.
@Test
public void testDoNothingOnSpy(){
List liste = new ArrayList();
List spy = spy(liste);
spy.add("hello");
spy.add("bonjour");
doNothing().when(spy).clear();
spy.clear();
assertEquals(spy.size(),2);
}
Ici, 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.
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...