• 10 hours
  • Medium

Free online content available in this course.

course.header.alt.is_video

course.header.alt.is_certifying

Got it!

Last updated on 9/20/24

Utilisez les fonctions avancées de Mockito

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 avec  any()  ;

  • 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.

Example of certificate of achievement
Example of certificate of achievement