Spring Boot et Spring Cloud

Tester Spring Boot 3 : slices, MockMvc, Testcontainers et cache de contextes

Intermédiaire8 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Vous saurez tester un service Spring Boot 3 au bon niveau pour chaque préoccupation : de simples tests unitaires pour la logique, @WebMvcTest avec MockMvc pour les contrôleurs, @DataJpaTest sur une vraie base de données dans Testcontainers pour les repositories, @SpringBootTest pour les vérifications de bout en bout, ainsi que les règles de cache de contexte et de remplacement de beans qui gardent toute la suite rapide et fiable.

Ce que vous allez apprendre

  • Choisir entre un test unitaire, une slice de test et un @SpringBootTest complet pour un morceau de code donné
  • Tester les contrôleurs, la validation et les réponses d'erreur avec @WebMvcTest et MockMvc
  • Exécuter les tests de repository sur une vraie base de données avec @DataJpaTest et @ServiceConnection de Testcontainers
  • Remplacer des collaborateurs avec @MockitoBean et comprendre l'effet des remplacements de beans sur le cache de contexte
  • Écrire des tests de bout en bout avec @SpringBootTest, un port aléatoire et un vrai client HTTP
  • Garder rapide une suite de tests qui grossit en maîtrisant le nombre de contextes applicatifs distincts

Un service Spring Boot contient plusieurs sortes de code, chacune avec ses propres façons d'échouer : des règles métier qui peuvent être fausses, des contrôleurs qui peuvent mal mapper le JSON ou les codes de statut, des requêtes qui peuvent être invalides sur la vraie base de données, et une configuration qui peut injecter le mauvais bean. Un seul type de test ne convient pas à tout cela. Démarrer l'application entière pour chaque vérification rend la suite lente et les échecs flous ; tout mocker la rend rapide et vide de sens. Cette page présente une approche par couches sur Spring Boot 3 avec JUnit 5 et les outils qu'apporte spring-boot-starter-test, et explique la mécanique du cache de contexte, qui décide si une suite de cinq cents tests s'exécute en vingt secondes ou en dix minutes.

Ce que fournit le starter de test

spring-boot-starter-test regroupe JUnit Jupiter, AssertJ, Mockito, Hamcrest, JSONassert, JsonPath, Awaitility et le support de test de Spring lui-même (spring-test, spring-boot-test, spring-boot-test-autoconfigure). Le starter n'inclut plus le moteur vintage de JUnit 4 depuis Boot 2.4 ; les tests utilisent @Test de org.junit.jupiter.api, et l'ancien @RunWith(SpringRunner.class) est remplacé par l'extension que @SpringBootTest et les slices déclarent déjà.

Les niveaux, du plus rapide et ciblé au plus lent et large :

NiveauAnnotationDémarreÀ utiliser pour
UnitaireaucunerienLogique métier, mappers, calculs
Slice web@WebMvcTestinfrastructure MVC + contrôleurs choisisMapping des requêtes, JSON, validation, réponses d'erreur, règles de sécurité
Slice données@DataJpaTestJPA, repositories, une base de donnéesRequêtes, mappings, migrations
Autres slices@JsonTest, @RestClientTest, @DataJdbcTest, @WebFluxTest…une préoccupation chacuneSérialisation, clients HTTP
Contexte complet@SpringBootTesttout, éventuellement un vrai serveurCâblage, parcours de bout en bout, configuration

La règle empirique : placez chaque vérification au niveau le plus bas capable de la prouver. Une règle d'arrondi n'a pas besoin de Spring ; un code de statut n'a pas besoin de base de données ; une requête JPQL a besoin d'une base de données, mais pas de Tomcat.

Tests unitaires : aucun conteneur nécessaire

Comme les applications Boot utilisent l'injection par constructeur, la plupart des classes peuvent être instanciées directement :

class PricingServiceTest {

    private final TaxRates rates = mock(TaxRates.class);
    private final PricingService pricing = new PricingService(rates);

