Skip to Content
BackendJUnit 5 & Mockito

JUnit 5 & Mockito

Patrons de test JVM : assertions, mocking, tests paramétrés, conditions et extensions.

Ce document couvre JUnit Jupiter (moteur) et Mockito. Pour les slice tests Spring Boot (@WebMvcTest, @DataJpaTest), voir Tests — Fondamentaux (section « Tests dans Spring Boot »).

Installation

Maven

<!-- Déjà inclus par spring-boot-starter-test --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- Ou en indépendant --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.11.4</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>5.14.2</version> <scope>test</scope> </dependency>

Gradle

testImplementation platform('org.junit:junit-bom:5.11.4') testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.mockito:mockito-core:5.14.2' testImplementation 'org.mockito:mockito-junit-jupiter:5.14.2'

Note : mockito-junit-jupiter apporte les annotations @Mock, @Spy, @ExtendWith(MockitoExtension.class). Sans ce starter, l’initialisation par annotation ne fonctionne pas.

Cycle de vie

AnnotationDéclenchement
@TestInstance(Lifecycle.PER_CLASS)Méthodes @BeforeAll/@AfterAll peuvent être non-static
@BeforeAllUne fois, avant tous les @Test
@AfterAllUne fois, après tous les @Test
@BeforeEachAvant chaque @Test
@AfterEachAprès chaque @Test
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)Exécution dans l’ordre @Order(1), @Order(2), …
@TestInstance(Lifecycle.PER_CLASS) class CycleDeVieTest { @BeforeAll void setupGlobal() { // initialisation lourde (SQL, serveurs) } @BeforeEach void resetState() { // réinitialisation par test } @Test @Order(1) void premier() { /* ... */ } @Test @Order(2) void deuxieme() { /* ... */ } }

Piège : Sans @TestInstance(Lifecycle.PER_CLASS), les méthodes @BeforeAll/@AfterAll doivent être static. Oublier ce suffixe provoque une erreur de compilation immédiate.

Assertions

Assertions standard

assertEquals(2, calcul.add(1, 1)); // égalité assertNotEquals("toto", "tata"); // différence assertTrue(notreCondition()); // boolean assertFalse(liste.isEmpty()); // boolean assertNull(requete.getUtilisateur()); // null assertNotNull(entity.getNom()); // non-null assertThrows(IllegalArgumentException.class, () -> create(null)); // exception

Messages de direction

assertEquals(42, resultat, "Le prix final doit être 42"); // → en cas d'échec : "Le prix final doit être 42 : expected <42> but was <0>"

Groupes d’assertions

assertAll("données utilisateur", () -> assertEquals("Alice", user.getNom()), () -> assertEquals("alice@example.com", user.getEmail()), () -> assertTrue(user.isActif()) ); // Exécute toutes les assertions même si certaines échouent, puis rapporte les échecs groupés

Assertions souples (SoftAssertions)

var soft = new SoftAssertions(); soft.assertEquals(user.getNom(), "Alice"); soft.assertTrue(user.isActif()); soft.assertNotNull(user.getEmail()); soft.assertAll(); // invalide — lève AssertionError avec tous les échecs accumulés

Usage : dans les tests de validation qui vérifient plusieurs champs simultanément. Sans soft assertions, le premier échec arrête la vérification des champs suivants.

Assumptions & Conditions

Assumptions (test ignoré si la condition est fausse)

// Ignore le test si la condition est fausse (ne fait PAS échouer) @Test void ne_s_execute_quen_ci() { assumeTrue(System.getenv("CI") != null); // ... } @Test void droitAdmin() { assumeTrue(user.getRole().equals("admin")); // ... }

assumeTrue() / assumeFalse() lancent une AssumptionViolatedException qui ignore le test (pas d’échec, pas d’exécution du corps).

Conditions (activer/désactiver un test selon un contexte)

@EnabledIf(System.getenv("ENABLE_FEATURE_X") != null) void featureX_activee() { /* ... */ } @DisabledIf(System.getProperty("os.name").toLowerCase().contains("windows")) void linux_seul() { /* ... */ } @Disabled("En attente de la feature Y") void featureEn_cours_developpement() { /* ... */ }

Piège : @Disabled sur un @Test ne déclenche aucun @BeforeEach/@AfterEach. C’est comme si le test n’existait pas.

Mockito

Annotations de base

@ExtendWith(MockitoExtension.class) class ServiceTest { @Mock ProduitRepository repository; // Double léger @Spy Logger logger = LoggerFactory.getLogger(getClass()); // Réel, espionné @InjectMocks GestionProduitService service; // Injecté dans le constructeur @Test void recupererProduitParId() { when(repository.findById(any(UUID.class))) .thenReturn(Optional.of(new Produit(UUID.randomUUID(), "Clavier", "Méca", BigDecimal.valueOf(89)))); var produit = service.get(UUID.randomUUID()); assertEquals("Clavier", produit.nom()); verify(repository).findById(any()); verify(repository, times(1)).findById(any()); } }

when(...).thenReturn(...)

// Retour unique when(repo.findById(id)).thenReturn(Optional.of(produit)); // Retours multiples (premier appel → premier return) when(repo.findAll()) .thenReturn(List.of(p1)) .thenReturn(List.of(p1, p2)); // Lancer une exception when(repo.save(any())) .thenThrow(new DataIntegrityViolationException("duplicate")); // Calculer la réponse when(repo.count()).thenAnswer(invocation -> { var arg = invocation.getArgument(0); return arg != null ? 1L : 0L; });

doThrow / doAnswer / doNothing (quand when échoue)

// Exception sans vérifier l'appel (évite NullPointerException sur void) doThrow(new IllegalStateException("verrouille")).when(service).stop(); // Intercepter un void doNothing().when(logger).warn(anyString()); // Répondre à un void doAnswer(inv -> { compteur++; return null; }).when(service).incrementer();

Piège : when(methodeVoid(...)) lève NullPointerExceptionmethodeVoid est appelée réellement sur le mock. Pour les méthodes void, utiliser doThrow/doNothing/doAnswer(...).when(mock).methodeVoid(...)

Vérification (verify)

verify(repository).save(produit); // appelé au moins une fois verify(repository, times(2)).save(produit); // exactement 2 fois verify(repository, never()).delete(any()); // jamais appelé verify(repository, atLeastOnce()).save(any()); // au moins une fois verify(repository, atMost(3)).save(any()); // au plus 3 fois

Matcheurs d’arguments

verify(repository).findById(any()); // n'importe quel UUID verify(repository).findById(isNull()); // null verify(logger).warn(eq("erreur")) // égalité stricte verify(logger).warn(startsWith("erreur")); // sous-chaîne début verify(logger).warn(matches("\\w+")); // regex verify(repository).save(argThat(p -> p.prix().compareTo(BigDecimal.ZERO) > 0)); // condition

Règle : les matcheurs (any(), eq(), …) doivent être utilisés uniquement dans les appels when(), verify(), doThrow(). Les utiliser dans le code testé (pas dans when) brise le mock.

Matcheurs et types génériques

// void retour → vérifier sans argument verify(repository).delete(any()); // Liste générique verify(repository).findAll(); // Dictionnaire / Map verify(mapper).put(eq("clé"), anyString()); // Nullable générique : tout argument, même null verify(repo).findById(isNull()); // Inversion de sens (vérifier ce qui n'a PAS été appelé) verify(repository, never()).delete(any(UUID.class));

Règle : quand un argument utilise un matcheur (any(), eq(), isNull()…), tous les autres arguments du même appel doivent aussi être des matcheurs. Mélanger une valeur littérale avec un matcheur dans le même verify() ou when() lève InvalidUseOfMatchersException.

BDD Mockito (Given / When / Then)

@ExtendWith(MockitoExtension.class) class ServiceBddTest { @Mock ProduitRepository repo; @InjectMocks GestionProduitService service; @Test void creerProduitValide() { // Given given(repo.save(any(Produit.class))) .willAnswer(inv -> inv.getArgument(0)); var produit = new Produit(UUID.randomUUID(), "Clavier", "Méca", BigDecimal.valueOf(89)); // When var saved = service.creer(produit); // Then then(repo).should().save(produit); assertEquals(produit, saved); } }

Note : le style BDD utilise given(), then(), willAnswer() au lieu de when(), verify(), thenReturn(). Style recommandé quand l’équipe privilégie la lisibilité « scénario ».

Tests de contrôleurs avec MockMvc

MockMvc simule une requête HTTP entrante sans démarrer de serveur réel. Combiné à @WebMvcTest, il ne charge que la couche contrôleurs + des @MockBean pour les dépendances, ce qui rend le test très rapide.

Configuration de base

@WebMvcTest(ProduitController.class) class ProduitControllerTest { @Autowired MockMvc mockMvc; @MockBean ProduitService produitService; @Test void creerRetourne201() throws Exception { when(produitService.creer(any(Produit.class))) .thenReturn(new Produit(UUID.randomUUID(), "Clavier", "Mécanique", new BigDecimal("89.99"))); mockMvc.perform(post("/api/produits") .contentType(MediaType.APPLICATION_JSON) .content(""" {"nom":"Clavier","description":"Mécanique","prix":89.99} """)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.nom").value("Clavier")); } }

@WebMvcTest injecte automatiquement MockMvc et désactive le chargement complet du contexte (pas de base, pas de @Service réels). @MockBean remplace un bean du contexte par un mock Mockito.

Assertions JSON avec jsonPath

jsonPath permet de naviguer dans le corps JSON de la réponse :

mockMvc.perform(get("/api/produits/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").exists()) .andExpect(jsonPath("$.nom").value("Clavier")) .andExpect(jsonPath("$.prix").value(89.99)) .andExpect(jsonPath("$.tags").isArray()) .andExpect(jsonPath("$.tags", hasSize(2)));
SurchargeUsage
.value(...)Valeur exacte (String, Number, Boolean, regex avec equalToRegex)
.string(regex)Expression régulière sur une chaîne JSON
.isArray() / .isObject()Vérifier le type JSON
.isEmpty() / .isNotEmpty()Liste ou objet vide ou non
.exists() / .doesNotExist()Présence d’une clé
hasSize(n)Taille d’un tableau ou d’une collection sérialisée

Vérifier l’absence d’erreurs de sérialisation

@Test void serieSansFuitesDeDonnees() throws Exception { when(produitService.obtenir(1L)) .thenReturn(new Produit(1L, "Clavier", "desc", new BigDecimal("50"), "alice@cache")); mockMvc.perform(get("/api/produits/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").doesNotExist()); // `email` ne doit pas figurer dans le JSON public }

Headers et paramètres

mockMvc.perform(get("/api/produits") .header("Authorization", "Bearer " + token) .param("page", "0") .param("size", "20") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(header().string("X-Total-Count", "42"));

Test de réponses d’erreur

@Test void creerAvecDonneesInvalidesRetourne400() throws Exception { mockMvc.perform(post("/api/produits") .contentType(MediaType.APPLICATION_JSON) .content(""" {"nom":"","description":"","prix":-1} """)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.errors").isArray()); }

Piège : @WebMvcTest ne charge pas les @ControllerAdvice globaux (gestionnaire d’exceptions) par défaut. Pour les tester, les inclure explicitement : @WebMvcTest(value = ProduitController.class, includeFilters = @ComponentScan.Filter(ControllerAdvice.class)), ou utiliser @Import(ControllerAdvice.class).

Piège : les @MockBean sont créés par classe de test, pas par méthode. Un @MockBean défini dans un test affecte tous les tests de la même classe. Réinitialiser avec Mockito.reset(mockBean) ou utiliser @BeforeEach avec un mock fraîchement configuré.

Note : pour les tests de contrôleurs sérialisant des objets avec des relations JPA (ex. @JsonIgnore mal configuré), privilégier @JsonTest avec JacksonTester pour isoler la configuration de sérialisation sans charger la couche contrôleur.

Tests paramétrés

@ParameterizedTest @ValueSource(ints = {2, 4, 6, 8}) void pair(int nombre) { assertTrue(nombre % 2 == 0); } @ParameterizedTest @NullAndEmptySource void accepteVide(String texte) { assertTrue(texte == null || texte.isEmpty()); } @ParameterizedTest(name = "{index} → affichage de « {0} »") @CsvSource({ "Alice, admin", "Bob, user", "Charlie, guest" }) void roleAssocie(String nom, String role) { var user = new User(nom, role); assertEquals(role, user.getRole()); }

Tests imbriqués (Nested)

@TestMethodOrder(MethodOrderer.OrderAnnotation.class) class ProduitTest { @Nested @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class Validation { @Test @Order(1) void nomVideLeve() { assertThrows(IllegalArgumentException.class, () -> new Produit(UUID.randomUUID(), "", "desc", BigDecimal.TEN)); } @Test @Order(2) void prixNegatifLeve() { assertThrows(IllegalArgumentException.class, () -> new Produit(UUID.randomUUID(), "Clavier", "desc", BigDecimal.valueOf(-1))); } } @Nested class Persistance { @Test void sauvegardeRetourneLentite() { /* ... */ } } }

Avantage : la classe imbriquée crée un préfixe de nom (ex. ProduitTest.Validation → nomVideLeve) dans le rapport d’exécution, sans modifier le nom de la méthode.

Extensions personnalisées

// Extension basique : logger via TestReporter public class LoggingExtension implements TestWatcher { @Override public void testSuccessful(ExtensionContext context, TestReporter reporter) { reporter.publish("success", context.getDisplayName()); } @Override public void testFailed(ExtensionContext context, TestReporter reporter, Throwable cause) { reporter.publish("failure", cause.getMessage()); } } @ExtendWith(LoggingExtension.class) class MaClasseTest { /* ... */ }

TestReporter

Accessible via ExtensionContext.getTestReporter(). Les entrées sont consultables dans le rapport HTML de Maven/Gradle (surefire-reports / test-results).

@Test void testAvecRapport(ExtensionContext ctx) { var reporter = ctx.getTestReporter(); reporter.publish("url_testee", "https://api.exemple.com/health"); reporter.publish("status", "200"); }

Exécution parallèle

Maven (pom.xml)

<plugin> <artifactId>maven-surefire-plugin</artifactId> <version>3.5.2</version> <configuration> <parallel>methods</parallel> <threadCount>4</threadCount> </configuration> </plugin>

Gradle (build.gradle)

test { parallelMode = 'methods' maxParallelForks = 4 }

Unités compatibles

NiveauDescription
classesClasses de test en parallèle
classes_instancesMéthodes dans une même classe en parallèle
methodsMéthodes dans une même classe en parallèle
allToutes les méthodes de toutes les classes

Piège : l’exécution parallèle nécessite l’isolation complète entre tests. L’état partagé (variables de classe, fichiers, dépendances) brise les résultats. Testcontainers nécessite @Testcontainers (qui gère les conteneurs statiques) et les tests avec conteneurs ne sont pas exécutés en parallèle avec d’autres tests utilisant le même conteneur.

Testcontainers

Testcontainers lance des conteneurs Docker à la volée pour les tests d’intégration (BD, caches, brokers). Les conteneurs sont créés au premier accès et détruits à la fin de la JVM.

Installation

testImplementation 'org.testcontainers:testcontainers:1.20.4' testImplementation 'org.testcontainers:junit-jupiter:1.20.4' testImplementation 'org.testcontainers:postgresql:1.20.4' testImplementation 'org.testcontainers:redis:1.20.4'
<dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers</artifactId> <version>1.20.4</version> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>1.20.4</version> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <version>1.20.4</version> <scope>test</scope> </dependency>

PostgreSQL

@Testcontainers class RepositoryTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine") .withDatabaseName("testdb") .withUsername("test") .withPassword("test"); @DynamicPropertySource static void configurer(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); registry.add("spring.docker.compose.skip.in-tests", () -> true); } @Test void sauvegardeEtRetrouve(ProduitRepository repo) { var produit = new Produit(UUID.randomUUID(), "Clavier", "Méca", BigDecimal.TEN); repo.save(produit); var retrouvé = repo.findById(produit.getId()).orElseThrow(); assertEquals("Clavier", retrouvé.getNom()); } }

Redis

@Testcontainers class CacheTest { @Container static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine") .withExposedPorts(6379); @DynamicPropertySource static void configurer(DynamicPropertyRegistry registry) { registry.add("spring.data.redis.host", redis::getHost); registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379).toString()); } @Test void stockeEtLit(CacheService cache) { cache.set("cle", "valeur"); assertEquals("valeur", cache.get("cle")); } }

Bonnes pratiques

  • Statique : déclarer @Container en static pour un seul conteneur partagé entre tous les tests de la classe.
  • @DynamicPropertySource : injecter les propriétés de connexion sans fichier de config statique.
  • @Testcontainers : obligatoire sur la classe pour activer la gestion du cycle de vie des conteneurs.
  • Isolation : chaque classe de test reçoit sa propre instance de conteneur si le marquage static est retiré.
  • Portabilité : pour les CI/CD, ajouter -Dtestcontainers.checks.offline=true pour contourner les vérifications de connexion internet au démarrage.

Piège : un conteneur static est partagé entre toutes les classes de test qui le référencent. Deux classes testant la même base de données peuvent se marcher sur les données l’une de l’autre. Séparer les tests par conteneur dédié (non-static) ou vider la base entre chaque test.

Vérification rapide

# Exécuter tous les tests mvn test # Exécuter un seul test mvn test -Dtest=ProduitTest # Exécuter une seule méthode mvn test -Dtest=ProduitTest#validationNomVide # Ignorer les tests avec @Disabled mvn test -DfailIfNoTests=false # Générer le rapport de couverture (avec JaCoCo) mvn test jacoco:report

Pièges courants

Piège Mockito : when(methodeVoid(...)) lève une NullPointerException. Pour les méthodes void, utiliser doThrow(), doNothing() ou doAnswer().

Piège JUnit : @TestInstance(PER_CLASS) + @BeforeAll non-static sans annotation = erreur de compilation. Vérifier que l’annotation est bien présente.

Piège matchers : un appel when() ou verify() avec au moins un matcheur doit utiliser des matcheurs pour tous les arguments. Mix eq("fixe") et any() obligatoire, pas avec une valeur littérale.

Piège paramétré : @CsvSource ne gère pas les virgules dans les valeurs. Utiliser @MethodSource ou @ArgumentsSource pour les données complexes.

Piège parallel : deux tests accédant au même fichier, à la même base de données en mémoire, ou à la même variable de classe en parallèle produisent des résultats non déterministes. Vérifier l’isolation avant d’activer le parallélisme.