• 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 12/11/24

Simulez des composants externes aux tests avec Mockito

Un punching-ball, un mannequin de crash-test, et un simulateur de voiture de course. Qu’ont en commun ces trois éléments ? En plus de recevoir des coups, ils remplacent quelque chose de réel. Par exemple, un punching-ball vous permet de vous entraîner à la boxe sans avoir à frapper quelqu’un. 🥊 Un  mannequin de crash-test permet aux ingénieurs de mesurer la sécurité de leur voiture sans blesser de vraies personnes. 🚙 Un simulateur de voiture de course vous permet d'incarner un coureur automobile mondialement connu en toute sécurité, et avec de nouvelles tentatives illimitées ! 🏎

Dans les tests logiciels, on utilise généralement un acteur de substitution qui joue le rôle de la classe que vous avez besoin d’utiliser ; c’est ce qu’on appelle un test double en anglais (doublure de test). Le principe, c'est d'utiliser du code qui prétend être l’élément dont vous avez besoin pour vos tests.

Les mocks (simulacres en français) sont les tests doubles les plus utilisés pour les tests unitaires. Imaginez un matériau magique que vous pourriez transformer en punching-ball ou en mannequin de simulation d’impact. Les mocks sont très polyvalents quand il s’agit de prétendre être d’autres classes et interfaces, en écrivant très peu de code ! Choisissez l’élément que vous voulez simuler, définissez-le, et dites « presto » ! Ils vous permettent aussi de vérifier comment le mock a été utilisé à l'exécution, pour que vous soyez sûr que la simulation ait été suffisamment convaincante.

Comprenez la nouvelle architecture du calculateur

Jusqu'à présent, nous avons effectué des tests unitaires sur la classe Calculator. Pour vous montrer l'intérêt des mocks, nous avons besoin d'une architecture de code plus complexe. La voici :

  • la classe existante Calculator est toujours là pour effectuer les calculs directs ;

  • la classe CalculationModel contient les données de calcul ;

  • l'interface CalculatorService utilise CalculationModel pour déclarer une méthode de calcul ;

  • la classe CalculatorServiceImpl implémente l'interface CalculatorService et utilise pour cela la classe Calculator ;

  • à la fin de ce chapitre, l'interface SolutionFormatter offre un service pour formater la réponse à un calcul. Elle est implémentée par la classe SolutionFormatterImpl, et sera utilisée par la classe CalculatorServiceImpl.

Voici le diagramme UML de classes illustrant cette architecture :

Architecture orienté services du calculateur
Architecture orientée services du calculateur

 Pourquoi une telle architecture, alors que les fonctionnalités paraissent simples ?

Nous n'allons pas rentrer dans les détails, car ce n'est pas l'objet du cours. Pour simplifier, cette architecture répond à deux exigences :

  • chaque classe est responsable d'une seule tâche. Ici, on ne met pas dans la même classe les tâches de calcul, la représentation des données de calcul et la présentation du résultat ;

  • un service doit être construit à partir d'une interface et d'une ou plusieurs implémentations. Si un service B a besoin d'utiliser un service A, alors la classe d'implémentation BImpl possède un objet faisant référence à l'interface du service A et non à une de ses implémentations.

Grâce à cette architecture, notre système de calculateur possède plusieurs classes qui collaborent. Donc, nous allons pouvoir créer des simulacres de certaines d'entre elles.

Créez et utilisez des mocks

Pour mocker efficacement les objets autour de votre système à tester, voici la démarche à suivre :

  1. Identifiez un comportement unique que vous testez avec votre classe sous-test (CUT).

  2. Demandez-vous quelles classes sont nécessaires au comportement à tester.

  3. Hormis votre CUT, envisagez toutes les autres classes pour le mocking.

  4. Ne mockez pas les classes qui ne servent quasiment qu’à porter des valeurs.

  5. Installez les mocks requis.

  6. Testez votre CUT.

  7. Vérifiez que vos mocks ont été correctement utilisés.

Le screencast ci-dessous applique cette démarche pour le calculateur, selon 4 temps :

  • transformation d'un test du service CalculatorService en mockant la classe du Calculator ;

  • vérification dans les assertions que les mocks ont été utilisés ;

  • configuration d'un mock pour les exceptions ;

  • application du TDD avec mocking pour intégrer une nouvelle classe qui formate le résultat d'un calcul.

