Tests d'intégration : @WebMvcTest, @DataJpaTest et Testcontainers

Publié le · Mis à jour le

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-test ou spring-boot-starter-data-jpa-test, et les imports ont changé de package (par exemple org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest). Laisse ton IDE proposer l'import. Et l'ancien @MockBean n'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 de MockMvc é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 @MockitoBean sur 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

  1. La plus petite tranche qui suffit : @WebMvcTest pour un contrôleur, @DataJpaTest pour un repository, @SpringBootTest pour un parcours.
  2. Teste tes requêtes, pas celles de Spring Data.
  3. flush et clear dans les tests de repository, pour vérifier la vraie base et non la mémoire d'Hibernate.
  4. La même base qu'en production, avec Testcontainers, plutôt que H2.
  5. @SpringBootTest pour les parcours importants, pas pour chaque cas d'erreur.
  6. Chaque test prépare ses données et ne dépend d'aucun autre.
  7. Jamais d'appel à une vraie API externe.
  8. Une configuration de test uniforme, pour que Spring réutilise son contexte.
  9. @MockitoBean avec Spring Boot 4 (@MockBean n'existe plus).

À retenir

  • Un test d'intégration vérifie que plusieurs couches fonctionnent ensemble : requêtes SQL, JSON, validation, gestionnaire d'erreurs, configuration.
  • @WebMvcTest teste la couche web avec MockMvc et un service simulé ; @DataJpaTest teste les repositories avec une base ; @SpringBootTest démarre toute l'application.
  • Testcontainers fournit une vraie base PostgreSQL dans Docker, et @ServiceConnection s'y connecte automatiquement.
  • Dans un test de repository, flush et clear garantissent 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.

Avis sur l'article

Pas encore de note : soyez le premier à donner votre avis.

Les avis sont publiés après validation par le modérateur.

Votre note