Tester un contrôleur avec @WebMvcTest et MockMvc

1. Vue d’ensemble

Tester un contrôleur avec @SpringBootTest charge l’application entière, y compris la base de données, le message broker et tous les autres beans. C’est lent et difficile d’isoler les erreurs. Spring Boot fournit @WebMvcTest pour charger uniquement la couche web. Associé à MockMvc, vous pouvez envoyer des requêtes simulées sans démarrer un serveur réel.

Après avoir lu cet article, vous saurez comment tester un contrôleur REST avec @WebMvcTest. Plus précisément, vérifier le statut, les en-têtes et le JSON renvoyé, tester la validation, tester les gestionnaires d’exceptions et éviter les erreurs courantes.

2. Dépendances

Vous avez besoin de spring-boot-starter-web pour le contrôleur, spring-boot-starter-validation pour Bean Validation, et spring-boot-starter-test pour la partie test :

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-test inclut déjà JUnit 5, AssertJ, Mockito, JsonPath et Spring Test. Vous n’avez rien d’autre à ajouter.

3. Contrôleur à tester

Nous allons utiliser une simple API de gestion des commandes. D’abord le modèle et la requête :

public record Order(Long id, String customerName, BigDecimal total) {}

public record CreateOrderRequest(
        @NotBlank String customerName,
        @NotNull @Positive BigDecimal total) {}

La couche service est une interface. Dans le test, nous allons la mocker :

public interface OrderService {
    Order findById(Long id);
    Order create(CreateOrderRequest request);
}

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("Không tìm thấy đơn hàng " + id);
    }
}

Le contrôleur a deux points de terminaison :

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderService.findById(id);
    }

    @PostMapping
    public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        Order created = orderService.create(request);
        return ResponseEntity
                .created(URI.create("/api/orders/" + created.id()))
                .body(created);
    }
}

Enfin, un @RestControllerAdvice pour convertir OrderNotFoundException en HTTP 404 :

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleNotFound(OrderNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
}

4. Premier test avec @WebMvcTest

4.1. Structure de test

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private OrderService orderService;
}

Voici ce que fait chaque partie :

  • @WebMvcTest(OrderController.class) crée un contexte Spring contenant uniquement les beans liés à MVC. Il s’agit de OrderController, @ControllerAdvice, Converter, Filter, WebMvcConfigurer et Jackson. Les @Service, @Repository ou @Component ordinaires ne sont pas chargés.
  • MockMvc est auto-configuré, vous n’avez qu’à l’injecter.
  • ObjectMapper est le bean Jackson configuré par Spring Boot. Utilisez-le pour sérialiser la requête afin que le test corresponde à la configuration réelle.
  • @MockitoBean crée un mock Mockito pour OrderService et l’enregistre dans le contexte. Cela permet à OrderController d’avoir la dépendance pour être instancié.

Note : À partir de Spring Boot 3.4, @MockBean est marqué comme dépréciée. Vous devriez utiliser @MockitoBean du package org.springframework.test.context.bean.override.mockito. Si vous êtes sur Spring Boot 3.3 ou antérieur, utilisez @MockBean. Le fonctionnement est similaire.

4.2. Test GET retournant JSON

@Test
void shouldReturnOrderWhenFound() throws Exception {
    given(orderService.findById(1L))
            .willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));

    mockMvc.perform(get("/api/orders/{id}", 1L))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.customerName").value("Nguyen Van A"))
            .andExpect(jsonPath("$.total").value(250000));
}

Les imports statiques nécessaires :

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

Explication étape par étape :

  • given(...).willReturn(...) spécifie que le mock retourne un Order fixe quand il est appelé avec l’id 1.
  • perform(get(...)) envoie la requête GET /api/orders/1 via DispatcherServlet. La requête ne passe pas par le réseau.
  • andDo(print()) affiche la requête et la réponse complètes sur la console. Cette étape est très utile quand le test échoue.
  • jsonPath("$.customerName") interroge le champ dans le corps JSON.

La partie output de print() aura cette forme :

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"id":1,"customerName":"Nguyen Van A","total":250000}

5. Test POST avec corps de requête

5.1. Cas de succès

