Guide du praticien des tests unitaires sur le Mockito de tous les jours

Publié: 2022-03-11

Les tests unitaires sont devenus obligatoires à l'ère de l'Agile, et de nombreux outils sont disponibles pour faciliter les tests automatisés. L'un de ces outils est Mockito, un framework open source qui vous permet de créer et de configurer des objets simulés pour les tests.

Dans cet article, nous aborderons la création et la configuration de simulations et leur utilisation pour vérifier le comportement attendu du système testé. Nous plongerons également un peu dans les composants internes de Mockito pour mieux comprendre sa conception et ses mises en garde. Nous utiliserons JUnit comme framework de test unitaire, mais comme Mockito n'est pas lié à JUnit, vous pouvez suivre même si vous utilisez un framework différent.

Obtention de Mockito

Obtenir Mockito est facile de nos jours. Si vous utilisez Gradle, il s'agit d'ajouter cette seule ligne à votre script de construction :

 testCompile "org.mockito:mockito−core:2.7.7"

Quant à ceux qui, comme moi, préfèrent toujours Maven, ajoutez simplement Mockito à vos dépendances comme suit :

 <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>2.7.7</version> <scope>test</scope> </dependency>

Bien sûr, le monde est beaucoup plus vaste que Maven et Gradle. Vous êtes libre d'utiliser n'importe quel outil de gestion de projet pour récupérer l'artefact jar Mockito à partir du référentiel central Maven.

Approche de Mockito

Les tests unitaires sont conçus pour tester le comportement de classes ou de méthodes spécifiques sans dépendre du comportement de leurs dépendances. Puisque nous testons la plus petite "unité" de code, nous n'avons pas besoin d'utiliser les implémentations réelles de ces dépendances. De plus, nous utiliserons des implémentations légèrement différentes de ces dépendances lors du test de différents comportements. Une approche traditionnelle et bien connue consiste à créer des « stubs » - des implémentations spécifiques d'une interface adaptée à un scénario donné. De telles implémentations ont généralement une logique codée en dur. Un talon est une sorte de double test. D'autres types incluent les faux, les simulacres, les espions, les mannequins, etc.

Nous nous concentrerons uniquement sur deux types de doubles de test, les "simulacres" et les "espions", car ils sont largement utilisés par Mockito.

se moque

C'est quoi se moquer ? Évidemment, ce n'est pas là que vous vous moquez de vos collègues développeurs. La moquerie pour les tests unitaires consiste à créer un objet qui implémente le comportement d'un sous-système réel de manière contrôlée. En bref, les mocks sont utilisés en remplacement d'une dépendance.

Avec Mockito, vous créez une maquette, dites à Mockito quoi faire lorsque des méthodes spécifiques sont appelées dessus, puis utilisez l'instance fictive dans votre test au lieu de la vraie chose. Après le test, vous pouvez interroger la maquette pour voir quelles méthodes spécifiques ont été appelées ou vérifier les effets secondaires sous la forme d'un état modifié.

Par défaut, Mockito fournit une implémentation pour chaque méthode du mock.

Espions

Un espion est l'autre type de test double créé par Mockito. Contrairement aux simulations, la création d'un espion nécessite une instance à espionner. Par défaut, un espion délègue tous les appels de méthode à l'objet réel et enregistre quelle méthode a été appelée et avec quels paramètres. C'est ce qui en fait un espion : il espionne un objet réel.

Envisagez d'utiliser des simulations au lieu d'espions dans la mesure du possible. Les espions peuvent être utiles pour tester du code hérité qui ne peut pas être repensé pour être facilement testable, mais la nécessité d'utiliser un espion pour se moquer partiellement d'une classe est un indicateur qu'une classe en fait trop, violant ainsi le principe de responsabilité unique.

Construire un exemple simple

Jetons un coup d'œil à une démo simple pour laquelle nous pouvons écrire des tests. Supposons que nous ayons une interface UserRepository avec une seule méthode pour trouver un utilisateur par son identifiant. Nous avons également le concept d'un encodeur de mot de passe pour transformer un mot de passe en texte clair en un hachage de mot de passe. UserRepository et PasswordEncoder sont des dépendances (également appelées collaborateurs) de UserService injectées via le constructeur. Voici à quoi ressemble notre code de démonstration :

Référentiel utilisateur
 public interface UserRepository { User findById(String id); }
Utilisateur
 public class User { private String id; private String passwordHash; private boolean enabled; public User(String id, String passwordHash, boolean enabled) { this.id = id; this.passwordHash = passwordHash; this.enabled = enabled; } ... }
Codeur de mot de passe
 public interface PasswordEncoder { String encode(String password); }
UserService
 public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } public boolean isValidUser(String id, String password) { User user = userRepository.findById(id); return isEnabledUser(user) && isValidPassword(user, password); } private boolean isEnabledUser(User user) { return user != null && user.isEnabled(); } private boolean isValidPassword(User user, String password) { String encodedPassword = passwordEncoder.encode(password); return encodedPassword.equals(user.getPasswordHash()); } }

Cet exemple de code peut être trouvé sur GitHub, vous pouvez donc le télécharger pour le consulter avec cet article.

Application de Mockito

En utilisant notre exemple de code, regardons comment appliquer Mockito et écrivons quelques tests.

Création de maquettes

Avec Mockito, créer un mock est aussi simple que d'appeler une méthode statique Mockito.mock() :

 import static org.mockito.Mockito.*; ... PasswordEncoder passwordEncoder = mock(PasswordEncoder.class);