Comme le modèle du calculateur a été refactorisé, je vous conseille de vous placer directement dans la nouvelle branche dédiée à ce chapitre : p2ch4.

git checkout -f p2ch4

N'hésitez pas à revoir ce screencast dense en informations. La suite de ce chapitre reprend point par point ce que vous avez découvert avec les extraits de code dont vous avez besoin.

Installez Mockito

Mockito est une bibliothèque qui s'importe comme JUnit avec Maven. Dans le fichier pom.xml, ajoutez la balise dep.junit.version avec la version adaptée dans les properties :

	<properties>
		<dep.junit.version>5.5.1</dep.junit.version>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<maven.compiler.source>11</maven.compiler.source>
		<maven.compiler.target>${maven.compiler.source}</maven.compiler.target>
	</properties>

Puis parmi les dependencies, supprimez celle faisant référence à junit-jupiter, et remplacez-la par :

		<dependency>
			<groupId>org.junit.jupiter</groupId>
			<artifactId>junit-jupiter-api</artifactId>
			<version>${dep.junit.version}</version>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.junit.jupiter</groupId>
			<artifactId>junit-jupiter</artifactId>
			<version>${dep.junit.version}</version>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.mockito</groupId>
			<artifactId>mockito-junit-jupiter</artifactId>
			<version>3.1.0</version>
			<scope>test</scope>
		</dependency>

Cela permet d'importer Mockito et de s'assurer que l'on garde bien la version souhaitée de JUnit 5.

Créez votre premier mock

Partons de la classe CalculatorService initiale. Regardez le code ci-dessous, il contient beaucoup de tests superflus :

public class CalculatorServiceTest {

	Calculator calculator = new Calculator();
	// Une instance réelle du calculateur est utilisée
	CalculatorService classUnderTest = new CalculatorServiceImpl(calculator);

	@Test
	public void add_returnsTheSum_ofTwoPositiveNumbers() {
		final int result = classUnderTest.calculate(
				new CalculationModel(CalculationType.ADDITION, 1, 2)).getSolution();
		assertThat(result).isEqualTo(3);
	}

	@Test
	public void add_returnsTheSum_ofTwoNegativeNumbers() {
		final int result = classUnderTest.calculate(
				new CalculationModel(CalculationType.ADDITION, -1, 2))
				.getSolution();

		assertThat(result).isEqualTo(1);
	}

	@Test
	public void add_returnsTheSum_ofZeroAndZero() {
		final int result = classUnderTest.calculate(
				new CalculationModel(CalculationType.ADDITION, 0, 0)).getSolution();
		assertThat(result).isEqualTo(0);
	}
}

Vous voyez comment ce test utilise une instance Calculator réelle ? Ce test vérifie le fonctionnement de deux classes ici ! De plus, étant donné que nous avons déjà dû tester Calculator dans CalculatorTest, le comportement de Calculator est vérifié une deuxième fois !

En quoi est-ce un problème ?

Vous vous souvenez du F.I.R.S.T. et du "I" pour le principe d’isolation des tests ? Un test unitaire, une unité de code testée. Si ce test échoue, est-ce dû à Calculator ou CalculatorService ? Cependant, il faut savoir trouver l'équilibre entre :

  • s'autoriser à se répéter quand on code pour tester différents scénarios alternatifs (cf. chapitre précédent) ;

  • recopier les mêmes tests, mais de manière non unitaire.

Alors, que faire ? Demandez-vous quelle est la responsabilité de la classe CalculationService : résoudre des calculs définis par la classe de données CalculationModel. C’est tout. Vous pouvez tester vos situations selon un scénario principal et des scénarios alternatifs, sans retester la classe Calculator. Cette dernière n’est pas la classe que vous testez, mais un élément qui aide votre CUT à accomplir son travail. Cela s’appelle un collaborateur, et constitue un candidat de mock idéal.

Nous allons donc remplacer les tests existants par un test par opération de base. D'abord, il faut déclarer Mockito comme une extension à notre classe de tests :

@ExtendWith(MockitoExtension.class)
public class CalculatorServiceTest {

Ensuite, analysons ce qui doit être mocké :

  • la classe CalculatorService est la classe à tester, on ne mocke pas ;

