Vous avez créé vos premiers mocks avec Mockito, sur des cas simples. Nous allons creuser un peu plus et découvrir des fonctionnalités avancées de Mockito. Allons-y !
Utilisez ArgumentCaptor pour vérifier les appels aux mocks
Dans le chapitre précédent, vous avez vu comment paramétrer le fonctionnement du mock grâce à la méthode when()
. Cela vous avait permis de donner un comportement simulé à la classe Calculator, par exemple pour l'addition :
when(calculator.add(1, 2)).thenReturn(3);
Vous pouviez vérifier que votre classe à tester CalculatorService utilisait bien la classe Calculator avec les bons arguments, et renvoyait le bon résultat :
verify(calculator).add(1, 2); // Vérifie si la méthode add() a été appelée
assertThat(result).isEqualTo(3);
Ici, l'exemple est simple et les arguments sont des types simples int. Mais si vous manipulez des classes de données plus complexes, il se peut que votre classe à tester s'occupe elle-même de créer des objets de données.
Alors, vous ne pourrez plus utiliser le combo when/verify de l'exemple précédent, car vous ne savez pas comment la classe à tester transforme en interne ses propres données.
Je ne suis pas sûr de bien comprendre 🧐
Voici un exemple concret :
Vous souhaitez mettre en place un service de calcul par lots, c'est-à-dire en mode batch. À partir d'un fichier ou d'une liste de chaînes de caractères contenant des opérations, vous voulez obtenir la liste des résultats des calculs.
La responsabilité de ce nouveau service BatchCalculatorService est de :
transformer un flux de chaînes de caractères en modèles de calcul ;
appeler la classe CalculatorService pour chaque modèle de calcul.
Voici un exemple de test pour cette classe n'utilisant pas Mockito :
public class BatchCalculatorServiceTest {
BatchCalculatorService batchCalculatorServiceNoMock;
@BeforeEach
public void init() {
batchCalculatorServiceNoMock = new BatchCalculatorServiceImpl(
new CalculatorServiceImpl(new Calculator(),
new SolutionFormatterImpl()));
}
@Test
public void givenOperationsList_whenbatchCalculate_thenReturnsCorrectAnswerList()
throws IOException, URISyntaxException {
// GIVEN
final Stream<String> operations =
Arrays.asList("2 + 2", "5 - 4", "6 x 8", "9 / 3").stream();
// WHEN
final List<CalculationModel> results =
batchCalculatorServiceNoMock.batchCalculate(operations);
// THEN
assertThat(results)
.extracting(CalculationModel::getSolution)
.containsExactly(4, 1, 48, 3);
}
}
Remarquez déjà comment fonctionne la classe à tester. Elle prend en entrée un Stream<String>
et ramène en sortie une List<CalculcationModel>
.
Ce test vérifie si les bons résultats sont transmis, mais ne s'occupe pas directement de la responsabilité de la classe BatchCalculatorService
.
Alors mockons ! La mise en place du mock ne devrait pas vous poser de soucis. Gardons l'ancien test, et créons le mock :
@ExtendWith(MockitoExtension.class)
public class BatchCalculatorServiceTest {
@Mock
CalculatorService calculatorService;
BatchCalculatorService batchCalculatorService;
BatchCalculatorService batchCalculatorServiceNoMock;
@BeforeEach
public void init() {
batchCalculatorService = new BatchCalculatorServiceImpl(calculatorService);
batchCalculatorServiceNoMock = new BatchCalculatorServiceImpl(
new CalculatorServiceImpl(new Calculator(),
new SolutionFormatterImpl()));
}
OK, j'ai un mock. Mais qu'est-ce que je teste maintenant ?
Nous allons vérifier que le service de calcul a bien été appelé autant de fois que nécessaire, avec les bons arguments à chaque fois. Au lieu d'utiliser when()
, nous allons utiliser une classe plus générale, ArgumentCaptor
. Son rôle est d'enregistrer les arguments utilisés lors d'un appel de mock. Ici, on veut capturer les modèles de calcul utilisés pour appeler le service de calcul. Voici comment on déclare cela :
final ArgumentCaptor<CalculationModel> calculationModelCaptor =
ArgumentCaptor.forClass(CalculationModel.class);
Lors de l'étape d'assertion, nous allons rapatrier tous les modèles de calcul utilisés. On en profite pour vérifier que la classe CalculatorService
a bien été utilisée autant de fois que de calculs dans le flux de données :
verify(calculatorService, times(4)).calculate(calculationModelCaptor.capture());
final List<CalculationModel> calculationModels = calculationModelCaptor.getAllValues();
La variable calculationModels
contient donc une liste de 4 éléments correspondant chacun à un modèle de calcul utilisé à l'appel du service CalculatorService
. Il suffit alors de vérifier pour chaque classe de modèle si elle correspond à ce qui est attendu vis-à-vis du flux de données initial. AssertJ nous aide à coder plus rapidement, car tout de même, pour chaque modèle de calcul, il y a 3 arguments (opérande gauche, type d'opération, opérande droit). Cela fait 12 valeurs à vérifier pour la liste complète !
Voici le code complet du test :
@Test
public void givenOperationsList_whenbatchCalculate_thenCallsServiceWithCorrectArguments()
throws IOException, URISyntaxException {
// GIVEN
final Stream<String> operations =
Arrays.asList("2 + 2", "5 - 4", "6 x 8", "9 / 3").stream();
final ArgumentCaptor<CalculationModel> calculationModelCaptor =
ArgumentCaptor.forClass(CalculationModel.class);
// WHEN
batchCalculatorService.batchCalculate(operations);
// THEN
verify(calculatorService, times(4)).calculate(calculationModelCaptor.capture());
final List<CalculationModel> calculationModels =
calculationModelCaptor.getAllValues();
assertThat(calculationModels)
.extracting(CalculationModel::getLeftArgument,
CalculationModel::getType,
CalculationModel::getRightArgument)
.containsExactly(
tuple(2, CalculationType.ADDITION, 2),
tuple(5, CalculationType.SUBTRACTION, 4),
tuple(6, CalculationType.MULTIPLICATION, 8),
tuple(9, CalculationType.DIVISION, 3));
}
Avec AssertJ, on utilise à nouveau la méthode extracting pour extraire les données à vérifier, et nous pouvons le faire sous forme de blocs de données appelés tuple. Avec un peu de concentration, on se rend compte que le code est plus synthétique et plus clair qu'effectuer 12 vérifications.
Utilisez when() avec des réponses complexes
Dans l'exemple précédent, nous avons pu vérifier les appels au service de calcul, c'est déjà très bien. Mais nous n'avons pas vérifié si la méthode batchCalculate
de BatchCalculatorService
renvoie bien la liste des résultats envoyés par le service de calcul !
Voici deux méthodes pour le faire.
La fonction de réponse
Jusqu'à présent, nous avons vu que when()
est associé à thenReturn()
ou thenThrow()
. Mais il se peut que vouliez donner une réponse en fonction des arguments appelés, et non pas juste une réponse simple par rapport à des arguments simples.
Dans ce cas, vous pouvez utiliser la fonction de réponse then()
associée à when()
. Grâce aux lambdas (consulter le chapitre sur les lambdas du cours Débutez la programmation avec Java si vous en avez besoin), nous allons donner en argument de then()
une action permettant de renvoyer un résultat en fonction du contenu du when()
.
Dans notre exemple, pour simuler correctement le service de calcul, nous allons construire un mock qui renvoie un résultat en fonction du type d'opération. Si l'opération est une addition, on renvoie le modèle de calcul avec la réponse 4, et on donne un résultat différent pour chaque type d'opération. Cela peut revenir à créer une méthode answer
ayant la forme suivante :
public CalculationModel answer(InvocationOnMock invocation) {
final CalculationModel model = invocation.getArgument(0, CalculationModel.class);
switch (model.getType()) {
case ADDITION:
model.setSolution(4);
break;
case SUBTRACTION:
model.setSolution(1);
break;
case MULTIPLICATION:
model.setSolution(48);
break;
case DIVISION:
model.setSolution(3);
break;
default:
}
return model;
});
}
La classe InvocationOnMock
permet de connaître les arguments de l'appel au mock. Ici, on souhaite connaître le type d'opération pour donner une réponse différente en fonction. De plus, on réutilise la classe en entrée CalculationModel
pour la renvoyer complétée de la réponse.
Nous allons ensuite utiliser une syntaxe plus concise avec les lambdas. À l'étape Arrange/Given
, on appelle :
when()
pour qu'il traite n'importe quel type d'arguments avecany()
;puis
then()
pour construire la réponse sous forme de lambda.
Cela donne le code suivant :
when(calculatorService.calculate(any(CalculationModel.class)))
.then(invocation -> {
final CalculationModel model = invocation.getArgument(0, CalculationModel.class);
switch (model.getType()) {
case ADDITION:
model.setSolution(4);
break;
case SUBTRACTION:
model.setSolution(1);
break;
case MULTIPLICATION:
model.setSolution(48);
break;
case DIVISION:
model.setSolution(3);
break;
default:
}
return model;
});
Notre mock est capable de traiter n'importe quelle opération. Il donnera alors la solution 4 si c'est une addition, 1 si c'est une soustraction, 48 si c'est une multiplication et 3 si c'est une division.
Le test va ensuite vérifier que le service a bien été appelé 4 fois et que les résultats sont conformes, de la même façon qu'avec le test sur des objets non mockés :
@Test
public void givenOperationsList_whenbatchCalculate_thenCallsServiceAndReturnsAnswer()
throws IOException, URISyntaxException {
// GIVEN
final Stream<String> operations =
Arrays.asList("2 + 2", "5 - 4", "6 x 8", "9 / 3").stream();
when(calculatorService.calculate(any(CalculationModel.class)))
.then(invocation -> {
final CalculationModel model = invocation.getArgument(0, CalculationModel.class);
switch (model.getType()) {
case ADDITION:
model.setSolution(4);
break;
case SUBTRACTION:
model.setSolution(1);
break;
case MULTIPLICATION:
model.setSolution(48);
break;
case DIVISION:
model.setSolution(3);
break;
default:
}
return model;
});
// WHEN
final List<CalculationModel> results =
batchCalculatorService.batchCalculate(operations);
// THEN
verify(calculatorService, times(4)).calculate(any(CalculationModel.class));
assertThat(results).extracting("solution").containsExactly(4, 1, 48, 3);
}
Avec la fonction de réponse, vous avez le moyen le plus général pour construire une réponse sophistiquée. Mais dans certains cas, vous pouvez aussi employer un moyen plus simple : la réponse multiple.
Alternative : la réponse multiple
Avec when()
, vous pouvez donner une réponse précise ou une fonction de réponse, mais vous pouvez aussi donner plusieurs réponses. Le mock va alors utiliser ces réponses dans l'ordre des appels effectués : au premier appel du mock, ce dernier renvoie la première réponse, puis au deuxième appel, la deuxième réponse fournie, et ainsi de suite. Au bout de la dernière réponse, le mock revient à la première réponse fournie.
Dans notre exemple de calculateur par lots, nous pouvons utiliser ce moyen, car nous maîtrisons bien les données de test utilisées : le flux d'opérations sous forme de chaînes de caractères.
Donc, si l'on a le flux de données suivant :
final Stream<String> operations =
Arrays.asList("2 + 2", "5 - 4", "6 x 8", "9 / 3").stream();
alors le mock pourra être configuré ainsi :
when(calculatorService.calculate(any(CalculationModel.class)))
.thenReturn(new CalculationModel(CalculationType.ADDITION, 2, 2, 4))
.thenReturn(new CalculationModel(CalculationType.SUBTRACTION, 5, 4, 1))
.thenReturn(new CalculationModel(CalculationType.MULTIPLICATION, 6, 8, 48))
.thenReturn(new CalculationModel(CalculationType.DIVISION, 9, 3, 3));
Il suffit de chaîner les méthodes thenReturn()
! Si on avait glissé une division par zéro, on aurait pu aussi chaîner avec thenThrow()
.
Le test complet devient :
@Test
public void givenOperationsList_whenbatchCalculate_thenCallsServiceAndReturnsAnswer2()
throws IOException, URISyntaxException {
// GIVEN
final Stream<String> operations =
Arrays.asList("2 + 2", "5 - 4", "6 x 8", "9 / 3").stream();
when(calculatorService.calculate(any(CalculationModel.class)))
.thenReturn(new CalculationModel(CalculationType.ADDITION, 2, 2, 4))
.thenReturn(new CalculationModel(CalculationType.SUBTRACTION, 5, 4, 1))
.thenReturn(new CalculationModel(CalculationType.MULTIPLICATION, 6, 8, 48))
.thenReturn(new CalculationModel(CalculationType.DIVISION, 9, 3, 3));
// WHEN
final List<CalculationModel> results =
batchCalculatorService.batchCalculate(operations);
// THEN
verify(calculatorService, times(4)).calculate(any(CalculationModel.class));
assertThat(results).extracting("solution").containsExactly(4, 1, 48, 3);
}
C'est un peu plus simple que la fonction de réponse, non ? Évidemment, le mock a un comportement moins général, mais ce qu'on cherche ici, c'est tester selon des scénarios précis et répétables.
Espionnez du code réel que vous ne pouvez pas simuler
Les mocks peuvent faire beaucoup de choses, mais Mockito ne peut pas gérer certaines situations. Il est en particulier limité par les contraintes du langage Java. Ainsi, une méthode déclarée avec le modificateur final
ne peut pas être simulée par un mock de Mockito. Voici un extrait de la classe IntSummaryStatistics
, fourni par le langage Java et qui ne peut pas être simulée par un mock :
public class IntSummaryStatistics implements IntConsumer {
...
@Override
public void accept(int value) {
++count;
sum += value;
min = Math.min(min, value);
max = Math.max(max, value);
}
...
public final double getAverage() {
return getCount() > 0 ? (double) getSum() / getCount() : 0.0d;
}
...
}
Imaginons un service de calcul statistique utilisant cette classe IntSummaryStatistics
. Le comportement de getAverage()
ne pourra pas être simulé à cause du mot clé final.
La solution consiste à utiliser une autre forme de doublure de test, la classe espion ou Spy pour Mockito. C'est une classe réelle qui peut être vue comme un mock partiel : par défaut, c'est le comportement réel qui est utilisé, mais l'on peut espionner les appels, voire modifier le comportement des méthodes qui n'ont pas le mot clé final dans leur déclaration, ici la méthode accept
, par exemple.
Voici une brève comparaison entre espions et mocks :
Situation | Mock | Espion |
Créer avec l’annotation | @Mock | @Spy |
Créer avec une méthode | mock(RealClass.class) | spy(RealClass.class) |
Appeler someMethod après when(someMethod) .thenReturn(response) | Renvoie la réponse fournie | Renvoie la réponse fournie |
Appeler someMethod() que vous n’avez pas définie avec ‘when’ | Le mock renvoie un null | La méthode originale est appelée dans la RealClass pour un résultat |
Capturer des arguments avec ArgumentCaptor | verify(mock) .someMethod(captor) | verify(spy) .someMethod(captor) |
Simuler une méthode ou classe finale | Erreur de Mockito | Erreur de Mockito |
Voici un exemple concret d'utilisation. Vous souhaitez tester une classe de calcul statistique StatisticsCalculator
, ayant cette forme :
public class StatisticsCalculator {
private final IntSummaryStatistics summaryStatistics;
public StatisticsCalculator(IntSummaryStatistics summaryStatistics) {
this.summaryStatistics = summaryStatistics;
}
public Integer average(List<Integer> samples) {
samples.forEach(summaryStatistics::accept);
// Extraire la moyenne
Double average = summaryStatistics.getAverage();
return average.intValue();
}
}
Le test consisterait à vérifier que le calculateur prend bien en compte tous les nombres à fournir à IntSummaryStatistics
, via la méthode accept
. Nous allons donc mocker la méthode accept
mais laisser la méthode final getAverage
telle quelle.
Voici la syntaxe du test :
@ExtendWith(MockitoExtension.class)
public class StatisticsCalculatorTest {
@Spy
IntSummaryStatistics summaryStatistics = new IntSummaryStatistics();
StatisticsCalculator underTest;
@BeforeEach
public void setUp() {
underTest = new StatisticsCalculator(summaryStatistics);
}
@Test
public void average_shouldSample_allIntegersProvided() {
final ArgumentCaptor<Integer> sampleCaptor = ArgumentCaptor.forClass(Integer.class);
final List<Integer> samples = Arrays.asList(2, 8, 5, 3, 7);
underTest.average(samples);
verify(summaryStatistics, times(samples.size())).accept(sampleCaptor.capture());
final List<Integer> capturedList = sampleCaptor.getAllValues();
assertThat(capturedList).containsExactly(samples.toArray(new Integer[0]));
}
}
Dans ce test, nous avons :
utilisé l'annotation @Spy au lieu de @Mock ;
utilisé ArgumentCaptor pour capturer et vérifier les paramètres d'appel de la méthode accept.
Nous pouvons ajouter un autre test, sans mock pour vérifier que le calcul de moyenne est correct :
@Test
public void average_shouldReturnTheMean_ofAListOfIntegers() {
final List<Integer> samples = Arrays.asList(2, 8, 5, 3, 7);
final Integer result = underTest.average(samples);
assertThat(result).isEqualTo(5);
}
Ce deuxième test est moins "unitaire", dans le sens où le test couvre à la fois le calculateur statistique et la classe IntSummaryStatistics
, mais nous ne pouvons pas faire autrement à cause du modificateur final.
Donc, les classes espions permettent de trouver des solutions lorsque le mocking n'est pas possible, mais veillez à ne pas généraliser l'utilisation des annotations @Spy !
En résumé
Utilisez ArgumentCaptor pour capturer les arguments réels avec lesquels votre mock est appelé. Cela vous permet de vérifier la conformité des appels aux mocks. Et ce, en particulier lorsque votre classe de test utilise des arguments sur lesquels vous n’avez pas de visibilité directe.
Utilisez les fonctions de réponse sous forme de lambdas ou les réponses multiples avec when(), pour obtenir des comportements plus sophistiqués de vos mocks.
@Spy et spy() vous permettent d’utiliser des instances réelles d’une classe, mais sur lesquelles vous pouvez utiliser when() et verify(). Ce sont des mocks partiels. Cela se révèle nécessaire lorsque vos classes contiennent des méthodes avec le modificateur final.