Notez l'importation statique pour Mockito. Pour la suite de cet article, nous considérerons implicitement cette importation ajoutée.

Après l'importation, nous simulons PasswordEncoder , une interface. Mockito se moque non seulement des interfaces, mais aussi des classes abstraites et des classes concrètes non finales. Prêt à l'emploi, Mockito ne peut pas se moquer des classes finales et des méthodes finales ou statiques, mais si vous en avez vraiment besoin, Mockito 2 fournit le plugin expérimental MockMaker.

Notez également que les méthodes equals() et hashCode() ne peuvent pas être simulées.

Créer des espions

Pour créer un espion, vous devez appeler la méthode statique spy() de Mockito et lui transmettre une instance à espionner. L'appel des méthodes de l'objet renvoyé appellera les méthodes réelles à moins que ces méthodes ne soient stubées. Ces appels sont enregistrés et les faits de ces appels peuvent être vérifiés (voir la description plus détaillée de verify() ). Faisons un espion :

 DecimalFormat decimalFormat = spy(new DecimalFormat()); assertEquals("42", decimalFormat.format(42L));

Créer un espion ne diffère pas beaucoup de créer une simulation. De plus, toutes les méthodes Mockito utilisées pour configurer un mock sont également applicables à la configuration d'un espion.

Les espions sont rarement utilisés par rapport aux simulations, mais vous pouvez les trouver utiles pour tester le code hérité qui ne peut pas être refactorisé, où le test nécessite une simulation partielle. Dans ces cas, vous pouvez simplement créer un espion et remplacer certaines de ses méthodes pour obtenir le comportement souhaité.

Valeurs de retour par défaut

L'appel mock(PasswordEncoder.class) renvoie une instance de PasswordEncoder . On peut même appeler ses méthodes, mais que retourneront-elles ? Par défaut, toutes les méthodes d'un mock renvoient des valeurs "non initialisées" ou "vides", par exemple, des zéros pour les types numériques (à la fois primitifs et encadrés), faux pour les booléens et nuls pour la plupart des autres types.

Considérez l'interface suivante :

 interface Demo { int getInt(); Integer getInteger(); double getDouble(); boolean getBoolean(); String getObject(); Collection<String> getCollection(); String[] getArray(); Stream<?> getStream(); Optional<?> getOptional(); }

Considérons maintenant l'extrait de code suivant, qui donne une idée des valeurs par défaut à attendre des méthodes d'un mock :

 Demo demo = mock(Demo.class); assertEquals(0, demo.getInt()); assertEquals(0, demo.getInteger().intValue()); assertEquals(0d, demo.getDouble(), 0d); assertFalse(demo.getBoolean()); assertNull(demo.getObject()); assertEquals(Collections.emptyList(), demo.getCollection()); assertNull(demo.getArray()); assertEquals(0L, demo.getStream().count()); assertFalse(demo.getOptional().isPresent());

Méthodes de remplacement

Les simulations fraîches et non modifiées ne sont utiles que dans de rares cas. Habituellement, nous voulons configurer le mock et définir ce qu'il faut faire lorsque des méthodes spécifiques du mock sont appelées. C'est ce qu'on appelle l'écrasement .

Mockito propose deux méthodes de stub. La première façon est " quand cette méthode est appelée, alors faites quelque chose." Considérez l'extrait suivant :

 when(passwordEncoder.encode("1")).thenReturn("a");

Il se lit presque comme l'anglais : "Lorsque passwordEncoder.encode(“1”) est appelé, renvoie un a ."

La deuxième façon de stub se lit plus comme "Faire quelque chose lorsque la méthode de ce mock est appelée avec les arguments suivants." Cette façon de stub est plus difficile à lire car la cause est précisée à la fin. Considérer:

 doReturn("a").when(passwordEncoder).encode("1");

L'extrait de code avec cette méthode de remplacement se lirait : "Return a when passwordEncoder 's encode() method is called with an argument of 1 ."

La première méthode est considérée comme préférée car elle est typée et parce qu'elle est plus lisible. Rarement, cependant, vous êtes obligé d'utiliser la deuxième méthode, comme lorsque vous supprimez une véritable méthode d'un espion, car l'appeler peut avoir des effets secondaires indésirables.

Explorons brièvement les méthodes de stubbing fournies par Mockito. Nous inclurons les deux manières de stub dans nos exemples.

Valeurs renvoyées

thenReturn ou doReturn() sont utilisés pour spécifier une valeur à renvoyer lors de l'invocation de la méthode.

 //”when this method is called, then do something” when(passwordEncoder.encode("1")).thenReturn("a");

ou

 //”do something when this mock's method is called with the following arguments” doReturn("a").when(passwordEncoder).encode("1");

Vous pouvez également spécifier plusieurs valeurs qui seront renvoyées comme résultats d'appels de méthode consécutifs. La dernière valeur sera utilisée comme résultat pour tous les autres appels de méthode.

 //when when(passwordEncoder.encode("1")).thenReturn("a", "b");

ou

 //do doReturn("a", "b").when(passwordEncoder).encode("1");

La même chose peut être obtenue avec l'extrait suivant :

 when(passwordEncoder.encode("1")) .thenReturn("a") .thenReturn("b");

Ce modèle peut également être utilisé avec d'autres méthodes de substitution pour définir les résultats d'appels consécutifs.

Renvoyer des réponses personnalisées

then() , un alias pour thenAnswer() et doAnswer() réalisent la même chose, qui est de configurer une réponse personnalisée à renvoyer lorsqu'une méthode est appelée, comme ceci :

 when(passwordEncoder.encode("1")).thenAnswer( invocation -> invocation.getArgument(0) + "!");