  • la classe Calculator est un collaborateur au service et effectue un traitement, on mocke ;

  • la classe CalculationModel porte uniquement des données en entrée et en sortie, on ne mocke pas !

Remplaçons les déclarations de Calculator et CalculatorService par le code suivant :

@ExtendWith(MockitoExtension.class)
public class CalculatorServiceTest {

	@Mock
	Calculator calculator;

	CalculatorService classUnderTest;

	@BeforeEach
	public void init() {
		classUnderTest = new CalculatorServiceImpl(calculator);
	}

L'annotation @Mock déclare le mock, on n'a plus besoin d'initialiser sa valeur. Et le service est déclaré dans une méthode d'initialisation @BeforeEach, c'est plus propre.

Ensuite, que doivent faire nos tests ? Prenons l'exemple de l'addition. Nous devons vérifier qu'avec un modèle de calcul d'addition, la classe Calculator est bien appelée et donne le résultat voulu :

  • comme Calculator est un mock et non plus l'objet réel, on doit lui indiquer quoi faire lorsqu'il sera appelé, ce sera dans l'étape Arrange ou Given ;

  • ensuite, on effectue le traitement en appelant la méthode calculate, comme c'était fait dans le test initial ;

  • enfin, dans l'étape Assert ou Then, on vérifie le résultat mais aussi que la classe Calculator a bien été appelée.

Après avoir ajouté la ligne d'import :

import static org.mockito.Mockito.*;

Nous obtenons le test suivant pour l'addition :

@Test
public void calculate_shouldUseCalculator_forAddition() {
	// GIVEN
	when(calculator.add(1, 2)).thenReturn(3);

	// WHEN
	final int result = classUnderTest.calculate(
			new CalculationModel(CalculationType.ADDITION, 1, 2)).getSolution();

	// THEN
	verify(calculator).add(1, 2);
	assertThat(result).isEqualTo(3);
}

À la ligne 4, les méthodes when et thenReturn de Mockito permettent de paramétrer le mock en affirmant que si la méthode add(1, 2) est appelée sur la classe (mockée) Calculator, alors on retourne le résultat 3. C'est grâce à cette ligne que l'on isole bien le test du service. Aucun échec de test ne peut venir de Calculator, car on le simule, et on indique comment simuler.

De plus, à la ligne 11, on vérifie, grâce à la méthode verify de Mockito, que la classe (mockée) Calculator a été utilisée, en particulier la méthode add(1, 2).

Donc, nous avons bien vérifié qu'en mettant en entrée un objet CalculationModel de type "addition", nous appelons bien la bonne méthode d'addition du calculateur. C'est bien la responsabilité du service CalculatorService.

Paramétrez vos mocks avec des paramètres génériques

Jusqu'à présent, vous avez utilisé les méthodes when et verify avec des paramètres précis d'addition ou d'une autre opération du calculateur. Mais vous pouvez aussi définir le comportement de votre mock ou vérifier l'utilisation du mock sans connaître certains paramètres.

Par exemple, dans le test d'addition, nous aurions pu ne pas préciser les paramètres dans when, grâce à la méthode  any(), qui prend en paramètre le typage du paramètre à fixer :

when(calculator.add(any(Integer.class), any(Integer.class))).thenReturn(3);

Cela signifie que si la méthode add du calculateur est appelée, quels que soient les paramètres, elle renverra 3 !

De la même façon, si vous souhaitez vérifier qu'une méthode de votre mock a été appelée quels que soient les paramètres :

verify(calculator).add(any(Integer.class), any(Integer.class));

cette assertion vérifie que la méthode add a été appelée sur le calculateur, peu importent les paramètres !

Donc le test suivant passe avec succès, où les paramètres d'addition sont choisis au hasard :

@Test
public void calculate_shouldUseCalculator_forAnyAddition() {
	// GIVEN
	final Random r = new Random();
	when(calculator.add(any(Integer.class), any(Integer.class))).thenReturn(3);

	// WHEN
	final int result = classUnderTest.calculate(
			new CalculationModel(CalculationType.ADDITION,
			    r.nextInt(), r.nextInt())).getSolution();

	// THEN
	verify(calculator).add(any(Integer.class), any(Integer.class));
	assertThat(result).isEqualTo(3);
}

Lorsque vous vérifiez l'appel d'une fonctionnalité, vous pouvez vérifier le nombre de fois que la méthode a été appelée, grâce à la méthode times(). Dans le test précédent, vous pouvez remplacer la ligne 13 par la ligne suivante :

