Escribir tests para controllers con @WebMvcTest y MockMvc

1. Descripción general

Probar un controller con @SpringBootTest cargará la aplicación completa, incluyendo la base de datos, message broker y todos los demás beans. Este enfoque es lento y dificulta el aislamiento de errores. Spring Boot proporciona @WebMvcTest para cargar solo la capa web. Cuando se combina con MockMvc, puedes enviar solicitudes simuladas sin necesidad de iniciar un servidor real.

Después de leer este artículo, sabrás cómo probar un REST controller con @WebMvcTest. Específicamente, verificarás el estado, encabezados y JSON devueltos, probarás validación, probarás manejadores de excepciones y evitarás errores comunes.

2. Dependencias

Necesitas spring-boot-starter-web para el controller, spring-boot-starter-validation para Bean Validation, y spring-boot-starter-test para las pruebas:

<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 ya incluye JUnit 5, AssertJ, Mockito, JsonPath y Spring Test. No necesitas añadir nada más.

3. Controller a probar

Usaremos una API simple de gestión de pedidos. Primero, el modelo y la solicitud:

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

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

La capa de servicio es una interfaz. En las pruebas, la mockearemos:

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

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("No se encontró el pedido " + id);
    }
}

El controller tiene dos endpoints:

@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);
    }
}

Finalmente, un @RestControllerAdvice para 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. Primera prueba con @WebMvcTest

4.1. Estructura base

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private OrderService orderService;
}

Cada parte funciona así:

  • @WebMvcTest(OrderController.class) crea un contexto de Spring que contiene solo los beans relacionados con MVC. Eso incluye OrderController, cualquier @ControllerAdvice, Converter, Filter, WebMvcConfigurer y Jackson. Los @Service, @Repository u otros @Component normales no se cargan.
  • MockMvc se autoconfigura, solo necesitas inyectarlo.
  • ObjectMapper es el bean de Jackson que Spring Boot configura. Úsalo para serializar solicitudes y que coincida con la configuración real.
  • @MockitoBean crea un mock de Mockito para OrderService y lo registra en el contexto. De esta forma, OrderController tiene la dependencia que necesita.

Nota: Desde Spring Boot 3.4, @MockBean está marcado como deprecated. Deberías usar @MockitoBean del paquete org.springframework.test.context.bean.override.mockito. Si estás en Spring Boot 3.3 o anterior, usa @MockBean. El funcionamiento es similar.

4.2. Probar GET que devuelve 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));
}

Los imports estáticos necesarios:

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.*;

Explicación paso a paso:

  • given(...).willReturn(...) configura el mock para devolver un Order fijo cuando se invoque con id 1.
  • perform(get(...)) envía una solicitud GET /api/orders/1 a través del DispatcherServlet. La solicitud no pasa por la red.
  • andDo(print()) imprime la solicitud y respuesta completas en la consola. Este paso es muy útil cuando una prueba falla.
  • jsonPath("$.customerName") consulta un campo en el cuerpo JSON.

La salida de print() tendrá este aspecto:

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. Probar POST con cuerpo de solicitud

5.1. Caso exitoso

@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));
}

Hay dos puntos importantes a tener en cuenta:

  • contentType(MediaType.APPLICATION_JSON) es obligatorio. Si lo olvidas, Spring devuelve 415 Unsupported Media Type.
  • given(orderService.create(request)) funciona porque record genera automáticamente equals(). La solicitud que el controller deserializa será igual al objeto request de la prueba. Si usas una clase normal sin override de equals(), usa any(CreateOrderRequest.class) en su lugar.

5.2. Probar validación

@WebMvcTest carga la configuración de validación. Por lo tanto, @Valid funciona igual que en la ejecución real:

@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 línea verify(orderService, never()).create(any()) asegura que el servicio no se invoque cuando los datos no son válidos. Este es exactamente el comportamiento que quieres proteger: la validación bloquea la solicitud en la capa web.

Esta prueba usa un bloque de texto JSON en lugar de ObjectMapper. Este estilo te permite enviar incluso payloads que Java record no puede representar, como JSON con tipos de datos incorrectos.

6. Probar manejador de excepciones

Como @WebMvcTest carga automáticamente los @RestControllerAdvice, puedes probar directamente la asignación de excepciones a estados 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("No se encontró el pedido 99"));
}

El cuerpo de la respuesta sigue el estándar RFC 9457 (Problem Details):

{"type":"about:blank","title":"Not Found","status":404,"detail":"No se encontró el pedido 99","instance":"/api/orders/99"}