ou

 doAnswer(invocation -> invocation.getArgument(0) + "!") .when(passwordEncoder).encode("1");

Le seul argument que thenAnswer() prend est une implémentation de l'interface Answer . Il a une seule méthode avec un paramètre de type InvocationOnMock .

Vous pouvez également lever une exception à la suite d'un appel de méthode :

 when(passwordEncoder.encode("1")).thenAnswer(invocation -> { throw new IllegalArgumentException(); });

…ou appeler la vraie méthode d'une classe (non applicable aux interfaces) :

 Date mock = mock(Date.class); doAnswer(InvocationOnMock::callRealMethod).when(mock).setTime(42); doAnswer(InvocationOnMock::callRealMethod).when(mock).getTime(); mock.setTime(42); assertEquals(42, mock.getTime());

Vous avez raison si vous pensez que cela semble encombrant. Mockito fournit thenCallRealMethod() et thenThrow() pour rationaliser cet aspect de vos tests.

Appel de méthodes réelles

Comme son nom l'indique, thenCallRealMethod() et doCallRealMethod() appellent la méthode réelle sur un objet factice :

 Date mock = mock(Date.class); when(mock.getTime()).thenCallRealMethod(); doCallRealMethod().when(mock).setTime(42); mock.setTime(42); assertEquals(42, mock.getTime());

L'appel de méthodes réelles peut être utile sur des simulations partielles, mais assurez-vous que la méthode appelée n'a pas d'effets secondaires indésirables et ne dépend pas de l'état de l'objet. Si c'est le cas, un espion peut convenir mieux qu'un simulacre.

Si vous créez une maquette d'interface et essayez de configurer un stub pour appeler une méthode réelle, Mockito lèvera une exception avec un message très informatif. Considérez l'extrait suivant :

 when(passwordEncoder.encode("1")).thenCallRealMethod();

Mockito échouera avec le message suivant :

 Cannot call abstract real method on java object! Calling real methods is only possible when mocking non abstract method. //correct example: when(mockOfConcreteClass.nonAbstractMethod()).thenCallRealMethod();

Félicitations aux développeurs de Mockito pour leur attention suffisante pour fournir des descriptions aussi complètes !

Lancer des exceptions

thenThrow() et doThrow() configurent une méthode simulée pour lancer une exception :

 when(passwordEncoder.encode("1")).thenThrow(new IllegalArgumentException());

ou

 doThrow(new IllegalArgumentException()).when(passwordEncoder).encode("1");

Mockito s'assure que l'exception levée est valide pour cette méthode stub spécifique et se plaindra si l'exception ne figure pas dans la liste des exceptions vérifiées de la méthode. Considérer ce qui suit:

 when(passwordEncoder.encode("1")).thenThrow(new IOException());

Cela conduira à une erreur :

 org.mockito.exceptions.base.MockitoException: Checked exception is invalid for this method! Invalid: java.io.IOException

Comme vous pouvez le voir, Mockito a détecté que encode() ne peut pas lancer une IOException .

Vous pouvez également transmettre la classe d'une exception au lieu de transmettre une instance d'une exception :

 when(passwordEncoder.encode("1")).thenThrow(IllegalArgumentException.class);

ou

 doThrow(IllegalArgumentException.class).when(passwordEncoder).encode("1");

Cela dit, Mockito ne peut pas valider une classe d'exception de la même manière qu'il validera une instance d'exception, vous devez donc être discipliné et ne pas transmettre d'objets de classe illégaux. Par exemple, ce qui suit IOException bien que encode() ne soit pas censé lever une exception vérifiée :

 when(passwordEncoder.encode("1")).thenThrow(IOException.class); passwordEncoder.encode("1");

Interfaces simulées avec des méthodes par défaut

Il convient de noter que lors de la création d'une maquette pour une interface, Mockito se moque de toutes les méthodes de cette interface. Depuis Java 8, les interfaces peuvent contenir des méthodes par défaut ainsi que des méthodes abstraites. Ces méthodes sont également moquées, vous devez donc veiller à les faire agir comme méthodes par défaut.

Considérez l'exemple suivant :

 interface AnInterface { default boolean isTrue() { return true; } } AnInterface mock = mock(AnInterface.class); assertFalse(mock.isTrue());

Dans cet exemple, assertFalse() réussira. Si ce n'est pas ce à quoi vous vous attendiez, assurez-vous que Mockito a appelé la vraie méthode, comme ceci :

 AnInterface mock = mock(AnInterface.class); when(mock.isTrue()).thenCallRealMethod(); assertTrue(mock.isTrue());

Correspondeurs d'arguments

Dans les sections précédentes, nous avons configuré nos méthodes fictives avec des valeurs exactes comme arguments. Dans ces cas, Mockito appelle simplement equals() en interne pour vérifier si les valeurs attendues sont égales aux valeurs réelles.

Parfois, cependant, nous ne connaissons pas ces valeurs à l'avance.

Peut-être que nous ne nous soucions tout simplement pas de la valeur réelle transmise en tant qu'argument, ou peut-être voulons-nous définir une réaction pour une plage de valeurs plus large. Tous ces scénarios (et bien d'autres) peuvent être traités avec des matchers d'arguments. L'idée est simple : au lieu de fournir une valeur exacte, vous fournissez un comparateur d'arguments pour que Mockito compare les arguments de la méthode.

Considérez l'extrait suivant :

 when(passwordEncoder.encode(anyString())).thenReturn("exact"); assertEquals("exact", passwordEncoder.encode("1")); assertEquals("exact", passwordEncoder.encode("abc"));

