Tests en Java¶
L'écosystème de test Java est l'un des plus matures de l'industrie. JUnit 5 est le standard actuel pour les tests unitaires, Mockito pour le mocking, et Testcontainers pour les tests d'intégration avec de vraies bases de données. Cette section couvre les différentes couches de test d'une application Spring Boot 3, avec des exemples bases sur les classes du chapitre 03.
Comparatif des outils de test¶
| Outil | Type | Points forts | Usage typique |
|---|---|---|---|
| JUnit 5 | Framework de base | Parametrize, extensions, annotations expressives | Tous types de tests |
| Mockito | Mocking | API fluide, vérification des interactions, @MockBean | Isolation des dépendances |
| Testcontainers | Intégration | Vrais services Docker (PG, Kafka, Redis), reproductible | Tests d'intégration fiables |
| ArchUnit | Architecture | Vérifié les règles de packaging, dépendances cycliques | Gouvernance architecture en CI |
| AssertJ | Assertions | API fluide et lisible, messages d'erreur clairs | Remplacement de Hamcrest / assertions JUnit |
| JaCoCo | Couverture | Rapport HTML, intégration Maven/Gradle, seuils CI | Mesure et enforcement de couverture |
Configuration des dépendances (pom.xml)¶
<dependencies>
<!-- Spring Boot Starter Test inclut JUnit 5, Mockito, AssertJ, Hamcrest -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers — base PostgreSQL pour les tests d'integration -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Tests unitaires du contrôleur avec @WebMvcTest¶
@WebMvcTest charge uniquement la couche web (contrôleurs, filtres, serialisation JSON). Les beans de service sont exclus et doivent être mockes avec @MockBean.
package com.example.item;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// Charge uniquement le contexte web — rapide (pas de BDD)
@WebMvcTest(ItemController.class)
class ItemControllerTest {
@Autowired
private MockMvc mockMvc; // Client HTTP en memoire
@MockBean
private ItemService service; // Service mocke par Mockito
@Test
@DisplayName("GET /api/items retourne la liste des items en JSON")
void listerTous_retourneListeJson() throws Exception {
// Arrange — configure le mock
Item item = new Item("Clavier", "Clavier mecanique", 89.99);
when(service.listerTous()).thenReturn(List.of(item));
// Act + Assert — requete HTTP simulee et assertions
mockMvc.perform(get("/api/items")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$[0].nom").value("Clavier"))
.andExpect(jsonPath("$[0].prix").value(89.99));
verify(service, times(1)).listerTous();
}
@Test
@DisplayName("GET /api/items/{id} retourne 404 si item inexistant")
void trouverParId_retourne404SiInexistant() throws Exception {
// Arrange — le service leve une exception
when(service.trouverParId(99L))
.thenThrow(new ItemNotFoundException(99L));
// Act + Assert
mockMvc.perform(get("/api/items/99"))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("POST /api/items cree un item et retourne 201")
void creer_retourne201AvecItemCree() throws Exception {
// Arrange
Item itemCree = new Item("Souris", "Souris sans fil", 45.0);
when(service.creer(any(ItemDto.class))).thenReturn(itemCree);
String corpsJson = """
{
"nom": "Souris",
"description": "Souris sans fil",
"prix": 45.0
}
""";
// Act + Assert
mockMvc.perform(post("/api/items")
.contentType(MediaType.APPLICATION_JSON)
.content(corpsJson))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.nom").value("Souris"));
}
@Test
@DisplayName("POST /api/items retourne 400 si validation echoue")
void creer_retourne400SiNomVide() throws Exception {
String corpsInvalide = """
{
"nom": "",
"prix": 10.0
}
""";
mockMvc.perform(post("/api/items")
.contentType(MediaType.APPLICATION_JSON)
.content(corpsInvalide))
.andExpect(status().isBadRequest());
// Le service ne doit jamais etre appele si la validation echoue
verifyNoInteractions(service);
}
@Test
@DisplayName("DELETE /api/items/{id} retourne 204")
void supprimer_retourne204() throws Exception {
doNothing().when(service).supprimer(1L);
mockMvc.perform(delete("/api/items/1"))
.andExpect(status().isNoContent());
verify(service).supprimer(1L);
}
}
Tests de service avec Mockito pur¶
package com.example.item;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
// MockitoExtension initialise les mocks sans Spring
@ExtendWith(MockitoExtension.class)
class ItemServiceTest {
@Mock
private ItemRepository repository;
@InjectMocks // Injecte les mocks dans le service
private ItemService service;
@Test
void trouverParId_retourneItem_siExiste() {
// Arrange
Item item = new Item("Ecran", "27 pouces 4K", 399.0);
when(repository.findById(1L)).thenReturn(Optional.of(item));
// Act
Item resultat = service.trouverParId(1L);
// Assert — AssertJ : assertions fluides et lisibles
assertThat(resultat.getNom()).isEqualTo("Ecran");
assertThat(resultat.getPrix()).isEqualTo(399.0);
}
@Test
void trouverParId_leveException_siInexistant() {
when(repository.findById(99L)).thenReturn(Optional.empty());
// AssertJ : verification que l'exception est bien levee
assertThatThrownBy(() -> service.trouverParId(99L))
.isInstanceOf(ItemNotFoundException.class)
.hasMessageContaining("99");
}
}
Tests d'intégration avec Testcontainers¶
Testcontainers démarré de vrais conteneurs Docker pendant les tests, garantissant une fidelite maximale par rapport à la production.
package com.example;
import com.example.item.ItemDto;
import com.example.item.ItemRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
// @SpringBootTest charge le contexte Spring complet
// RANDOM_PORT demarre un vrai serveur HTTP sur un port aleatoire
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class ItemIntegrationTest {
// @ServiceConnection configure automatiquement le datasource Spring
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ItemRepository repository;
@BeforeEach
void nettoyerBase() {
repository.deleteAll();
}
@Test
void creerEtListerItems_scenario_complet() {
// Creer un item via l'API HTTP
ItemDto dto = new ItemDto(null, "Casque audio", "Casque Bluetooth", 129.0);
ResponseEntity<Object> reponseCreation =
restTemplate.postForEntity("/api/items", dto, Object.class);
assertThat(reponseCreation.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Verifier qu'il apparait dans la liste
ResponseEntity<Object[]> reponseListe =
restTemplate.getForEntity("/api/items", Object[].class);
assertThat(reponseListe.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(reponseListe.getBody()).hasSize(1);
}
@Test
void trouverParId_retourne404_siInexistant() {
ResponseEntity<Object> reponse =
restTemplate.getForEntity("/api/items/9999", Object.class);
assertThat(reponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}
Configuration JaCoCo¶
JaCoCo mesure la couverture de code et peut faire échouer le build si les seuils ne sont pas atteints.
<!-- pom.xml — plugin JaCoCo -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<!-- Initialise l'agent JaCoCo avant les tests -->
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<!-- Genere le rapport HTML apres les tests -->
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
<!-- Verifie les seuils de couverture — echec du build si non atteints -->
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<!-- 80% des lignes doivent etre couvertes -->
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<!-- 70% des branches (if/else, switch) -->
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
# Generer le rapport de couverture
mvn test jacoco:report
# Rapport disponible dans : target/site/jacoco/index.html
# Verifier les seuils sans generer le rapport
mvn verify
Exclure des classes de la couverture
Certaines classes n'ont pas besoin d'être couvertes : classes de configuration, entités JPA, DTOs. Excluez-les dans la configuration JaCoCo pour ne pas fausser les métriques.