Este es el punto fuerte de @WebMvcTest comparado con las pruebas unitarias puras que llaman directamente a orderController.getOrder(99L). Las pruebas unitarias puras solo ven la excepción lanzada. No pueden verificar qué estado y cuerpo recibe realmente el cliente.

7. Escribir assertions al estilo AssertJ con MockMvcTester

Desde Spring Boot 3.4 (Spring Framework 6.2), si AssertJ está en el classpath, Spring Boot autoconfigura un MockMvcTester adicional. Este bean te permite escribir assertions con AssertJ en lugar de una cadena de 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");
    }
}

Comparado con MockMvc, este estilo de escritura tiene tres ventajas:

  • No necesitas throws Exception.
  • No tienes que recordar muchos imports estáticos de MockMvcResultMatchers.
  • Los mensajes de error de AssertJ suelen ser más fáciles de leer.

Ambas APIs son equivalentes en capacidades. Elige una y úsala consistentemente en tu proyecto.

8. Limitar lo que se carga

8.1. Especificar el controller

Si escribes @WebMvcTest sin parámetros, Spring carga todos los controllers de la aplicación. Entonces debes mockear la dependencia de cada controller, incluso los no relacionados con la prueba. Por lo tanto, siempre especifica el controller a probar:

@WebMvcTest(OrderController.class)

8.2. Cargar beans específicos

A veces un controller usa un bean pequeño sin efectos secundarios, como un OrderMapper. Mockear este bean no aporta valor. Puedes cargar el bean real con @Import:

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

8.3. Cuando hay Spring Security

Si spring-boot-starter-security está en el classpath, @WebMvcTest también cargará la cadena de filtros de seguridad. Las pruebas anteriores devolverían 401 o 403. Necesitas añadir esta dependencia:

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

Luego simula un usuario y envía el token CSRF para solicitudes que modifican datos:

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

Nota: La clase SecurityFilterChain que defines tú mismo (anotada con @Configuration) no se carga automáticamente en todas las versiones. Si quieres probar tu propia regla de autorización, añade @Import(SecurityConfig.class).

9. Errores comunes

NoSuchBeanDefinitionException al iniciar la prueba. El controller depende de un bean que no es de la capa web y que aún no has mockeado. Solución: añade @MockitoBean para ese bean, o @Import para cargarlo.

Las anotaciones en la clase @SpringBootApplication rompen la prueba. Por ejemplo, pones @EnableJpaRepositories o @EnableScheduling directamente en la clase main. @WebMvcTest sigue leyendo esta clase, entonces intentará crear repositories y falla porque falta DataSource. Solución: traslada estas anotaciones a una clase @Configuration aparte.

Olvidar contentType. El resultado es 415 en lugar del estado esperado.

Verificar el resultado con MockMvc pero la lógica está en el servicio. @WebMvcTest solo debe verificar la parte del trabajo de la capa web:

  • routing,
  • serialización,
  • validación,
  • código de estado,
  • asignación de excepciones.

La lógica de negocio pruébala por separado en el servicio con una prueba unitaria pura.

Exceso de mocks. Si una prueba debe stub cinco o seis métodos, probablemente el controller está haciendo demasiado trabajo. Esa es una señal para refactorizar, no para escribir más stubs.

Nota: No debes usar @WebMvcTest si quieres verificar un flujo end-to-end a través de la base de datos, o si necesitas probar el comportamiento del servidor real (puerto, HTTP/2, filtro del contenedor). En ese caso, usa @SpringBootTest(webEnvironment = RANDOM_PORT) junto con TestRestTemplate o WebTestClient. Si tu aplicación usa WebFlux, la slice correspondiente es @WebFluxTest, no @WebMvcTest.

10. Conclusión

@WebMvcTest te ayuda a probar controllers rápidamente y de forma enfocada. Carga solo la capa web, mientras que las dependencias se reemplazan con @MockitoBean. Combinado con MockMvc o MockMvcTester, puedes verificar los comportamientos que el cliente realmente ve: códigos de estado, encabezados, JSON, validación y asignación de excepciones.

Las reglas principales a recordar:

  • Siempre especifica el controller en @WebMvcTest.
  • Mockea exactamente lo que el controller necesita.
  • No olvides contentType cuando envíes cuerpo.
  • Mantén la clase @SpringBootApplication limpia para que los test slices no carguen configuración innecesaria.
spring-boottestingmockmvcjunit5webmvctest