    @Test
    void appliesReducedRateToBooks() {
        when(rates.rateFor(Category.BOOKS)).thenReturn(new BigDecimal("0.055"));

        Money total = pricing.priceWithTax(new Item("ISBN-1", Category.BOOKS, Money.of("20.00")));

        assertThat(total).isEqualTo(Money.of("21.10"));
        verifyNoMoreInteractions(rates);
    }
}

Rien ici n'est propre à Spring, et c'est tout l'intérêt. Si une classe est difficile à construire dans un test, la cause est généralement l'injection par champ ou une dépendance à l'ApplicationContext : corrigez la conception plutôt que de vous rabattre sur @SpringBootTest. Le @ExtendWith(MockitoExtension.class) de Mockito, avec @Mock et @InjectMocks, supprime le code répétitif quand il y a plusieurs collaborateurs.

Les contrôleurs avec @WebMvcTest

@WebMvcTest(OrderController.class) construit un contexte ne contenant que la couche web et le contrôleur indiqué, configure automatiquement MockMvc, et attend de vous que vous fournissiez les dépendances du contrôleur :

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean OrderService orders;

    @Test
    void returnsOrderAsJson() throws Exception {
        when(orders.get(42L)).thenReturn(sampleOrder(42L));

        mvc.perform(get("/api/orders/42").accept(MediaType.APPLICATION_JSON))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.id").value(42))
           .andExpect(jsonPath("$.lines", hasSize(2)));
    }

    @Test
    void rejectsInvalidBodyWithProblemDetail() throws Exception {
        mvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                        {"customerId": null, "lines": []}
                        """))
           .andExpect(status().isBadRequest())
           .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
           .andExpect(jsonPath("$.errors[*].field", containsInAnyOrder("customerId", "lines")));
        verifyNoInteractions(orders);
    }

    @Test
    void mapsDomainExceptionTo404() throws Exception {
        when(orders.get(7L)).thenThrow(new OrderNotFoundException(7L));

        mvc.perform(get("/api/orders/7"))
           .andExpect(status().isNotFound())
           .andExpect(jsonPath("$.title").value("Order not found"));
    }
}

MockMvc pilote directement le DispatcherServlet, sans socket : le test exerce donc la résolution des arguments, la validation, la conversion des messages, @RestControllerAdvice et les filtres, c'est-à-dire exactement ce que des tests unitaires de la classe contrôleur laisseraient passer. Depuis Boot 3.4 (Spring Framework 6.2), MockMvcTester offre la même chose dans le style AssertJ : assertThat(mvc.get().uri("/api/orders/42")).hasStatusOk().bodyJson().extractingPath("$.id").isEqualTo(42).

Si Spring Security est sur le classpath, la slice inclut la configuration de sécurité : les requêtes non authentifiées reçoivent un 401, sauf si vous ajoutez @WithMockUser de spring-security-test ou un post-processor jwt(). C'est une fonctionnalité, puisque cela vous permet de tester les règles d'autorisation endpoint par endpoint sans serveur de jetons.

Les repositories avec @DataJpaTest et Testcontainers

@DataJpaTest configure JPA, les repositories Spring Data et un TestEntityManager, et rend chaque test transactionnel avec un rollback à la fin, si bien que les tests ne voient pas les données des autres. Par défaut, il remplace le DataSource par une base embarquée, ce qui est pratique mais trompeur ; faites-le plutôt pointer sur le moteur que vous déployez :

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired OrderRepository orders;
    @Autowired TestEntityManager em;

    @Test
    void findWithLinesFetchesLinesInOneQuery() {
        Order order = em.persistFlushFind(orderWithLines(3));
        em.clear();                                  // detach so the next load hits the database

        Optional<Order> loaded = orders.findWithLines(order.getId());

        assertThat(loaded).isPresent();
        assertThat(loaded.get().getLines()).hasSize(3);   // no LazyInitializationException: fetch join worked
    }

    @Test
    void expireStaleOrdersOnlyTouchesNewOrders() {
        em.persist(order(OrderStatus.NEW, daysAgo(10)));
        em.persist(order(OrderStatus.SHIPPED, daysAgo(10)));
        em.flush();

        int updated = orders.expireStaleOrders(OrderStatus.EXPIRED, Instant.now().minus(7, DAYS));

        assertThat(updated).isEqualTo(1);
    }
}

@ServiceConnection (Boot 3.1+) lit l'hôte, le port et les identifiants du conteneur et configure le DataSource à partir d'eux, en remplacement de l'ancienne méthode @DynamicPropertySource qui renseignait spring.datasource.url à la main. Flyway s'exécute sur le conteneur comme il le ferait en production, donc le test valide aussi les migrations. Un conteneur static est démarré une fois par classe de test ; pour en partager un sur toute la suite, déclarez-le dans une classe @TestConfiguration importée par chaque test, ou utilisez la fonctionnalité de réutilisation de Testcontainers.

Comme la slice fait un rollback après chaque test, le code qui dépend de ce qui se passe au commit (@TransactionalEventListener, allocation par SEQUENCE, triggers de base de données) ne se déclenchera pas. Annotez un test précis avec @Transactional(propagation = NOT_SUPPORTED) ou @Commit quand c'est justement ce comportement que vous testez, et nettoyez explicitement.

Bout en bout avec @SpringBootTest

Quand la question est « est-ce que l'ensemble fonctionne ? », démarrez l'ensemble :

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderFlowTest {

    @Container @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired TestRestTemplate http;              // pre-configured with the random port
    @MockitoBean PaymentGateway gateway;           // the one dependency that must not be real

    @Test
    void placingAnOrderReturns201AndPersistsIt() {
        when(gateway.charge(any())).thenReturn(Payment.approved("txn-1"));

        ResponseEntity<OrderResponse> response = http.postForEntity("/api/orders",
                new CreateOrderRequest(1L, List.of(new OrderLine("ABC-12345", 2))), OrderResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getHeaders().getLocation()).hasPath("/api/orders/" + response.getBody().id());

        ResponseEntity<OrderResponse> fetched = http.getForEntity(response.getHeaders().getLocation(), OrderResponse.class);
        assertThat(fetched.getBody().status()).isEqualTo("PAID");
    }
}

RANDOM_PORT démarre le serveur embarqué sur un port libre et met à disposition TestRestTemplate (ou WebTestClient si WebFlux est sur le classpath) avec l'URL de base déjà renseignée. L'environnement par défaut, MOCK, démarre le contexte sans serveur et vous permet d'utiliser MockMvc avec @AutoConfigureMockMvc, ce qui est plus rapide et suffisant quand la couche réseau n'est pas l'objet du test.

@SpringBootTest trouve la classe @SpringBootConfiguration en remontant l'arborescence des packages depuis le test : c'est pourquoi la classe applicative se trouve dans le package racine. Les propriétés du test se placent dans properties = "..." sur l'annotation, dans un src/test/resources/application-test.yml activé par @ActiveProfiles("test"), ou via @TestPropertySource ; les deux premières options sont les moins surprenantes.

Gardez ces tests peu nombreux et leur périmètre large. Un test qui couvre un parcours utilisateur réaliste vaut plus que dix tests qui refont ce que les slices vérifient déjà.

Remplacer des beans, et ce que cela coûte

@MockitoBean remplace (ou ajoute) un bean dans le contexte par un mock Mockito réinitialisé après chaque méthode de test ; @MockitoSpyBean enveloppe le vrai bean. Tous deux viennent de Spring Framework 6.2 et succèdent aux @MockBean/@SpyBean de Boot, dépréciés en 3.4. Pour quelque chose de plus élaboré qu'un mock, une classe @TestConfiguration avec des méthodes @Bean, importée par @Import ou déclarée en classe statique imbriquée dans le test, fournit un fake écrit à la main, souvent plus lisible que le stubbing d'une douzaine d'appels.

Le prix de ces remplacements se paie en démarrages de contexte. Spring met en cache les contextes applicatifs d'une classe de test à l'autre, avec pour clé leur configuration : les classes de configuration, les profils actifs, les propriétés, l'ensemble des beans remplacés, l'environnement web, et d'autres éléments encore. Deux classes de test ayant la même clé partagent un contexte ; la moindre différence entraîne un nouveau démarrage complet. Une suite où chaque classe déclare ses propres champs @MockitoBean crée quasiment un contexte par classe et passe l'essentiel de son temps à démarrer Spring.

Les habitudes qui limitent ce nombre :

  • Définissez les mocks et fakes partagés une seule fois, dans une classe de test de base ou une @TestConfiguration commune, et réutilisez le même jeu d'annotations d'une classe à l'autre.
  • Évitez @DirtiesContext, sauf quand un test corrompt réellement un état partagé : il jette le contexte.
  • Regroupez les propriétés de test dans un profil plutôt que dans des attributs properties propres à chaque classe.
  • Utilisez les slices : un contexte @WebMvcTest démarre en une fraction du temps du contexte complet, et il est mis en cache séparément.

Pour voir ce qui se passe, comptez les bannières de Boot (ou les lignes « Started … in x seconds ») dans les logs de test ; logging.level.org.springframework.test.context.cache=DEBUG affiche les hits et misses du cache.

Autres slices à connaître

@JsonTest ne construit que la configuration Jackson et fournit un JacksonTester<T> pour faire l'aller-retour d'un DTO, utile pour les formats de date et le traitement des null. @RestClientTest(InventoryClient.class) configure un MockRestServiceServer lié au RestClient.Builder auto-configuré : vous pouvez ainsi vérifier les requêtes sortantes et scénariser les réponses sans réseau. @DataJdbcTest, @DataMongoTest, @DataRedisTest et @WebFluxTest suivent le même modèle. Chaque slice accepte @Import pour ajouter des beans et excludeAutoConfiguration pour réduire ce qu'elle charge.

Pour les jobs Spring Batch, @SpringBatchTest ajoute JobLauncherTestUtils ; pour le travail planifié ou asynchrone, le await().atMost(...).untilAsserted(...) d'Awaitility remplace Thread.sleep dans les assertions.

Erreurs fréquentes

@SpringBootTest pour tout. Des suites lentes, des échecs flous, et des tests qui passent parce qu'un mock quelque part a renvoyé null. Commencez au niveau unitaire et ne montez que lorsque la vérification l'exige.

Mocker le repository dans un test de service et appeler cela un test d'intégration. Cela ne prouve rien sur la requête. Le comportement d'un repository se teste avec @DataJpaTest et un vrai moteur.

H2 en test, PostgreSQL en production. Requêtes natives, on conflict, colonnes JSON et comportement des séquences diffèrent. Testcontainers ne vous laisse plus d'excuse.

Oublier que la slice n'inclut pas tout. Un @WebMvcTest ne prendra pas en compte un converter @Component ni un @Service dont le contrôleur a besoin ; le message d'erreur indique le bean manquant. Ajoutez-le avec @Import ou un mock.

Faire des assertions sur le getMessage() des exceptions ou sur la sortie des logs. Les deux changent d'une version de bibliothèque à l'autre. Vérifiez plutôt les codes de statut, les corps de réponse et l'état persisté.

Un ensemble de @MockitoBean différent dans chaque classe. Chaque combinaison est un nouveau contexte. Regroupez.

Des tests qui dépendent de l'ordre ou de données résiduelles. La slice de données fait un rollback, mais @SpringBootTest n'en fait pas, sauf si vous ajoutez @Transactional, et les bases Testcontainers persistent pendant toute la classe. Réinitialisez l'état explicitement, ou faites en sorte que chaque test crée ce dont il a besoin avec des clés uniques.

Points clés à retenir

  • Testez chaque préoccupation au niveau le plus bas capable de la prouver : de simples tests unitaires pour la logique, des slices pour les couches web et données, quelques parcours @SpringBootTest pour le câblage.
  • @WebMvcTest avec MockMvc couvre le routage, le JSON, la validation, les réponses d'erreur et les règles de sécurité, sans serveur ni base de données.
  • @DataJpaTest avec @AutoConfigureTestDatabase(replace = NONE) et un @ServiceConnection Testcontainers teste les requêtes et les migrations sur le vrai moteur de base de données.
  • @MockitoBean remplace des collaborateurs dans le contexte ; chaque ensemble distinct de remplacements, de propriétés ou de profils crée un contexte de plus en cache, alors gardez des configurations uniformes.
  • @SpringBootTest(webEnvironment = RANDOM_PORT) avec TestRestTemplate offre de vraies vérifications de bout en bout ; utilisez-les avec parcimonie et faites en sorte qu'elles comptent.
  • Surveillez les démarrages de contexte dans les logs : c'est le nombre de contextes, et non le nombre de tests, qui détermine la durée de la suite.

Sources et lectures complémentaires

Questions fréquentes

Pourquoi mes tests Spring Boot sont-ils si lents ?
Presque toujours parce que la suite démarre de nombreux contextes applicatifs distincts. Spring met un contexte en cache et le réutilise pour toutes les classes de test ayant la même configuration, mais la moindre différence (un autre ensemble de champs @MockitoBean, d'autres propriétés, un autre profil actif, @DirtiesContext) en crée un nouveau, et chaque démarrage coûte plusieurs secondes. Regroupez les mocks dans une configuration de test partagée, évitez les propriétés propres à chaque classe, et comptez dans les logs combien de fois la bannière de Boot s'affiche.
Qu'est-ce qui remplace @MockBean depuis Spring Boot 3.4 ?
@MockitoBean et @MockitoSpyBean, apportés par Spring Framework 6.2 dans le package org.springframework.test.context.bean.override.mockito. Boot a déprécié ses propres @MockBean et @SpyBean en 3.4 à leur profit. Pour la plupart des usages, le comportement est identique : le bean du contexte est remplacé par un mock Mockito réinitialisé après chaque test, et ce remplacement compte comme une différence pour le cache de contexte.
Les tests de repository doivent-ils utiliser H2 ou la vraie base de données ?
Le vrai moteur de base de données, via Testcontainers. H2 accepte du SQL que PostgreSQL ou MySQL rejettent et ne dispose ni de leurs types ni de leurs fonctions : une suite verte sur H2 peut masquer une migration ou une requête native qui échoue. Par défaut, @DataJpaTest remplace votre DataSource par une base embarquée ; désactivez ce comportement avec @AutoConfigureTestDatabase(replace = NONE) et branchez-le sur un conteneur avec @ServiceConnection.
@WebMvcTest charge-t-il mes services et mes repositories ?
Non. Il crée un contexte limité à la couche web : contrôleurs, controller advice, filtres, converters, beans WebMvcConfigurer et Spring Security s'il est présent. Chaque service dont dépend un contrôleur doit être fourni, en général sous forme de @MockitoBean ; s'il manque, le contexte refuse de démarrer avec une erreur de dépendance non satisfaite, et c'est précisément le rôle de la slice.
SpringTester une application Spring : tests unitaires, TestContext et MockMvcTester le code Spring au bon niveau : tests unitaires purs, contextes en cache avec SpringExtension, rollback transactionnel, @MockitoBean et MockMvc.Intermédiaire7 min de lectureTestsTests unitaires en Java avec JUnit 5 et Mockito 5Des tests unitaires qui survivent au refactoring : cycle de vie JUnit 5, assertions, tests paramétrés et imbriqués, AssertJ, doublures et stubs Mockito.Débutant8 min de lectureTestsTests d'intégration avec de vraies dépendances : Testcontainers, WireMock et contratsTester de vraies frontières : bases en conteneur, isolation d'état, HTTP simulé avec WireMock, tests de broker et contrats pilotés par le consommateur.Intermédiaire10 min de lectureAPI REST avec Spring Boot 3 : contrôleurs, DTO, codes de statut et clientsConstruire une API REST JSON avec Spring Boot 3 : contrôleurs orientés ressources, records comme DTO, bons codes de statut, pagination, CORS et RestClient.Intermédiaire9 min de lectureSpring Data JPA avec Spring Boot : repositories, requêtes, transactions et migrationsSpring Data JPA sur Spring Boot 3 et Hibernate 6 : repositories, méthodes dérivées et @Query, pagination, projections, transactions, N+1 et Flyway.Intermédiaire8 min de lectureValidation et gestion des erreurs avec Spring Boot 3 : Bean Validation et Problem DetailsValider corps et paramètres avec Jakarta Bean Validation, puis transformer chaque échec en réponse Problem Details RFC 9457 cohérente.Intermédiaire7 min de lecture