Tests d'intégration : @WebMvcTest, @DataJpaTest et Testcontainers
L'idée en une phrase
Un test d'intégration vérifie que plusieurs morceaux fonctionnent ensemble : ton contrôleur avec Spring MVC et la sérialisation JSON, ton repository avec une vraie base de données, ou toute l'application d'un bout à l'autre. Il est plus lent qu'un test unitaire, mais il détecte ce qu'un test unitaire ne peut pas voir : une requête SQL fausse, une annotation oubliée, un JSON mal formé, une configuration incorrecte.
Une image pour comprendre : le banc de montage
Reprenons l'usine de la fiche sur les tests unitaires. Chaque pièce a passé son banc d'essai : le frein freine, le moteur tourne. Mais une fois assemblés, le frein est-il bien relié à la pédale ? Le moteur est-il bien fixé à la boîte de vitesses ?
Avant de sortir la voiture sur la route, on passe donc par un banc de montage : on assemble quelques pièces réelles ensemble et on vérifie qu'elles s'emboîtent.
- un test unitaire vérifie la pièce seule ;
- un test d'intégration vérifie l'assemblage ;
- un test de bout en bout (une autre fiche) vérifie la voiture entière sur la route.
Ce qu'un test unitaire ne voit pas
Tous ces bugs passent les tests unitaires, parce que les mocks font exactement ce qu'on leur dit :
| Bug | Pourquoi le test unitaire ne le voit pas |
|---|---|
une requête @Query avec une faute dans le nom d'un champ |
le repository est un mock : la requête n'est jamais exécutée |
@Valid oublié dans le contrôleur |
le contrôleur n'est pas appelé à travers Spring MVC |
| un champ du DTO mal nommé dans le JSON | aucune sérialisation JSON n'a lieu |
| le gestionnaire d'erreurs renvoie 500 au lieu de 404 | le @RestControllerAdvice n'est pas chargé |
| un script Flyway qui ne correspond pas aux entités | aucune base de données n'est utilisée |
@Transactional ignoré (appel interne) |
aucun proxy Spring n'existe dans un test unitaire |
Les tests d'intégration ne remplacent pas les tests unitaires : ils complètent. Les règles métier se testent en unitaire, vite et en détail. L'assemblage se vérifie en intégration, avec moins de tests mais plus réalistes.
Trois outils, du plus ciblé au plus complet
Spring Boot propose de démarrer une partie de l'application seulement (une tranche, slice en anglais), ou toute l'application :
| Annotation | Ce qui est démarré | Pour tester |
|---|---|---|
@WebMvcTest |
la couche web : contrôleurs, validation, JSON, gestionnaire d'erreurs | les contrôleurs |
@DataJpaTest |
la couche de persistance : entités, repositories, base de données | les repositories et les requêtes |
@SpringBootTest |
toute l'application | un parcours complet, de la requête HTTP à la base |
Plus on démarre de choses, plus le test est réaliste… et lent. Choisis la plus petite tranche qui suffit à vérifier ce que tu veux.
Note sur Spring Boot 4 : les outils de test ont été découpés en modules. Selon ce que tu testes, le projet a besoin de
spring-boot-starter-webmvc-testouspring-boot-starter-data-jpa-test, et les imports ont changé de package (par exempleorg.springframework.boot.webmvc.test.autoconfigure.WebMvcTest). Laisse ton IDE proposer l'import. Et l'ancien@MockBeann'existe plus : on utilise@MockitoBean.
Tester un contrôleur : @WebMvcTest
@WebMvcTest démarre la couche web sans les services ni la base. On simule le service avec @MockitoBean, et on envoie de vraies requêtes HTTP à travers Spring MVC, sans démarrer de serveur, grâce à MockMvc.
@WebMvcTest(ProduitController.class)
class ProduitControllerTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
ProduitService produitService; // un mock, enregistré comme bean dans Spring
@Test
void renvoie_le_produit_en_json() throws Exception {
when(produitService.trouver(42L))
.thenReturn(new ProduitDto("Clavier mécanique", new BigDecimal("89.90")));
mockMvc.perform(get("/produits/42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.nom").value("Clavier mécanique"))
.andExpect(jsonPath("$.prix").value(89.90));
}
@Test
void renvoie_404_si_le_produit_est_introuvable() throws Exception {
when(produitService.trouver(99L))
.thenThrow(new RessourceIntrouvableException("Produit", 99L));
mockMvc.perform(get("/produits/99"))
.andExpect(status().isNotFound());
}
@Test
void refuse_un_produit_sans_nom() throws Exception {
mockMvc.perform(post("/produits")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{ "nom": "", "prix": 10.00 }
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.erreurs.nom").exists());
verifyNoInteractions(produitService); // la validation a bloqué la requête avant le service
}
}
Ces trois tests vérifient ce qu'un test unitaire du contrôleur ne pourrait pas :
- l'adresse et la méthode HTTP sont les bonnes ;
- le JSON produit a les bons noms de champs ;
- le gestionnaire d'erreurs transforme l'exception en 404 (il est chargé automatiquement par
@WebMvcTest) ; - la validation est bien déclenchée, et renvoie la liste des champs en erreur (voir la fiche sur la validation des DTO).
Astuce : écrire le corps de la requête dans un text block (
""") rend le test lisible, et vérifie au passage le vrai format JSON attendu par l'API, celui que le front-end enverra.
Note : Spring propose aussi
MockMvcTester, une variante récente qui s'écrit avec le style d'AssertJ (assertThat(mvc.get().uri("/produits/42")).hasStatusOk()). Les deux font la même chose : suis le style du projet.
Tester un repository : @DataJpaTest
@DataJpaTest démarre seulement la couche de persistance : les entités, les repositories et une base de données. Chaque test est annulé à la fin (rollback) : la base revient à son état initial.
Inutile de tester findById ou save : c'est Spring Data qui les fournit. Teste tes requêtes : les méthodes dérivées un peu complexes et les @Query.
@DataJpaTest
class ProduitRepositoryTest {
@Autowired
ProduitRepository produitRepository;
@Autowired
TestEntityManager entityManager;
@Test
void decremente_le_stock_quand_il_en_reste() {
Produit clavier = entityManager.persistFlushFind(new Produit("Clavier", 1));
int modifies = produitRepository.decrementerStock(clavier.getId());
entityManager.clear(); // ✅ oublie les objets en mémoire, pour relire la vraie base
assertThat(modifies).isEqualTo(1);
assertThat(produitRepository.findById(clavier.getId()).orElseThrow().getStock()).isZero();
}
@Test
void ne_decremente_pas_un_stock_a_zero() {
Produit clavier = entityManager.persistFlushFind(new Produit("Clavier", 0));
int modifies = produitRepository.decrementerStock(clavier.getId());
assertThat(modifies).isZero();
}
}
On reconnaît la requête UPDATE ... WHERE stock > 0 de la fiche sur la concurrence. Le test vérifie qu'elle est correcte et qu'elle fait bien ce qu'on attend, dans les deux cas.
Pourquoi flush et clear ?
Souviens-toi du cycle de vie des entités (voir la fiche sur les entités JPA) : Hibernate garde les entités gérées en mémoire, et n'écrit en base qu'au moment du flush. Sans précaution, un test peut relire l'objet en mémoire au lieu de la base, et passer alors que la requête SQL est fausse.
| Instruction | Effet |
|---|---|
persistFlushFind(...) |
enregistre l'entité, écrit en base, et la relit |
flush() |
force l'écriture en base de tout ce qui est en attente |
clear() |
vide la mémoire d'Hibernate : la lecture suivante ira vraiment en base |
Une vraie base de données : Testcontainers
Par défaut, @DataJpaTest remplace ta base par une base en mémoire (H2) si elle est présente dans le projet. C'est rapide, mais ce n'est pas ta base de production :
- certaines fonctions SQL de PostgreSQL n'existent pas dans H2, ou s'écrivent autrement ;
- les types de colonnes, les contraintes et les comportements diffèrent ;
- tes scripts de migration Flyway, écrits pour PostgreSQL, peuvent ne pas fonctionner sur H2.
Un test qui passe sur H2 et échoue en production ne protège pas grand-chose.
Testcontainers démarre une vraie base PostgreSQL dans un conteneur Docker, le temps des tests, puis la supprime. Spring Boot s'y connecte automatiquement.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
Une classe de configuration, partagée par tous les tests :
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection // ✅ Spring Boot configure la connexion tout seul
PostgreSQLContainer postgres() {
return new PostgreSQLContainer("postgres:17-alpine"); // la même version qu'en production
}
}
@ServiceConnection remplace toute la configuration manuelle (adresse, utilisateur, mot de passe) : Spring Boot récupère ces informations directement auprès du conteneur.
Pour l'utiliser dans un test de repository :
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // ne pas remplacer par H2
@Import(TestcontainersConfiguration.class)
class ProduitRepositoryTest { ... }
Bonus : au démarrage, Flyway exécute tes scripts de migration sur cette base. Un script cassé fait échouer les tests, bien avant la production.
Note : Testcontainers a besoin de Docker, sur ton poste comme dans le pipeline. Si les tests échouent avec un message comme « Could not find a valid Docker environment », c'est que Docker n'est pas lancé, ou pas disponible sur le runner (voir la fiche sur les pipelines CI/CD).
Tester toute l'application : @SpringBootTest
@SpringBootTest démarre toute l'application : contrôleurs, services, repositories, configuration. Avec Testcontainers, on teste un parcours complet, sans aucun mock :
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class CommandeParcoursIT {
@Autowired
MockMvc mockMvc;
@Autowired
CommandeRepository commandeRepository;
@Test
@Sql("/donnees/client-1-et-produit-10.sql") // le client et le produit utilisés par la commande
void une_commande_creee_est_enregistree_et_consultable() throws Exception {
String reponse = mockMvc.perform(post("/commandes")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{ "clientId": 1, "lignes": [ { "produitId": 10, "quantite": 2 } ] }
"""))
.andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString();
assertThat(commandeRepository.count()).isEqualTo(1);
// puis, par exemple, un GET sur l'adresse renvoyée pour vérifier la lecture
}
}
Ce test traverse toutes les couches pour de vrai : JSON, validation, service, transaction, requêtes SQL, base PostgreSQL. C'est le plus réaliste, mais aussi le plus lent. Réserve-le aux parcours importants, pas à chaque cas particulier : les cas d'erreur détaillés se testent mieux en unitaire ou avec @WebMvcTest.
Note : avec
@SpringBootTest, MockMvc n'est disponible qu'avec@AutoConfigureMockMvc. Sans cette annotation, l'injection deMockMvcéchoue au démarrage du test.
Les données de test
Les tests d'intégration partagent une base : il faut éviter qu'ils se marchent dessus.
| Méthode | Fonctionnement | Attention |
|---|---|---|
@Transactional sur la classe de test |
chaque test est annulé à la fin | rien n'est jamais validé : certains problèmes (LazyInitializationException, contraintes vérifiées au commit) peuvent passer inaperçus (voir la fiche sur @Transactional) |
nettoyage dans @BeforeEach |
commandeRepository.deleteAll() avant chaque test |
il faut supprimer dans le bon ordre, à cause des clés étrangères |
@Sql |
un script SQL exécuté avant le test | pratique pour des données de départ précises |
Et quelques règles simples :
- chaque test prépare ses propres données : il ne compte jamais sur les données laissées par un autre ;
- des valeurs uniques quand une contrainte d'unicité existe (un e-mail différent par test), pour que les tests ne dépendent pas de leur ordre ;
- le minimum de données nécessaires : un test qui insère 50 lignes pour en vérifier une est difficile à comprendre.
Les API externes
Un test d'intégration ne doit jamais appeler une vraie API externe : elle peut être lente, indisponible, payante, ou modifier de vraies données.
- pour un service qui utilise un client HTTP, un
@MockitoBeansur ce client suffit souvent ; - pour tester le client HTTP lui-même (l'adresse appelée, la lecture du JSON reçu), Spring propose
@RestClientTest, et des outils comme WireMock simulent un vrai serveur HTTP qui renvoie les réponses que tu prépares.
Garder les tests rapides
Spring réutilise le contexte
Démarrer Spring prend plusieurs secondes. Pour ne pas le refaire à chaque classe de test, Spring réutilise le même contexte entre les classes qui ont exactement la même configuration.
Mais chaque différence crée un nouveau contexte : un @MockitoBean en plus, une propriété différente, une autre classe importée. Dix classes de test avec dix combinaisons de mocks différentes, c'est dix démarrages de Spring.
Uniformise la configuration des tests @SpringBootTest, par exemple avec une classe de base commune dont les autres héritent.
*Test et *IT
Beaucoup de projets séparent les tests par leur nom :
| Suffixe | Exécuté par | Phase Maven |
|---|---|---|
CommandeServiceTest |
Surefire | mvn test : rapide, à lancer souvent |
CommandeParcoursIT |
Failsafe (s'il est configuré dans le projet) | mvn verify : plus lent, avant de pousser et dans le pipeline |
Ce découpage permet de lancer les tests rapides en permanence, et les tests d'intégration moins souvent. Vérifie la convention de ton projet.
Les bonnes habitudes
- La plus petite tranche qui suffit :
@WebMvcTestpour un contrôleur,@DataJpaTestpour un repository,@SpringBootTestpour un parcours. - Teste tes requêtes, pas celles de Spring Data.
flushetcleardans les tests de repository, pour vérifier la vraie base et non la mémoire d'Hibernate.- La même base qu'en production, avec Testcontainers, plutôt que H2.
@SpringBootTestpour les parcours importants, pas pour chaque cas d'erreur.- Chaque test prépare ses données et ne dépend d'aucun autre.
- Jamais d'appel à une vraie API externe.
- Une configuration de test uniforme, pour que Spring réutilise son contexte.
@MockitoBeanavec Spring Boot 4 (@MockBeann'existe plus).
À retenir
- Un test d'intégration vérifie que plusieurs couches fonctionnent ensemble : requêtes SQL, JSON, validation, gestionnaire d'erreurs, configuration.
@WebMvcTestteste la couche web avec MockMvc et un service simulé ;@DataJpaTestteste les repositories avec une base ;@SpringBootTestdémarre toute l'application.- Testcontainers fournit une vraie base PostgreSQL dans Docker, et
@ServiceConnections'y connecte automatiquement. - Dans un test de repository,
flushetcleargarantissent que tu vérifies la base, pas la mémoire. - Les tests d'intégration sont plus lents : peu nombreux, ciblés sur l'assemblage, en complément des tests unitaires.