@Test
void shouldCreateOrder() throws Exception {
    var request = new CreateOrderRequest("Tran Thi B", new BigDecimal("99000"));
    given(orderService.create(request))
            .willReturn(new Order(10L, "Tran Thi B", new BigDecimal("99000")));

    mockMvc.perform(post("/api/orders")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", "/api/orders/10"))
            .andExpect(jsonPath("$.id").value(10));
}

Il y a deux points importants à noter ici :

  • contentType(MediaType.APPLICATION_JSON) est obligatoire. S’il est absent, Spring retourne 415 Unsupported Media Type.
  • given(orderService.create(request)) fonctionne parce que record génère automatiquement equals(). La requête désérialisée par le contrôleur sera égale à l’objet request du test. Si vous utilisez une classe ordinaire sans redéfinir equals(), utilisez plutôt any(CreateOrderRequest.class).

5.2. Test validation

@WebMvcTest charge la configuration de validation. Par conséquent, @Valid fonctionne comme dans la vraie application :

@Test
void shouldReturnBadRequestWhenCustomerNameIsBlank() throws Exception {
    String invalidJson = """
            {"customerName": "", "total": -5}
            """;

    mockMvc.perform(post("/api/orders")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(invalidJson))
            .andExpect(status().isBadRequest());

    verify(orderService, never()).create(any());
}

La ligne verify(orderService, never()).create(any()) garantit que le service n’est pas appelé quand les données ne sont pas valides. C’est le comportement que vous voulez protéger : la validation bloque la requête au niveau de la couche web.

Ce test utilise un bloc de texte JSON au lieu de ObjectMapper. Ce style vous permet d’envoyer même des payloads que record Java ne peut pas représenter, par exemple un JSON avec un mauvais type de données.

6. Test du gestionnaire d’exceptions

Parce que @WebMvcTest charge automatiquement les @RestControllerAdvice, vous pouvez aussi tester le mappage de l’exception vers le statut HTTP :

@Test
void shouldReturnNotFoundWhenOrderDoesNotExist() throws Exception {
    given(orderService.findById(99L)).willThrow(new OrderNotFoundException(99L));

    mockMvc.perform(get("/api/orders/{id}", 99L))
            .andExpect(status().isNotFound())
            .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.detail").value("Không tìm thấy đơn hàng 99"));
}

Le corps de la réponse suit la norme RFC 9457 (Problem Details) :

{"type":"about:blank","title":"Not Found","status":404,"detail":"Không tìm thấy đơn hàng 99","instance":"/api/orders/99"}

C’est la force de @WebMvcTest par rapport à un unit test pur qui appellerait directement orderController.getOrder(99L). Un unit test pur voit seulement l’exception levée. Il ne peut pas vérifier ce que le client reçoit réellement en termes de statut et de corps.

7. Écrire des assertions en style AssertJ avec MockMvcTester

À partir de Spring Boot 3.4 (Spring Framework 6.2), si AssertJ est sur le classpath, Spring Boot auto-configure également MockMvcTester. Ce bean vous permet d’écrire des assertions en AssertJ au lieu d’une chaîne d’andExpect :

@WebMvcTest(OrderController.class)
class OrderControllerAssertJTest {

    @Autowired
    private MockMvcTester mvc;

    @MockitoBean
    private OrderService orderService;

    @Test
    void shouldReturnOrderWhenFound() {
        given(orderService.findById(1L))
                .willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));

        assertThat(mvc.get().uri("/api/orders/{id}", 1L))
                .hasStatusOk()
                .hasContentType(MediaType.APPLICATION_JSON)
                .bodyJson()
                .extractingPath("$.customerName")
                .isEqualTo("Nguyen Van A");
    }
}

Par rapport à MockMvc, ce style d’écriture a trois avantages :

  • Pas besoin de throws Exception.
  • Pas besoin de mémoriser de nombreux imports statiques de MockMvcResultMatchers.
  • Les messages d’erreur d’AssertJ sont généralement plus faciles à lire.

Les deux API sont équivalentes en termes de capacités. Choisissez-en une et utilisez-la de manière cohérente dans le projet.

8. Limiter ce qui est chargé

