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 :
| Niveau | Annotation | Démarre | À utiliser pour |
|---|---|---|---|
| Unitaire | aucune | rien | Logique métier, mappers, calculs |
| Slice web | @WebMvcTest | infrastructure MVC + contrôleurs choisis | Mapping des requêtes, JSON, validation, réponses d'erreur, règles de sécurité |
| Slice données | @DataJpaTest | JPA, repositories, une base de données | Requêtes, mappings, migrations |
| Autres slices | @JsonTest, @RestClientTest, @DataJdbcTest, @WebFluxTest… | une préoccupation chacune | Sérialisation, clients HTTP |
| Contexte complet | @SpringBootTest | tout, éventuellement un vrai serveur | Câ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
@TestConfigurationcommune, 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
propertiespropres à chaque classe. - Utilisez les slices : un contexte
@WebMvcTestdé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
@SpringBootTestpour le câblage. @WebMvcTestavecMockMvccouvre 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.@DataJpaTestavec@AutoConfigureTestDatabase(replace = NONE)et un@ServiceConnectionTestcontainers teste les requêtes et les migrations sur le vrai moteur de base de données.@MockitoBeanremplace 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)avecTestRestTemplateoffre 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.