    verify(calculator, times(1)).add(any(Integer.class), any(Integer.class));

Cette vérification est plus stricte que la précédente, car si le calculateur est appelé plus d'une fois, le test échouera !

Vous pouvez aussi vérifier qu'une méthode n'est PAS appelée avec times(0) :

	verify(calculator, times(0)).sub(any(Integer.class), any(Integer.class));

Ou avec plus de sens avec la méthode  never()  :

	verify(calculator, never()).sub(any(Integer.class), any(Integer.class));

Exceptions de mocking

Vous pouvez également utiliser Mockito pour configurer vos mocks afin qu'ils lancent des exceptions. Si vous souhaitez simuler l'exception de la division par 0, vous pouvez le faire !

Pour cela, dans l'étape Arrange/Given, il suffit de remplacer thenReturn par thenThrow . Par exemple, vous voulez tester que si une division par 0 a lieu, le service retourne une exception IllegalArgumentException. En effet, dans cet exemple, le besoin émis est que le service doit traiter l'exception ArithmeticException pour renvoyer une exception de type IllegalArgumentException. Voici le code de test :

@Test
public void calculate_shouldThrowIllegalArgumentException_forADivisionBy0() {
	// GIVEN
	when(calculator.divide(1, 0)).thenThrow(new ArithmeticException());

	// WHEN
	assertThrows(IllegalArgumentException.class, () -> classUnderTest.calculate(
			new CalculationModel(CalculationType.DIVISION, 1, 0)));

	// THEN
	verify(calculator, times(1)).divide(1, 0);
}

Quelle est cette syntaxe à la ligne 7 ?

Avec JUnit 5, il s'agit de la syntaxe pour vérifier qu'une ligne de code peut générer une exception ! Cette méthode assertThrows prend en paramètres :

  • la classe d'exception à laquelle on doit s'attendre ;

  • la ligne de code sous forme de lambda. Un lambda est une possibilité de Java 8 pour intégrer une action à faire comme paramètre de méthode. Pour plus de détails, consultez le chapitre sur les lambdas du cours Débutez la programmation avec Java. Ici, mettez simplement l'action à faire précédée des symboles  () ->.

Ce test va donc vérifier à la ligne 7 que la bonne exception est lancée, mais aussi à la ligne 11 que la méthode de division a été appelée !

Appliquez le TDD avec Mockito

Imaginez que votre responsable vous demande de mettre à jour ce CalculationService pour qu’il puisse présenter des versions de grands nombres facilement lisibles aux utilisateurs. Il vous donne comme exemple d’afficher le nombre 100020 comme « 100 020 ». Voyons comment vous pouvez utiliser le TDD avec le mocking pour coder cette évolution sans régression, c'est-à-dire sans créer des bugs à d'autres endroits de votre programme.

Le screencast a bien illustré la démarche, voici simplement les classes à modifier. D'abord, créez le test. La solution formatée doit être portée par la classe CalculationModel.

	@Test
	public void calculate_shouldFormatSolution_forAnAddition() {
		// GIVEN
		when(calculator.add(10000, 3000)).thenReturn(13000);

		// WHEN
		final String formattedResult = classUnderTest
		    .calculate(new CalculationModel(CalculationType.ADDITION, 10000, 3000))
    		.getFormattedSolution();

		// THEN
		assertThat(formattedResult).isEqualTo("13 000");
	}

Pour que ce code compile, vous devez ajouter un attribut à la classe CalculationModel, ainsi que son getter/setter :

public class CalculationModel {
    
	// ...
	
	private String formattedSolution;

	// ...

	public String getFormattedSolution() {
		return formattedSolution;
	}

