
Tests des contrôleurs web avec `MockMvc`
Maîtrisez les tests de vos contrôleurs Spring MVC ou REST avec MockMvc. Simulez des requêtes HTTP et validez les réponses sans démarrer de serveur, en isolant la couche web.
Introduction aux tests de contrôleurs avec MockMvc
Tester la couche web d'une application Spring Boot est essentiel pour s'assurer que les requêtes HTTP sont correctement routées, que les données d'entrée sont bien traitées et que les réponses attendues sont générées. Spring Boot fournit un outil puissant et pratique pour cela : `MockMvc`. Il permet de simuler des appels HTTP vers vos contrôleurs Spring MVC ou REST de manière isolée, sans avoir besoin de démarrer un serveur web complet.
L'avantage principal de `MockMvc` est sa rapidité et son intégration étroite avec le framework Spring Test. Il permet d'exécuter des tests qui se concentrent spécifiquement sur la logique du contrôleur : le mapping des requêtes, la liaison des paramètres, la validation des entrées, la gestion des exceptions via `@ControllerAdvice`, et la sérialisation/désérialisation des corps de requête/réponse.
Ces tests s'inscrivent typiquement entre les tests unitaires purs (qui isolent complètement une classe) et les tests d'intégration complets (qui démarrent toute l'application). Ils offrent un excellent compromis en validant l'intégration des composants de la couche web (contrôleurs, filtres, gestionnaires d'exceptions, convertisseurs) tout en restant rapides car ils n'impliquent pas de communication réseau ni de démarrage de serveur.
Mise en place des tests avec `@WebMvcTest` et MockMvc
La manière la plus courante d'utiliser `MockMvc` est en conjonction avec l'annotation `@WebMvcTest`. Cette annotation de 'slice test' configure un contexte Spring minimal contenant uniquement les beans nécessaires à la couche MVC (contrôleurs, `@ControllerAdvice`, `HttpMessageConverter`, filtres, etc.) et auto-configure une instance de `MockMvc` prête à l'emploi.
Il est fortement recommandé de cibler spécifiquement le contrôleur que vous souhaitez tester en le passant en argument à l'annotation (par exemple, `@WebMvcTest(ProductController.class)`). Cela évite de charger tous les contrôleurs de l'application et rend le test plus ciblé et plus rapide.
Etant donné que `@WebMvcTest` ne charge que la couche web, les dépendances de votre contrôleur (comme les services ou les repositories) ne seront pas présentes dans le contexte. Pour simuler leur comportement, vous devez utiliser l'annotation `@MockBean`. Spring remplacera alors toute définition de bean existante pour ce type par un mock Mockito, que vous pourrez configurer et vérifier dans vos tests.
Voici une structure de test typique :
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
// Import static pour MockMvcRequestBuilders et MockMvcResultMatchers
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.mockito.Mockito.*;
import static org.hamcrest.Matchers.*;
import com.fasterxml.jackson.databind.ObjectMapper; // Pour convertir les objets en JSON
import java.util.Optional;
// Supposons une classe Product et un ProductController
@WebMvcTest(ProductController.class) // Cible le contrôleur spécifique
class ProductControllerTest {
@Autowired
private MockMvc mockMvc; // Instance injectée et configurée par @WebMvcTest
@MockBean // Crée un mock pour le service dont dépend le contrôleur
private ProductService productService;
@Autowired
private ObjectMapper objectMapper; // Utile pour sérialiser/désérialiser le JSON
@Test
void getProductById_shouldReturnProduct_whenProductExists() throws Exception {
// Arrange
Product product = new Product(1L, "Test Product", 99.99);
when(productService.getProductById(1L)).thenReturn(Optional.of(product));
// Act & Assert
mockMvc.perform(get("/api/products/1") // Simule une requête GET
.accept(MediaType.APPLICATION_JSON)) // Définit le header Accept
.andExpect(status().isOk()) // Vérifie le statut HTTP 200 OK
.andExpect(content().contentType(MediaType.APPLICATION_JSON)) // Vérifie le Content-Type
.andExpect(jsonPath("$.id", is(1))) // Vérifie l'ID dans le JSON de réponse
.andExpect(jsonPath("$.name", is("Test Product"))) // Vérifie le nom
.andExpect(jsonPath("$.price", is(99.99))); // Vérifie le prix
verify(productService, times(1)).getProductById(1L); // Vérifie l'appel au service mocké
}
@Test
void getProductById_shouldReturnNotFound_whenProductDoesNotExist() throws Exception {
// Arrange
when(productService.getProductById(99L)).thenReturn(Optional.empty());
// Act & Assert
mockMvc.perform(get("/api/products/99")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound()); // Vérifie le statut HTTP 404 Not Found
verify(productService, times(1)).getProductById(99L);
}
@Test
void createProduct_shouldReturnCreatedProduct_whenInputIsValid() throws Exception {
// Arrange
Product productToCreate = new Product(null, "New Gadget", 123.45);
Product createdProduct = new Product(5L, "New Gadget", 123.45);
when(productService.createProduct(any(Product.class))).thenReturn(createdProduct);
// Act & Assert
mockMvc.perform(post("/api/products") // Simule une requête POST
.contentType(MediaType.APPLICATION_JSON) // Définit le Content-Type de la requête
.content(objectMapper.writeValueAsString(productToCreate))) // Fournit le corps JSON
.andExpect(status().isCreated()) // Vérifie le statut HTTP 201 Created
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(header().string("Location", containsString("/api/products/5"))) // Vérifie l'en-tête Location
.andExpect(jsonPath("$.id", is(5)))
.andExpect(jsonPath("$.name", is("New Gadget")));
verify(productService, times(1)).createProduct(any(Product.class));
}
@Test
void createProduct_shouldReturnBadRequest_whenInputIsInvalid() throws Exception {
// Arrange (Ex: validation échoue car le nom est manquant)
Product invalidProduct = new Product(null, null, 10.0);
// Supposons que le contrôleur ou la validation Bean renvoie 400 Bad Request
// Pas besoin de mocker le service ici car la validation échoue avant
// Act & Assert
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidProduct)))
.andExpect(status().isBadRequest()); // Vérifie le statut HTTP 400 Bad Request
verify(productService, never()).createProduct(any(Product.class)); // Le service ne doit pas être appelé
}
// Autres tests pour PUT, DELETE, gestion d'erreurs spécifiques, etc.
}
Simuler les requêtes et valider les réponses
L'utilisation de `MockMvc` repose sur un enchaînement de méthodes fluides : `perform()` pour exécuter la requête, suivi de `andExpect()` pour les assertions et éventuellement `andDo()` pour des actions supplémentaires (comme imprimer la requête/réponse).
Construire la requête : La classe `MockMvcRequestBuilders` fournit des méthodes statiques pour tous les verbes HTTP (`get()`, `post()`, `put()`, `delete()`, etc.). Ces méthodes permettent de spécifier l'URI (avec support des variables de chemin), les paramètres de requête (`param()`), les en-têtes (`header()`, `accept()`, `contentType()`), et le corps de la requête (`content()`). Pour le corps, vous pouvez fournir une chaîne de caractères (souvent JSON généré via `ObjectMapper`) ou un tableau d'octets.
Valider la réponse : La classe `MockMvcResultMatchers` offre une vaste gamme de méthodes statiques pour vérifier tous les aspects de la réponse :
- Statut HTTP : `status().isOk()`, `isCreated()`, `isNotFound()`, `isBadRequest()`, `isInternalServerError()`, etc.
- En-têtes : `header().exists("Location")`, `header().string("Content-Type", "application/json")`.
- Type de contenu : `contentType(MediaType.APPLICATION_JSON)`.
- Corps de la réponse : Pour le JSON, `jsonPath("$.chemin.vers.attribut", matcher)` est extrêmement puissant (utilise JsonPath et les matchers Hamcrest comme `is()`, `hasSize()`, `containsString()`). Pour les applications MVC traditionnelles, on peut vérifier le nom de la vue (`view().name("nomVue")`) ou les attributs du modèle (`model().attributeExists("nomAttribut")`, `model().attribute("nomAttribut", valeurAttendue)`).
- Contenu brut : `content().string("texte attendu")`, `content().bytes(tableauOctetsAttendu)`.
Vérifier les interactions : Après avoir exécuté la requête et validé la réponse, il est crucial de vérifier que le contrôleur a correctement interagi avec ses dépendances mockées. On utilise pour cela les méthodes de Mockito comme `verify(mockedService, times(1)).methodName(arguments)` ou `verify(mockedService, never()).anotherMethod()`.
En combinant `@WebMvcTest`, `MockMvc`, `@MockBean`, `MockMvcRequestBuilders` et `MockMvcResultMatchers`, vous disposez d'un arsenal complet pour tester efficacement et de manière isolée vos contrôleurs Spring Boot, garantissant ainsi la robustesse de votre couche web.