Vous pouvez voir que le résultat est le même quelle que soit la valeur que nous passons à encode() car nous avons utilisé le matcher d'argument anyString() dans cette première ligne. Si nous réécrivons cette ligne en anglais simple, cela ressemblerait à "lorsque l'encodeur de mot de passe est invité à encoder n'importe quelle chaîne, puis renvoie la chaîne 'exact'".

Mockito vous demande de fournir tous les arguments soit par matchers , soit par valeurs exactes. Donc, si une méthode a plus d'un argument et que vous souhaitez utiliser des comparateurs d'arguments pour seulement certains de ses arguments, oubliez-le. Vous ne pouvez pas écrire de code comme celui-ci :

 abstract class AClass { public abstract boolean call(String s, int i); } AClass mock = mock(AClass.class); //This doesn't work. when(mock.call("a", anyInt())).thenReturn(true);

Pour corriger l'erreur, nous devons remplacer la dernière ligne pour inclure le matcher d'argument eq pour a , comme suit :

 when(mock.call(eq("a"), anyInt())).thenReturn(true);

Ici, nous avons utilisé les matchers d'arguments eq() et anyInt() , mais il y en a beaucoup d'autres disponibles. Pour une liste complète des matchers d'arguments, reportez-vous à la documentation sur la classe org.mockito.ArgumentMatchers .

Il est important de noter que vous ne pouvez pas utiliser de comparateurs d'arguments en dehors de la vérification ou du remplacement. Par exemple, vous ne pouvez pas avoir les éléments suivants :

 //this won't work String orMatcher = or(eq("a"), endsWith("b")); verify(mock).encode(orMatcher);

Mockito détectera le matcher d'argument mal placé et lancera une InvalidUseOfMatchersException . La vérification avec les matchers d'arguments doit être effectuée de la manière suivante :

 verify(mock).encode(or(eq("a"), endsWith("b")));

Les correspondances d'arguments ne peuvent pas non plus être utilisées comme valeur de retour. Mockito ne peut pas retourner anyString() ou n'importe quoi ; une valeur exacte est requise lors du remplacement d'appels.

Correspondants personnalisés

Les matchers personnalisés viennent à la rescousse lorsque vous devez fournir une logique de correspondance qui n'est pas déjà disponible dans Mockito. La décision de créer un matcher personnalisé ne doit pas être prise à la légère car la nécessité de faire correspondre les arguments de manière non triviale indique soit un problème de conception, soit qu'un test devient trop compliqué.

En tant que tel, il vaut la peine de vérifier si vous pouvez simplifier un test en utilisant certains des comparateurs d'arguments indulgents tels que isNull() et nullable() avant d'écrire un matcher personnalisé. Si vous ressentez toujours le besoin d'écrire un matcher d'arguments, Mockito fournit une famille de méthodes pour le faire.

Considérez l'exemple suivant :

 FileFilter fileFilter = mock(FileFilter.class); ArgumentMatcher<File> hasLuck = file -> file.getName().endsWith("luck"); when(fileFilter.accept(argThat(hasLuck))).thenReturn(true); assertFalse(fileFilter.accept(new File("/deserve"))); assertTrue(fileFilter.accept(new File("/deserve/luck")));