	public void setFormattedSolution(String formattedSolution) {
		this.formattedSolution = formattedSolution;
	}

De toute façon, le test échoue (c'est rouge 😄), car la classe CalculationModel, si elle prévoit de stocker des solutions formatées, n'est pas responsable du formatage ! On doit alors se rendre compte que :

  1. Le service CalculationService doit rendre un résultat formaté.

  2. Si l'on souhaite bien modulariser son code, le formatage doit être effectué par un autre service qui sera appelé par CalculatorService.

La classe CalculatorService dépend d'un autre service, SolutionFormatter, qui, lui, est fourni au constructeur. Lors du calcul, la méthode alimente la solution formatée en appelant le service SolutionFormatter :

public class CalculatorServiceImpl implements CalculatorService {

	private final Calculator calculator;

	private final SolutionFormatter solutionFormatter;

	public CalculatorServiceImpl(Calculator calculator,
	        SolutionFormatter solutionFormatter) {
		this.calculator = calculator;
		this.solutionFormatter = solutionFormatter;
	}

	@Override
	public CalculationModel calculate(CalculationModel calculationModel) {
	    
		// ...
		
		calculationModel.setSolution(response);
		calculationModel.setFormattedSolution(solutionFormatter.format(response));
		return calculationModel;
	}

Pour que le code compile, créons une nouvelle interface SolutionFormatter :

public interface SolutionFormatter {
	String format(int solution);
}

Il faut ensuite modifier la classe de test du service CalculatorServiceTest. En effet, le code suivant ne compile plus :

@ExtendWith(MockitoExtension.class)
public class CalculatorServiceTest {

	@Mock
	Calculator calculator;

	CalculatorService classUnderTest;

	@BeforeEach
	public void init() {
		classUnderTest = new CalculatorServiceImpl(calculator);
	}
	
	// ...

Car CalculatorServiceImpl a besoin d'une instance de SolutionFormatter ! Que faire ? Utilisons un mock ! L'initialisation de la classe de test devient donc :

@ExtendWith(MockitoExtension.class)
public class CalculatorServiceTest {

	@Mock
	Calculator calculator;

	@Mock
	SolutionFormatter solutionFormatter;

	CalculatorService classUnderTest;

	@BeforeEach
	public void init() {
		classUnderTest = new CalculatorServiceImpl(calculator, solutionFormatter);
	}
	
	// ...

Et le test est complété par un when supplémentaire pour fixer le comportement du solutionFormatter mocké :

@Test
public void calculate_shouldFormatSolution_forAnAddition() {
	// GIVEN
	when(calculator.add(10000, 3000)).thenReturn(13000);
	when(solutionFormatter.format(13000)).thenReturn("13 000");

	// WHEN
	final String formattedResult = classUnderTest
	    .calculate(new CalculationModel(CalculationType.ADDITION, 10000, 3000))
	    .getFormattedSolution();

	// THEN
	assertThat(formattedResult).isEqualTo("13 000");
}

Et le test passe enfin ! C'est vert ! Vous comprenez la démarche ? Le TDD se marie avec Mockito, il s'agit juste de coder dans le bon ordre, et cela deviendra naturel avec l'expérience !

Mais où est l'implémentation de SolutionFormatter ?

En effet, aucune implémentation n'a été codée ! Pour cela, il suffit de coder le test unitaire de SolutionFormatter. Et pour que le test compile et passe avec succès, il faudra évidemment implémenter l'interface SolutionFormatter !

Essayez par vous-même !

Réécrivez les autres tests de CalculatorServiceTest, un par opération de base ! Le format de ces tests est très similaire à celui de l'addition. Comprenez, codez et testez jusqu'à ce que vos tests passent avec JUnit. Je vous laisse aussi l'implémentation des tests et de la classe implémentant SolutionFormatter en exercice !

La solution de cet exercice est reprise au début du chapitre suivant, sur la branche Git p2ch5.

En résumé

  • Dans les tests de logiciel, on utilise habituellement un acteur de remplacement pour jouer le rôle de la classe que vous avez besoin d’utiliser ; ce remplaçant s’appelle une doublure de test ou test double en anglais.

  • Un mock, ou une simulation, est un type de doublure de test simple à créer, et qui vous permet également de tester comment on interagit avec lui.

  • Mockito vous permet de définir comment vos mocks doivent se comporter (avec when) et vérifier si ces mocks sont bien utilisés (avec verify).

  • En faisant du TDD, vous pouvez être amené à coder des évolutions avec des services complémentaires. Mockito vous aide à pratiquer le TDD facilement grâce aux mocks.

 Vous avez découvert les rudiments de Mockito, passons maintenant à ses fonctions avancées.

Example of certificate of achievement
Example of certificate of achievement