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-jupiterapporte les annotations@Mock,@Spy,@ExtendWith(MockitoExtension.class). Sans ce starter, l’initialisation par annotation ne fonctionne pas.
Cycle de vie
| Annotation | Déclenchement |
|---|---|
@TestInstance(Lifecycle.PER_CLASS) | Méthodes @BeforeAll/@AfterAll peuvent être non-static |
@BeforeAll | Une fois, avant tous les @Test |
@AfterAll | Une fois, après tous les @Test |
@BeforeEach | Avant chaque @Test |
@AfterEach | Aprè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/@AfterAlldoivent êtrestatic. 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)); // exceptionMessages 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ésAssertions 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ésUsage : 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 :
@Disabledsur un@Testne 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èveNullPointerException—methodeVoidest appelée réellement sur le mock. Pour les méthodesvoid, utiliserdoThrow/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 foisMatcheurs 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)); // conditionRègle : les matcheurs (
any(),eq(), …) doivent être utilisés uniquement dans les appelswhen(),verify(),doThrow(). Les utiliser dans le code testé (pas danswhen) 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êmeverify()ouwhen()lèveInvalidUseOfMatchersException.
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 dewhen(),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)));| Surcharge | Usage |
|---|---|
.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 :
@WebMvcTestne charge pas les@ControllerAdviceglobaux (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
@MockBeansont créés par classe de test, pas par méthode. Un@MockBeandéfini dans un test affecte tous les tests de la même classe. Réinitialiser avecMockito.reset(mockBean)ou utiliser@BeforeEachavec un mock fraîchement configuré.
Note : pour les tests de contrôleurs sérialisant des objets avec des relations JPA (ex.
@JsonIgnoremal configuré), privilégier@JsonTestavecJacksonTesterpour 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
| Niveau | Description |
|---|---|
classes | Classes de test en parallèle |
classes_instances | Méthodes dans une même classe en parallèle |
methods | Méthodes dans une même classe en parallèle |
all | Toutes 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
@Containerenstaticpour 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
staticest retiré. - Portabilité : pour les CI/CD, ajouter
-Dtestcontainers.checks.offline=truepour contourner les vérifications de connexion internet au démarrage.
Piège : un conteneur
staticest 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:reportPièges courants
Piège Mockito :
when(methodeVoid(...))lève uneNullPointerException. Pour les méthodesvoid, utiliserdoThrow(),doNothing()oudoAnswer().
Piège JUnit :
@TestInstance(PER_CLASS)+@BeforeAllnon-staticsans annotation = erreur de compilation. Vérifier que l’annotation est bien présente.
Piège matchers : un appel
when()ouverify()avec au moins un matcheur doit utiliser des matcheurs pour tous les arguments. Mixeq("fixe")etany()obligatoire, pas avec une valeur littérale.
Piège paramétré :
@CsvSourcene gère pas les virgules dans les valeurs. Utiliser@MethodSourceou@ArgumentsSourcepour 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.