8.1. Spécifier le contrôleur

Si vous écrivez @WebMvcTest sans paramètre, Spring charge tous les contrôleurs de l’application. Vous devez alors mocker la dépendance de chaque contrôleur, y compris ceux non liés au test. Par conséquent, spécifiez toujours le contrôleur à tester :

@WebMvcTest(OrderController.class)

8.2. Charger des beans spécifiques supplémentaires

Parfois, un contrôleur utilise un petit bean sans effets secondaires, par exemple un OrderMapper. Mocker ce bean n’apporte aucune valeur. Vous pouvez charger le vrai bean avec @Import :

@WebMvcTest(OrderController.class)
@Import(OrderMapper.class)
class OrderControllerTest {
    // ...
}

8.3. Avec Spring Security

Si spring-boot-starter-security est sur le classpath, @WebMvcTest charge également la chaîne de filtres de sécurité. Les tests ci-dessus retourneront 401 ou 403. Vous devez ajouter la dépendance suivante :

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

Ensuite, simulez un utilisateur et incluez un token CSRF pour les requêtes qui modifient les données :

@Test
@WithMockUser(roles = "USER")
void shouldCreateOrderWhenAuthenticated() throws Exception {
    // ... stub orderService comme section 5.1
    mockMvc.perform(post("/api/orders")
                    .with(csrf())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("""
                            {"customerName": "Le Van C", "total": 120000}
                            """))
            .andExpect(status().isCreated());
}

Note : La classe SecurityFilterChain que vous définissez vous-même (marquée @Configuration) n’est pas chargée automatiquement dans toutes les versions. Si vous voulez tester les règles d’autorisation correctes, ajoutez @Import(SecurityConfig.class).

9. Erreurs courantes

NoSuchBeanDefinitionException lors du démarrage du test. Le contrôleur dépend d’un bean qui n’appartient pas à la couche web et que vous n’avez pas mocké. Solution : ajoutez @MockitoBean pour ce bean, ou @Import le.

Les annotations sur la classe @SpringBootApplication cassent le test. Par exemple, vous mettez @EnableJpaRepositories ou @EnableScheduling directement sur la classe principale. @WebMvcTest lit toujours cette classe, donc elle essaiera de créer un repository et échouera faute de DataSource. Solution : séparez ces annotations dans une classe @Configuration distincte.

Oublier contentType. Le résultat est 415 au lieu du statut attendu.

Vérifier les résultats avec MockMvc mais la logique se trouve dans le service. @WebMvcTest ne doit vérifier que ce qui relève de la couche web :

  • routage,
  • sérialisation,
  • validation,
  • code de statut,
  • mappage des exceptions.

Testez la logique métier séparément au niveau du service avec un unit test pur.

Trop de mocks. Si un test doit stubber cinq ou six méthodes, le contrôleur fait peut-être trop de choses. C’est un signal pour refactoriser, pas pour écrire plus de stubs.

Note : N’utilisez pas @WebMvcTest si vous voulez tester un flux end-to-end via la base de données, ou si vous avez besoin de tester le comportement du serveur réel (port, HTTP/2, filtres du conteneur). Dans ce cas, utilisez @SpringBootTest(webEnvironment = RANDOM_PORT) avec TestRestTemplate ou WebTestClient. Si l’application utilise WebFlux, la slice correspondante est @WebFluxTest, pas @WebMvcTest.

10. Conclusion

@WebMvcTest vous aide à tester un contrôleur rapidement et efficacement. Il charge uniquement la couche web, les dépendances en arrière-plan étant remplacées par @MockitoBean. Combiné avec MockMvc ou MockMvcTester, vous pouvez vérifier les comportements que le client voit réellement : code de statut, en-têtes, JSON, validation et mappage des exceptions.

Les règles principales à retenir :

  • Spécifiez toujours le contrôleur dans @WebMvcTest.
  • Mocker exactement ce dont le contrôleur a besoin.
  • N’oubliez pas contentType quand vous envoyez un corps.
  • Gardez la classe @SpringBootApplication propre afin que les test slices ne traînent pas la configuration inutile.
spring-boottestingmockmvcjunit5webmvctest