Spring Framework
Tester une application Spring : tests unitaires, TestContext et MockMvc
Intermédiaire7 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
L'essentiel du code Spring doit se tester sans Spring, et le reste avec un contexte soigneusement mis en cache. Ce guide montre comment l'injection par constructeur garde les tests unitaires rapides, comment le framework Spring TestContext charge et réutilise les contextes sous JUnit 5, comment tester l'accès aux données avec rollback transactionnel et @Sql, comment remplacer des beans par des mocks, et comment mettre les contrôleurs à l'épreuve via MockMvc et MockMvcTester, basé sur AssertJ, sans démarrer de serveur.
Ce que vous allez apprendre
- Identifier les tests qui ont besoin d'un contexte Spring et écrire les autres comme de simples tests JUnit 5
- Charger un contexte de test avec @SpringJUnitConfig, des profils et des propriétés de test, et le garder en cache d'une classe à l'autre
- Tester des repositories dans des transactions annulées, avec des données initialisées par @Sql
- Remplacer des collaborateurs avec @MockitoBean et @TestBean sans dégrader le cache de contextes plus que nécessaire
- Tester des contrôleurs MVC et des endpoints REST avec MockMvc et MockMvcTester, validation et corps d'erreur compris
Le meilleur test d'un service Spring est celui qui ne charge jamais Spring. Avec l'injection par constructeur, un service est un objet ordinaire que l'on construit avec des fakes en quelques lignes, et ces tests s'exécutent en quelques millisecondes. Le support de test de Spring sert aux parties qui dépendent réellement du framework — câblage, transactions, accès aux données, mapping des requêtes, sécurité — et son problème central est le coût : un ApplicationContext met plusieurs secondes à se construire, si bien que toute la conception du framework TestContext vise à en construire le moins possible et à les réutiliser. Ce guide cible Spring Framework 6.x avec JUnit 5 et le module spring-test ; le sujet consacré à Spring Boot couvre les test slices.
Premier niveau : sans conteneur
class OrderServiceTest {
private final OrderRepository repository = mock(OrderRepository.class);
private final Mailer mailer = mock(Mailer.class);
private final OrderService service = new OrderService(repository, mailer);
@Test
void confirmSendsMailToCustomer() {
when(repository.find(42L)).thenReturn(new Order(42L, "[email protected]"));
service.confirm(42L);
verify(mailer).send("[email protected]", "Order 42 confirmed");
}
}
Rien ici n'est propre à Spring, et c'est tout l'intérêt. Si une classe ne peut pas être testée ainsi — parce qu'elle utilise l'injection par champ, appelle getBean ou lit un holder statique d'ApplicationContext —, corrigez la classe avant de recourir à un conteneur. Ce que les tests purs ne peuvent pas vérifier, c'est le comportement ajouté par Spring : que @Transactional annule bien, que @Cacheable renvoie la valeur en cache, qu'une URL est mappée sur la bonne méthode, que la règle de sécurité refuse l'accès. Pour cela, il faut un contexte.
Le framework TestContext
spring-test s'intègre à JUnit 5 via SpringExtension. L'annotation composée @SpringJUnitConfig associe l'extension et @ContextConfiguration :
@SpringJUnitConfig(classes = { PersistenceConfig.class, ServiceConfig.class })
@ActiveProfiles("test")
@TestPropertySource(properties = "app.mail.enabled=false")
class CatalogServiceIntegrationTest {
@Autowired CatalogService catalog; // l'injection par champ est acceptable dans les tests
@Test
void repricesAndRecordsHistory(@Autowired PriceHistoryRepository history) { // l'injection par paramètre fonctionne aussi
catalog.reprice("SKU-1", Money.of(12_50));
assertThat(history.forSku("SKU-1")).hasSize(1);
}
}
Utilisez une configuration de test dédiée : une classe @Configuration dans src/test/java qui importe la configuration de production et remplace ce qui doit l'être par une doublure de test, comme un Clock ou une passerelle sortante. Limitez le nombre de ces configurations, pour la raison expliquée ci-dessous.
Le cache de contextes
Le framework met en cache chaque contexte qu'il construit, avec pour clé l'ensemble complet des entrées : classes ou emplacements de configuration, profils actifs, sources de propriétés de test, initialiseurs de contexte, customizers (remplacements de beans compris) et loader. Deux classes de test aux entrées identiques partagent un seul contexte pendant toute l'exécution de la JVM ; la moindre différence en crée un autre. Une suite de cinquante classes de test et trois configurations construit trois contextes ; la même suite avec un @TestPropertySource différent sur chaque classe en construit cinquante.
@DirtiesContext marque un contexte comme inutilisable après le test et force sa reconstruction. C'est parfois nécessaire — un test qui modifie l'état d'un singleton, ou qui arrête un bean —, mais c'est plus souvent le symptôme d'un test qui devrait remettre en ordre ce qu'il a touché. Préférez réinitialiser l'état dans @AfterEach. La propriété statique spring.test.context.cache.maxSize et la ligne de log Started … context vous aident à voir combien de contextes une exécution crée.
Propriétés, profils et valeurs dynamiques
@ActiveProfiles sélectionne les beans conditionnés par un profil, @TestPropertySource ajoute des propriétés avec la priorité la plus haute, et @DynamicPropertySource fournit des valeurs connues seulement à l'exécution — par exemple le port d'un conteneur démarré par Testcontainers :
@SpringJUnitConfig(PersistenceConfig.class)
@Testcontainers
class RepositoryContractTest {
@Container
static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("db.url", postgres::getJdbcUrl);
registry.add("db.user", postgres::getUsername);
registry.add("db.password", postgres::getPassword);
}
…
}
Tester sur le vrai moteur de base de données plutôt que sur H2 révèle les différences de dialecte — séquences, RETURNING, colonnes JSON, sensibilité à la casse — qu'un substitut en mémoire masque. Le conteneur est partagé par tous les tests de la classe, puisque le champ est statique.
Tests d'accès aux données
Ajoutez @Transactional à une classe de test et chaque méthode s'exécute dans une transaction annulée à la fin, si bien que les tests ne peuvent pas polluer les données les uns des autres. Insérez les données de départ avec @Sql :
@SpringJUnitConfig(PersistenceConfig.class)
@Transactional
class JpaProductRepositoryTest {
@Autowired ProductRepository products;
@PersistenceContext EntityManager em;
@Test
@Sql("/sql/three-products.sql") // s'exécute dans la même transaction, avant la méthode
void findsBySku() {
assertThat(products.findBySku("SKU-2")).isPresent();
}
@Test
void savesNewProduct() {
Product saved = products.save(new Product("SKU-9", "Kettle", 2_999));
em.flush(); // force l'INSERT pour que les contraintes soient vérifiées
em.clear(); // vide le cache de premier niveau pour que la lecture suivante passe par le SQL
assertThat(products.findBySku("SKU-9")).map(Product::getName).contains("Kettle");
}
@Test
@Commit // conserve les données — rarement souhaité
void seedsReferenceData() { … }
}
@Sql accepte aussi executionPhase = AFTER_TEST_METHOD pour les scripts de nettoyage, et @SqlConfig pour les séparateurs et la gestion des erreurs. TestTransaction.flagForCommit() et TestTransaction.end() offrent un contrôle programmatique quand un test doit observer des données réellement commitées — pour vérifier un @TransactionalEventListener, par exemple, qui par définition ne se déclenche jamais dans un test annulé.
La ligne flush() n'a rien d'une formalité. Avec JPA, l'INSERT de save est différé jusqu'au flush ; si le test ne fait ses assertions que sur l'entité gérée en mémoire, il passe même lorsque la base aurait rejeté la ligne.
Remplacer des beans
Spring Framework 6.2 a intégré le remplacement de beans dans spring-test, qui ne dépend donc plus de Spring Boot pour cela :
@SpringJUnitConfig(ServiceConfig.class)
class CheckoutServiceTest {
@MockitoBean PaymentGateway gateway; // remplace le bean PaymentGateway par un mock Mockito
@MockitoSpyBean OrderRepository orders; // enveloppe le vrai bean dans un spy
@Autowired CheckoutService checkout;
@Test
void declinedPaymentLeavesOrderUnpaid() {
when(gateway.charge(any())).thenReturn(PaymentResult.declined("insufficient funds"));
checkout.pay(7L);
verify(orders, never()).markPaid(7L);
}
}
@TestBean en est la variante sans Mockito : il remplace un bean par la valeur de retour d'une méthode factory statique de la classe de test, ce qui convient à un fake écrit à la main doté d'un vrai comportement. Les trois créent un customizer de contexte : une classe de test avec @MockitoBean PaymentGateway obtient donc un contexte en cache différent de celle qui n'en a pas. Déclarer les remplacements dans une classe de base abstraite que les tests apparentés étendent garde la clé de cache identique entre eux.
Mockez aux frontières — passerelles de paiement, envoi de mails, API distantes, horloge — et gardez les vrais beans entre les deux. Un test qui mocke le repository sous un service lui-même mocké dans le test du contrôleur n'a rien vérifié du câblage.
Tester les contrôleurs avec MockMvc
MockMvc pilote le DispatcherServlet avec des objets requête et réponse servlet simulés. Le construire à partir d'un WebApplicationContext fait intervenir vos vrais mappings, convertisseurs, @ControllerAdvice et intercepteurs :
@SpringJUnitWebConfig(WebConfig.class) // @SpringJUnitConfig + @WebAppConfiguration
class ProductControllerTest {
MockMvc mvc;
@MockitoBean ProductService products;
@BeforeEach
void setUp(WebApplicationContext wac) {
mvc = MockMvcBuilders.webAppContextSetup(wac).build();
}
@Test
void returnsProductAsJson() throws Exception {
when(products.find(1L)).thenReturn(Optional.of(new Product(1L, "Kettle", 2_999)));
mvc.perform(get("/api/products/1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Kettle"))
.andExpect(jsonPath("$.priceCents").value(2999));
}
@Test
void rejectsInvalidBodyWithProblemDetail() throws Exception {
mvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "", "priceCents": -5}
"""))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.errors[*].field").value(containsInAnyOrder("name", "priceCents")));
}
}
MockMvcBuilders.standaloneSetup(new ProductController(products)) se passe entièrement de contexte et reste le moyen le plus rapide de tester le mapping et le binding d'un seul contrôleur ; enregistrez l'advice avec .setControllerAdvice(new ApiExceptionHandler()). Le revers est que le mode standalone utilise la configuration MVC par défaut : les convertisseurs ou argument resolvers personnalisés de votre WebMvcConfigurer sont absents, sauf si vous les ajoutez à la main.
MockMvcTester
Spring Framework 6.2 a ajouté MockMvcTester, une façade native AssertJ sur le même moteur, qui supprime l'exception checked et la soupe d'imports statiques :
MockMvcTester tester = MockMvcTester.from(wac);
assertThat(tester.get().uri("/api/products/{id}", 1).accept(MediaType.APPLICATION_JSON))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.bodyJson().extractingPath("$.name").isEqualTo("Kettle");
assertThat(tester.get().uri("/api/products/999"))
.hasStatus(HttpStatus.NOT_FOUND)
.hasContentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON);
Les exceptions levées par le contrôleur sont capturées dans le résultat au lieu de se propager : vous pouvez donc vérifier .hasFailed() et inspecter la cause. Les deux API conviennent ; le tester est plus lisible pour les nouveaux tests et nécessite AssertJ dans le classpath de test.
Si Spring Security est présent, ajoutez spring-security-test et appliquez springSecurity() de SecurityMockMvcConfigurers au builder ; @WithMockUser et with(jwt()) permettent alors de tester les règles d'autorisation sans avoir à s'authentifier réellement.
Tester les appels HTTP sortants
MockRestServiceServer se lie à un RestClient.Builder (ou à un RestTemplate) et rejoue des réponses préparées, ce qui permet de tester une classe de passerelle face au JSON exact documenté par votre partenaire :
RestClient.Builder builder = RestClient.builder().baseUrl("https://inventory.internal/api");
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
InventoryGateway gateway = new InventoryGateway(builder.build());
server.expect(requestTo("https://inventory.internal/api/stock/SKU-1"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("""
{"sku": "SKU-1", "available": 3}
""", MediaType.APPLICATION_JSON));
assertThat(gateway.stockFor("SKU-1").available()).isEqualTo(3);
server.verify();
Erreurs fréquentes
Charger un contexte pour tester de la logique. Si l'assertion porte sur un calcul ou une branche, un test pur est plus rapide, plus clair, et ne casse pas quand la configuration change.
Un contexte différent par classe de test. Des valeurs @TestPropertySource propres à chaque classe, des @Import ponctuels et des @MockitoBean éparpillés multiplient les contextes. Regroupez.
Faire des assertions sur un état JPA non flushé. Le test passe ; la production échoue sur la contrainte. Faites un flush et un clear avant de relire.
@DirtiesContext par réflexe. Il transforme un contexte en cache en contexte reconstruit pour chaque classe qui le porte. Réinitialisez plutôt l'état.
Mocker tout ce qui se trouve sous la classe testée dans un test de contexte. Vous avez payé un contexte et n'avez rien vérifié de ce qu'il apporte. Ne mockez qu'aux frontières externes.
Tester la sécurité en appelant directement le service. @PreAuthorize relève du proxy ; un appel direct à l'objet mocké ou réel sans contexte de sécurité ne prouve rien. Utilisez @WithMockUser sur le bean proxifié.
Compter sur H2 pour une application PostgreSQL. Les fonctionnalités du dialecte, le comportement des séquences et les opérateurs JSON diffèrent. Utilisez le vrai moteur dans un conteneur pour les tests d'accès aux données.
Points clés à retenir
- Les classes à injection par constructeur se testent comme des objets ordinaires ; réservez le contexte Spring au câblage, aux transactions, à l'accès aux données, au web et à la sécurité.
@SpringJUnitConfigcharge un contexte mis en cache ; toute différence de configuration, de profils, de propriétés ou de remplacements en crée un autre, alors standardisez les entrées.- Les tests
@Transactionalsont annulés automatiquement ;@Sqlinitialise les données ; faites un flush et un clear avant de vérifier des écritures JPA. @MockitoBean,@MockitoSpyBeanet@TestBean(6.2) remplacent des beans aux frontières et font partie de la clé de cache.MockMvcetMockMvcTestersollicitent les vrais mappings, convertisseurs et advices sans serveur ;standaloneSetupisole un seul contrôleur.MockRestServiceServercouvre les appels HTTP sortants, et@DynamicPropertySourceavec Testcontainers les vraies bases de données.
Sources et lectures complémentaires
Questions fréquentes
- Pourquoi ma suite de tests est-elle lente alors que chaque test est rapide ?
- Presque toujours à cause de la multiplication des contextes. Chaque combinaison distincte de classes de configuration, de profils actifs, de propriétés de test et de remplacements de beans crée et met en cache un ApplicationContext séparé, et chacun coûte plusieurs secondes. Limitez-vous à un petit nombre de configurations de test, évitez @DirtiesContext et placez les déclarations @MockitoBean dans une classe de base ou une configuration partagée pour que les classes partagent un contexte. Spring journalise des lignes « Started … context » que vous pouvez compter.
- @Transactional sur un test annule-t-il vraiment mes modifications JPA ?
- Oui — la transaction est annulée par défaut après chaque méthode de test — mais avec une subtilité : Hibernate diffère le SQL jusqu'au flush, et une transaction marquée pour rollback peut ne jamais être flushée, si bien que les violations de contraintes et les erreurs de mapping restent invisibles. Appelez entityManager.flush() (puis clear()) en fin de test, ou juste après l'écriture à vérifier, pour que le SQL s'exécute réellement.
- Quelle différence entre @MockitoBean et le @MockBean de Spring Boot ?
- Ils font le même travail : remplacer ou ajouter un bean dans le contexte de test par un mock Mockito. @MockitoBean fait partie du module spring-test de Spring Framework 6.2 et fonctionne avec n'importe quel test Spring, tandis que @MockBean venait de Spring Boot et a été déprécié dans Boot 3.4 au profit de @MockitoBean. Les deux créent une clé de cache de contexte distincte pour chaque combinaison de beans mockés.
- Faut-il utiliser MockMvc ou un vrai serveur avec TestRestTemplate ?
- MockMvc exécute le DispatcherServlet dans le processus avec des objets requête simulés : pas de port, pas d'analyse HTTP, c'est rapide, et le contrôleur est atteint via les vrais handler mappings, convertisseurs et advices. En revanche, il ne peut pas tester les filtres servlet enregistrés hors de Spring, la configuration du serveur embarqué ni les détails propres à HTTP comme la compression. Utilisez MockMvc pour le comportement des contrôleurs et quelques tests sur serveur réel pour la surface de déploiement.