Ici, nous créons le matcher d'argument hasLuck et utilisons argThat() pour passer le matcher comme argument à une méthode simulée, en le remplaçant pour qu'il renvoie true si le nom de fichier se termine par "chance". Vous pouvez traiter ArgumentMatcher comme une interface fonctionnelle et créer son instance avec un lambda (ce que nous avons fait dans l'exemple). Une syntaxe moins concise ressemblerait à :

 ArgumentMatcher<File> hasLuck = new ArgumentMatcher<File>() { @Override public boolean matches(File file) { return file.getName().endsWith("luck"); } };

Si vous avez besoin de créer un matcher d'arguments qui fonctionne avec des types primitifs, il existe plusieurs autres méthodes pour cela dans org.mockito.ArgumentMatchers :

  • charThat(ArgumentMatcher<Character> matcher)
  • booleanThat(ArgumentMatcher<Boolean> matcher)
  • byteThat(ArgumentMatcher<Byte> matcher)
  • shortThat(ArgumentMatcher<Short> matcher)
  • intThat(ArgumentMatcher<Integer> matcher)
  • longThat(ArgumentMatcher<Long> matcher)
  • floatThat(ArgumentMatcher<Float> matcher)
  • doubleThat(ArgumentMatcher<Double> matcher)

Combiner les correspondants

Il n'est pas toujours utile de créer un matcher d'arguments personnalisé lorsqu'une condition est trop compliquée pour être gérée avec des matchers de base ; parfois combiner des matchers fera l'affaire. Mockito fournit des comparateurs d'arguments pour implémenter des opérations logiques communes ("non", "et", "ou") sur des comparateurs d'arguments qui correspondent à la fois aux types primitifs et non primitifs. Ces matchers sont implémentés en tant que méthodes statiques dans la classe org.mockito.AdditionalMatchers .

Considérez l'exemple suivant :

 when(passwordEncoder.encode(or(eq("1"), contains("a")))).thenReturn("ok"); assertEquals("ok", passwordEncoder.encode("1")); assertEquals("ok", passwordEncoder.encode("123abc")); assertNull(passwordEncoder.encode("123"));

Ici, nous avons combiné les résultats de deux matchers d'arguments : eq("1") et contains("a") . L'expression finale, or(eq("1"), contains("a")) , peut être interprétée comme "la chaîne d'argument doit être égale à "1" ou contenir "a".

Notez qu'il existe des comparateurs moins courants répertoriés dans la classe org.mockito.AdditionalMatchers , tels que geq() , leq() , gt() et lt() , qui sont des comparaisons de valeurs applicables aux valeurs primitives et aux instances de java.lang.Comparable .

Vérification du comportement

Une fois qu'une simulation ou un espion a été utilisé, nous pouvons verify que des interactions spécifiques ont eu lieu. Littéralement, nous disons "Hé, Mockito, assurez-vous que cette méthode a été appelée avec ces arguments."

Considérons l'exemple artificiel suivant :

 PasswordEncoder passwordEncoder = mock(PasswordEncoder.class); when(passwordEncoder.encode("a")).thenReturn("1"); passwordEncoder.encode("a"); verify(passwordEncoder).encode("a");

Ici, nous avons mis en place une simulation et appelé sa méthode encode() . La dernière ligne vérifie que la méthode encode() du mock a été appelée avec la valeur d'argument spécifique a . Veuillez noter que la vérification d'une invocation stub est redondante ; le but de l'extrait précédent est de montrer l'idée de faire une vérification après que certaines interactions se soient produites.

Si nous modifions la dernière ligne pour avoir un argument différent - disons, b - le test précédent échouera et Mockito se plaindra que l'invocation réelle a des arguments différents ( b au lieu du a attendu).

Les comparateurs d'arguments peuvent être utilisés pour la vérification comme pour le stub :

 verify(passwordEncoder).encode(anyString());

Par défaut, Mockito vérifie que la méthode a été appelée une fois, mais vous pouvez vérifier n'importe quel nombre d'invocations :

 // verify the exact number of invocations verify(passwordEncoder, times(42)).encode(anyString()); // verify that there was at least one invocation verify(passwordEncoder, atLeastOnce()).encode(anyString()); // verify that there were at least five invocations verify(passwordEncoder, atLeast(5)).encode(anyString()); // verify the maximum number of invocations verify(passwordEncoder, atMost(5)).encode(anyString()); // verify that it was the only invocation and // that there're no more unverified interactions verify(passwordEncoder, only()).encode(anyString()); // verify that there were no invocations verify(passwordEncoder, never()).encode(anyString());

Une fonctionnalité rarement utilisée de verify() est sa capacité à échouer sur un délai d'attente, ce qui est principalement utile pour tester du code concurrent. Par exemple, si notre encodeur de mot de passe est appelé dans un autre thread en même temps que verify() , nous pouvons écrire un test comme suit :

 usePasswordEncoderInOtherThread(); verify(passwordEncoder, timeout(500)).encode("a");

Ce test réussira si encode() est appelé et terminé en 500 millisecondes ou moins. Si vous devez attendre la période complète que vous spécifiez, utilisez after() au lieu de timeout() :

 verify(passwordEncoder, after(500)).encode("a");

D'autres modes de vérification ( times() , atLeast() , etc) peuvent être combinés avec timeout() et after() pour faire des tests plus compliqués :

 // passes as soon as encode() has been called 3 times within 500 ms verify(passwordEncoder, timeout(500).times(3)).encode("a");

Outre times() , les modes de vérification pris en charge incluent only() , atLeast() et atLeastOnce() (en tant qu'alias de atLeast(1) ).

Mockito vous permet également de vérifier l'ordre des appels dans un groupe de simulations. Ce n'est pas une fonctionnalité à utiliser très souvent, mais elle peut être utile si l'ordre des invocations est important. Considérez l'exemple suivant :

 PasswordEncoder first = mock(PasswordEncoder.class); PasswordEncoder second = mock(PasswordEncoder.class); // simulate calls first.encode("f1"); second.encode("s1"); first.encode("f2"); // verify call order InOrder inOrder = inOrder(first, second); inOrder.verify(first).encode("f1"); inOrder.verify(second).encode("s1"); inOrder.verify(first).encode("f2");

Si nous réorganisons l'ordre des appels simulés, le test échouera avec VerificationInOrderFailure .

L'absence d'invocations peut également être vérifiée à l'aide verifyZeroInteractions() . Cette méthode accepte un mock ou des mocks comme argument et échouera si des méthodes du/des mock(s) passé(s) ont été appelées.

Il convient également de mentionner la méthode verifyNoMoreInteractions() , car elle prend des simulacres comme argument et peut être utilisée pour vérifier que chaque appel sur ces simulacres a été vérifié.

Saisir les arguments

En plus de vérifier qu'une méthode a été appelée avec des arguments spécifiques, Mockito vous permet de capturer ces arguments afin que vous puissiez ensuite exécuter des assertions personnalisées sur eux. En d'autres termes, vous dites "Hé, Mockito, vérifie que cette méthode a été appelée et donne-moi les valeurs d'argument avec lesquelles elle a été appelée."

Créons une maquette de PasswordEncoder , appelons encode() , capturons l'argument et vérifions sa valeur :

 PasswordEncoder passwordEncoder = mock(PasswordEncoder.class); passwordEncoder.encode("password"); ArgumentCaptor<String> passwordCaptor = ArgumentCaptor.forClass(String.class); verify(passwordEncoder).encode(passwordCaptor.capture()); assertEquals("password", passwordCaptor.getValue());

Comme vous pouvez le voir, nous passons passwordCaptor.capture() comme argument de encode() pour vérification ; cela crée en interne un matcher d'argument qui enregistre l'argument. Ensuite, nous récupérons la valeur capturée avec passwordCaptor.getValue() et l'inspectons avec assertEquals() .

Si nous devons capturer un argument sur plusieurs appels, ArgumentCaptor vous permet de récupérer toutes les valeurs avec getAllValues() , comme ceci :

 PasswordEncoder passwordEncoder = mock(PasswordEncoder.class); passwordEncoder.encode("password1"); passwordEncoder.encode("password2"); passwordEncoder.encode("password3"); ArgumentCaptor<String> passwordCaptor = ArgumentCaptor.forClass(String.class); verify(passwordEncoder, times(3)).encode(passwordCaptor.capture()); assertEquals(Arrays.asList("password1", "password2", "password3"), passwordCaptor.getAllValues());

La même technique peut être utilisée pour capturer les arguments de la méthode d'arité variable (également appelés varargs).

Test de notre exemple simple

Maintenant que nous en savons beaucoup plus sur Mockito, il est temps de revenir à notre démo. Écrivons le test de la méthode isValidUser . Voici à quoi cela pourrait ressembler :

 public class UserServiceTest { private static final String PASSWORD = "password"; private static final User ENABLED_USER = new User("user id", "hash", true); private static final User DISABLED_USER = new User("disabled user id", "disabled user password hash", false); private UserRepository userRepository; private PasswordEncoder passwordEncoder; private UserService userService; @Before public void setup() { userRepository = createUserRepository(); passwordEncoder = createPasswordEncoder(); userService = new UserService(userRepository, passwordEncoder); } @Test public void shouldBeValidForValidCredentials() { boolean userIsValid = userService.isValidUser(ENABLED_USER.getId(), PASSWORD); assertTrue(userIsValid); // userRepository had to be used to find a user with verify(userRepository).findById(ENABLED_USER.getId()); // passwordEncoder had to be used to compute a hash of "password" verify(passwordEncoder).encode(PASSWORD); } @Test public void shouldBeInvalidForInvalidId() { boolean userIsValid = userService.isValidUser("invalid id", PASSWORD); assertFalse(userIsValid); InOrder inOrder = inOrder(userRepository, passwordEncoder); inOrder.verify(userRepository).findById("invalid id"); inOrder.verify(passwordEncoder, never()).encode(anyString()); } @Test public void shouldBeInvalidForInvalidPassword() { boolean userIsValid = userService.isValidUser(ENABLED_USER.getId(), "invalid"); assertFalse(userIsValid); ArgumentCaptor<String> passwordCaptor = ArgumentCaptor.forClass(String.class); verify(passwordEncoder).encode(passwordCaptor.capture()); assertEquals("invalid", passwordCaptor.getValue()); } @Test public void shouldBeInvalidForDisabledUser() { boolean userIsValid = userService.isValidUser(DISABLED_USER.getId(), PASSWORD); assertFalse(userIsValid); verify(userRepository).findById(DISABLED_USER.getId()); verifyZeroInteractions(passwordEncoder); } private PasswordEncoder createPasswordEncoder() { PasswordEncoder mock = mock(PasswordEncoder.class); when(mock.encode(anyString())).thenReturn("any password hash"); when(mock.encode(PASSWORD)).thenReturn(ENABLED_USER.getPasswordHash()); return mock; } private UserRepository createUserRepository() { UserRepository mock = mock(UserRepository.class); when(mock.findById(ENABLED_USER.getId())).thenReturn(ENABLED_USER); when(mock.findById(DISABLED_USER.getId())).thenReturn(DISABLED_USER); return mock; } }

Plonger sous l'API

Mockito fournit une API lisible et pratique, mais explorons certains de ses fonctionnements internes afin de comprendre ses limites et d'éviter les erreurs étranges.

Examinons ce qui se passe dans Mockito lorsque l'extrait suivant est exécuté :

 // 1: create PasswordEncoder mock = mock(PasswordEncoder.class); // 2: stub when(mock.encode("a")).thenReturn("1"); // 3: act mock.encode("a"); // 4: verify verify(mock).encode(or(eq("a"), endsWith("b")));

De toute évidence, la première ligne crée une simulation. Mockito utilise ByteBuddy pour créer une sous-classe de la classe donnée. Le nouvel objet de classe a un nom généré comme demo.mockito.PasswordEncoder$MockitoMock$1953422997 , son equals() agira comme vérification d'identité et hashCode() renverra un code de hachage d'identité. Une fois la classe générée et chargée, son instance est créée à l'aide d'Objenesis.

Regardons la ligne suivante :

 when(mock.encode("a")).thenReturn("1");

L'ordre est important : la première instruction exécutée ici est mock.encode("a") , qui invoquera encode() sur le mock avec une valeur de retour par défaut de null . Donc vraiment, nous passons null comme argument de when() . Mockito ne se soucie pas de la valeur exacte transmise à when() car il stocke des informations sur l'invocation d'une méthode simulée dans ce que l'on appelle un "stubbing continu" lorsque cette méthode est invoquée. Plus tard, lorsque nous appelons when() , Mockito récupère cet objet de remplacement en cours et le renvoie comme résultat de when() . Ensuite, nous appelons thenReturn(“1”) sur l'objet de remplacement en cours retourné.

La troisième ligne, mock.encode("a"); est simple : nous appelons la méthode stubbed. En interne, Mockito enregistre cette invocation pour une vérification ultérieure et renvoie la réponse d'invocation tronquée ; dans notre cas, c'est la chaîne 1 .

Dans la quatrième ligne ( verify(mock).encode(or(eq("a"), endsWith("b"))); ), nous demandons à Mockito de vérifier qu'il y a eu une invocation de encode() avec ces arguments spécifiques.

verify() est exécuté en premier, ce qui transforme l'état interne de Mockito en mode vérification. Il est important de comprendre que Mockito conserve son état dans un ThreadLocal . Cela permet d'implémenter une belle syntaxe mais, d'un autre côté, cela peut conduire à un comportement bizarre si le framework est mal utilisé (si vous essayez d'utiliser des matchers d'arguments en dehors de la vérification ou du stubbing, par exemple).

Alors, comment Mockito crée-t-il un or matcher ? Tout d'abord, eq("a") est appelé, et un matcher equals est ajouté à la pile des matchers. Deuxièmement, endsWith("b") est appelé et un matcher endsWith est ajouté à la pile. Enfin, or(null, null) est appelé - il utilise les deux matchers qu'il extrait de la pile, crée le or matcher et le pousse vers la pile. Enfin, encode() est appelé. Mockito vérifie ensuite que la méthode a été invoquée le nombre de fois attendu et avec les arguments attendus.

Bien que les matchers d'arguments ne puissent pas être extraits en variables (car cela modifie l'ordre d'appel), ils peuvent être extraits en méthodes. Cela préserve l'ordre des appels et maintient la pile dans le bon état :

 verify(mock).encode(matchCondition()); … String matchCondition() { return or(eq("a"), endsWith("b")); }

Modification des réponses par défaut

Dans les sections précédentes, nous avons créé nos simulacres de telle sorte que lorsque des méthodes simulées sont appelées, elles renvoient une valeur "vide". Ce comportement est configurable. Vous pouvez même fournir votre propre implémentation de org.mockito.stubbing.Answer si celles fournies par Mockito ne conviennent pas, mais cela peut indiquer que quelque chose ne va pas lorsque les tests unitaires deviennent trop compliqués. N'oubliez pas le principe KISS !

Explorons l'offre de Mockito de réponses par défaut prédéfinies :

  • RETURNS_DEFAULTS est la stratégie par défaut ; cela ne vaut pas la peine d'être mentionné explicitement lors de la configuration d'une simulation.

  • CALLS_REAL_METHODS fait en sorte que les invocations sans stub appellent des méthodes réelles.

  • RETURNS_SMART_NULLS évite une NullPointerException en renvoyant SmartNull au lieu de null lors de l'utilisation d'un objet renvoyé par un appel de méthode non stubbed. Vous échouerez toujours avec une NullPointerException , mais SmartNull vous donne une trace de pile plus agréable avec la ligne où la méthode non stubbed a été appelée. Cela vaut la peine d'avoir RETURNS_SMART_NULLS comme réponse par défaut dans Mockito !

  • RETURNS_MOCKS essaie d'abord de renvoyer des valeurs "vides" ordinaires, puis se moque, si possible, et null sinon. Le critère de vide diffère un peu de ce que nous avons vu précédemment : au lieu de renvoyer null pour les chaînes et les tableaux, les simulations créées avec RETURNS_MOCKS renvoient respectivement des chaînes vides et des tableaux vides.

  • RETURNS_SELF est utile pour se moquer des constructeurs. Avec ce paramètre, un simulacre renverra une instance de lui-même si une méthode est appelée qui renvoie quelque chose d'un type égal à la classe (ou une superclasse) de la classe simulée.

  • RETURNS_DEEP_STUBS va plus loin que RETURNS_MOCKS et crée des mocks capables de renvoyer des mocks à partir de mocks à partir de mocks, etc. Contrairement à RETURNS_MOCKS , les règles de vide sont par défaut dans RETURNS_DEEP_STUBS , il renvoie donc null pour les chaînes et les tableaux :

 interface We { Are we(); } interface Are { So are(); } interface So { Deep so(); } interface Deep { boolean deep(); } ... We mock = mock(We.class, Mockito.RETURNS_DEEP_STUBS); when(mock.we().are().so().deep()).thenReturn(true); assertTrue(mock.we().are().so().deep());

Nommer une maquette

Mockito vous permet de nommer un mock, une fonctionnalité utile si vous avez beaucoup de mocks dans un test et que vous avez besoin de les distinguer. Cela dit, avoir besoin de nommer des simulacres peut être le symptôme d'une mauvaise conception. Considérer ce qui suit:

 PasswordEncoder robustPasswordEncoder = mock(PasswordEncoder.class); PasswordEncoder weakPasswordEncoder = mock(PasswordEncoder.class); verify(robustPasswordEncoder).encode(anyString());

Mockito se plaindra, mais comme nous n'avons pas officiellement nommé les simulacres, nous ne savons pas lequel :

 Wanted but not invoked: passwordEncoder.encode(<any string>);

Nommons-les en passant une chaîne sur la construction :

 PasswordEncoder robustPasswordEncoder = mock(PasswordEncoder.class, "robustPasswordEncoder"); PasswordEncoder weakPasswordEncoder = mock(PasswordEncoder.class, "weakPasswordEncoder"); verify(robustPasswordEncoder).encode(anyString());

Maintenant, le message d'erreur est plus convivial et pointe clairement vers robustPasswordEncoder :

 Wanted but not invoked: robustPasswordEncoder.encode(<any string>);

Implémentation de plusieurs interfaces fictives

Sometimes, you may wish to create a mock that implements several interfaces. Mockito is able to do that easily, like so:

 PasswordEncoder mock = mock( PasswordEncoder.class, withSettings().extraInterfaces(List.class, Map.class)); assertTrue(mock instanceof List); assertTrue(mock instanceof Map);

Listening Invocations

A mock can be configured to call an invocation listener every time a method of the mock was called. Inside the listener, you can find out whether the invocation produced a value or if an exception was thrown.

 InvocationListener invocationListener = new InvocationListener() { @Override public void reportInvocation(MethodInvocationReport report) { if (report.threwException()) { Throwable throwable = report.getThrowable(); // do something with throwable throwable.printStackTrace(); } else { Object returnedValue = report.getReturnedValue(); // do something with returnedValue System.out.println(returnedValue); } } }; PasswordEncoder passwordEncoder = mock( PasswordEncoder.class, withSettings().invocationListeners(invocationListener)); passwordEncoder.encode("1");

In this example, we're dumping either the returned value or a stack trace to a system output stream. Our implementation does roughly the same as Mockito's org.mockito.internal.debugging.VerboseMockInvocationLogger (don't use this directly, it's internal stuff). If logging invocations is the only feature you need from the listener, then Mockito provides a cleaner way to express your intent with the verboseLogging() setting:

 PasswordEncoder passwordEncoder = mock( PasswordEncoder.class, withSettings().verboseLogging());

Take notice, though, that Mockito will call the listeners even when you're stubbing methods. Considérez l'exemple suivant :

 PasswordEncoder passwordEncoder = mock( PasswordEncoder.class, withSettings().verboseLogging()); // listeners are called upon encode() invocation when(passwordEncoder.encode("1")).thenReturn("encoded1"); passwordEncoder.encode("1"); passwordEncoder.encode("2");

This snippet will produce an output similar to the following:

 ############ Logging method invocation #1 on mock/spy ######## passwordEncoder.encode("1"); invoked: -> at demo.mockito.MockSettingsTest.verboseLogging(MockSettingsTest.java:85) has returned: "null" ############ Logging method invocation #2 on mock/spy ######## stubbed: -> at demo.mockito.MockSettingsTest.verboseLogging(MockSettingsTest.java:85) passwordEncoder.encode("1"); invoked: -> at demo.mockito.MockSettingsTest.verboseLogging(MockSettingsTest.java:89) has returned: "encoded1" (java.lang.String) ############ Logging method invocation #3 on mock/spy ######## passwordEncoder.encode("2"); invoked: -> at demo.mockito.MockSettingsTest.verboseLogging(MockSettingsTest.java:90) has returned: "null"

Note that the first logged invocation corresponds to calling encode() while stubbing it. It's the next invocation that corresponds to calling the stubbed method.

Other Settings

Mockito offers a few more settings that let you do the following:

  • Enable mock serialization by using withSettings().serializable() .
  • Turn off recording of method invocations to save memory (this will make verification impossible) by using withSettings().stubOnly() .
  • Use the constructor of a mock when creating its instance by using withSettings().useConstructor() . When mocking inner non-static classes, add an outerInstance() setting, like so: withSettings().useConstructor().outerInstance(outerObject) .

If you need to create a spy with custom settings (such as a custom name), there's a spiedInstance() setting, so that Mockito will create a spy on the instance you provide, like so:

 UserService userService = new UserService( mock(UserRepository.class), mock(PasswordEncoder.class)); UserService userServiceMock = mock( UserService.class, withSettings().spiedInstance(userService).name("coolService"));

When a spied instance is specified, Mockito will create a new instance and populate its non-static fields with values from the original object. That's why it's important to use the returned instance: Only its method calls can be stubbed and verified.

Note that, when you create a spy, you're basically creating a mock that calls real methods:

 // creating a spy this way... spy(userService); // ... is a shorthand for mock(UserService.class, withSettings() .spiedInstance(userService) .defaultAnswer(CALLS_REAL_METHODS));

When Mockito Tastes Bad

It's our bad habits that make our tests complex and unmaintainable, not Mockito. For example, you may feel the need to mock everything. This kind of thinking leads to testing mocks instead of production code. Mocking third-party APIs can also be dangerous due to potential changes in that API that can break the tests.

Though bad taste is a matter of perception, Mockito provides a few controversial features that can make your tests less maintainable. Sometimes stubbing isn't trivial, or an abuse of dependency injection can make recreating mocks for each test difficult, unreasonable or inefficient.

Clearing Invocations

Mockito allows for clearing invocations for mocks while preserving stubbing, like so:

 PasswordEncoder passwordEncoder = mock(PasswordEncoder.class); UserRepository userRepository = mock(UserRepository.class); // use mocks passwordEncoder.encode(null); userRepository.findById(null); // clear clearInvocations(passwordEncoder, userRepository); // succeeds because invocations were cleared verifyZeroInteractions(passwordEncoder, userRepository);

Resort to clearing invocations only if recreating a mock would lead to significant overhead or if a configured mock is provided by a dependency injection framework and stubbing is non-trivial.

Resetting a Mock

Resetting a mock with reset() is another controversial feature and should be used in extremely rare cases, like when a mock is injected by a container and you can't recreate it for each test.

Overusing Verify

Another bad habit is trying to replace every assert with Mockito's verify() . It's important to clearly understand what is being tested: interactions between collaborators can be checked with verify() , while confirming the observable results of an executed action is done with asserts.

Mockito Is about Frame of Mind

Using Mockito is not just a matter of adding another dependency, it requires changing how you think about your unit tests while removing a lot of boilerplate.

With multiple mock interfaces, listening invocations, matchers and argument captors, we've seen how Mockito makes your tests cleaner and easier to understand, but like any tool, it must be used appropriately to be useful. Now armed with the knowledge of Mockito's inner workings, you can take your unit testing to the next level.