Slice tests con @WebMvcTest

Avanzado
Spring Boot
Spring Boot
Actualizado: 07/05/2026

Diagrama: tutorial-spring-boot-test-slices-webmvctest

Por qué slice tests

Cuando una suite de tests crece, la diferencia entre tardar segundos o minutos la marcan dos cosas: cuántos tests arrancan el contexto completo y qué tan grande es ese contexto. @SpringBootTest levanta toda la aplicación (controllers, services, repositorios, beans de Kafka, beans de Redis, autoconfiguraciones) aunque el test solo necesite probar un endpoint. Cada vez que un módulo cambia, el coste de arranque se multiplica.

Los slice tests resuelven el problema cargando exclusivamente la capa que se está probando. Spring Boot ofrece varias variantes (@WebMvcTest, @DataJpaTest, @JsonTest, @RestClientTest, @WebFluxTest) y cada una limita el contexto a unos pocos beans. El resultado es una suite que arranca en cientos de milisegundos, no en decenas de segundos.

Un test que tarda 20 ms en correr es un test que se ejecuta en cada commit. Un test que tarda 2 segundos termina ignorado en la rama develop. La velocidad no es un detalle, es lo que decide si tu suite vive o muere.

Qué carga @WebMvcTest

@WebMvcTest autoconfigura solo lo que pertenece al stack web de Spring MVC:

  • Controllers anotados con @Controller o @RestController.
  • Filtros, HandlerInterceptor, HandlerMethodArgumentResolver.
  • @ControllerAdvice y @RestControllerAdvice.
  • Convertidores Jackson (HttpMessageConverter).
  • Configuración de Spring Security (si está en el classpath).

Lo que no carga: servicios anotados con @Service, repositorios @Repository, DataSource, EntityManager, beans de mensajería. Si el controller depende de un servicio, hay que proveerlo como mock.

graph TD
    A[Test con @WebMvcTest] --> B[Spring Web]
    A --> C[Jackson HttpMessageConverters]
    A --> D[Filters y Interceptors]
    A --> E[ControllerAdvice]
    A --> F[Spring Security config]
    A -.NO carga.-> G[Services]
    A -.NO carga.-> H[Repositories]
    A -.NO carga.-> I[DataSource]

Primer test: GET con jsonPath

Supongamos un endpoint sencillo que devuelve un departamento por id:

@RestController
@RequestMapping("/api/v1/departamentos")
@RequiredArgsConstructor
public class DepartamentoController {

    private final DepartamentoService service;

    @GetMapping("/{id}")
    public ResponseEntity<DepartamentoDto> buscarPorId(@PathVariable Long id) {
        return ResponseEntity.ok(service.buscarPorId(id));
    }
}

El test correspondiente con @WebMvcTest queda así:

@WebMvcTest(DepartamentoController.class)
class DepartamentoControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    DepartamentoService service;

    @Test
    void buscarPorId_devuelve200ConBodyJson() throws Exception {
        var dto = new DepartamentoDto(7L, "Ingeniería", "Madrid");
        when(service.buscarPorId(7L)).thenReturn(dto);

        mockMvc.perform(get("/api/v1/departamentos/{id}", 7L)
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(7))
            .andExpect(jsonPath("$.nombre").value("Ingeniería"))
            .andExpect(jsonPath("$.ciudad").value("Madrid"));
    }
}

Tres detalles relevantes:

  • @WebMvcTest(DepartamentoController.class) limita la carga al controller indicado. Si se omite la clase, Spring Boot escanea todos los @Controller del proyecto, lo que aumenta el tiempo de arranque.
  • @MockitoBean (Spring Boot 4) sustituye al antiguo @MockBean, que quedó deprecated. Inyecta un mock de Mockito en el contexto y permite que el controller lo reciba por inyección.
  • jsonPath permite navegar el JSON con expresiones tipo XPath. $.id apunta a la raíz, $.empleados[0].nombre al primer elemento del array.

POST con @Valid y validación

Validar un cuerpo JSON contra un record con anotaciones Bean Validation es uno de los flujos más probados:

public record DepartamentoCrearDto(
    @NotBlank @Size(max = 60) String nombre,
    @NotBlank String ciudad
) {}

@PostMapping
public ResponseEntity<DepartamentoDto> crear(@Valid @RequestBody DepartamentoCrearDto dto) {
    return ResponseEntity.status(CREATED).body(service.crear(dto));
}

Con @WebMvcTest se prueban los dos caminos: cuerpo válido (devuelve 201) y cuerpo inválido (devuelve 400 con mensajes de error):

@Test
void crear_conCuerpoValido_devuelve201() throws Exception {
    var entrada = new DepartamentoCrearDto("Marketing", "Valencia");
    var salida = new DepartamentoDto(11L, "Marketing", "Valencia");
    when(service.crear(entrada)).thenReturn(salida);

    mockMvc.perform(post("/api/v1/departamentos")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                { "nombre": "Marketing", "ciudad": "Valencia" }
                """))
        .andExpect(status().isCreated())
        .andExpect(jsonPath("$.id").value(11));
}

@Test
void crear_conNombreEnBlanco_devuelve400() throws Exception {
    mockMvc.perform(post("/api/v1/departamentos")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                { "nombre": "", "ciudad": "Valencia" }
                """))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errors[?(@.field=='nombre')]").exists());

    verifyNoInteractions(service);
}

verifyNoInteractions(service) confirma que el controller nunca delegó al servicio cuando la validación falló: la respuesta 400 se generó antes de entrar al método de negocio.

Mockear el servicio con @MockitoBean

@MockitoBean es la forma oficial en Spring Boot 4 de meter un mock dentro del ApplicationContext. Sustituye al bean real (si existe) o lo registra si no estaba. Cada test recibe un mock limpio porque Spring Boot resetea los mocks entre tests.

@MockitoBean
DepartamentoService service;

@MockitoBean
AuditoriaService auditoria;

@Test
void crear_invocaServicioYAuditoria() throws Exception {
    when(service.crear(any())).thenReturn(new DepartamentoDto(1L, "RR. HH.", "Bilbao"));

    mockMvc.perform(post("/api/v1/departamentos")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                { "nombre": "RR. HH.", "ciudad": "Bilbao" }
                """))
        .andExpect(status().isCreated());

    verify(service).crear(argThat(dto -> dto.nombre().equals("RR. HH.")));
    verify(auditoria).registrarCreacion(eq("RR. HH."));
}

El antiguo @MockBean sigue funcionando en Spring Boot 4 por compatibilidad, pero está marcado como deprecated. Para proyectos nuevos usa siempre @MockitoBean. Para @SpyBean la sustitución oficial es @MockitoSpyBean.

Probar @RestControllerAdvice

El @RestControllerAdvice se carga automáticamente con @WebMvcTest, así que los tests pueden verificar tanto el camino feliz como el manejo de errores sin configuración adicional.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ApiError> notFound(EntityNotFoundException ex) {
        return ResponseEntity.status(NOT_FOUND)
            .body(new ApiError("entity_not_found", ex.getMessage()));
    }
}

Y el test asegura que la respuesta cumple el contrato:

@Test
void buscarPorId_inexistente_devuelve404Estructurado() throws Exception {
    when(service.buscarPorId(99L))
        .thenThrow(new EntityNotFoundException("Departamento 99 no existe"));

    mockMvc.perform(get("/api/v1/departamentos/{id}", 99L))
        .andExpect(status().isNotFound())
        .andExpect(jsonPath("$.code").value("entity_not_found"))
        .andExpect(jsonPath("$.message").value("Departamento 99 no existe"));
}

El test no necesita levantar la BD ni la capa de servicio: con un thenThrow simulamos el error y comprobamos que el advice responde como espera el front.

Seguridad en slice tests

Si Spring Security está en el classpath, @WebMvcTest también carga la cadena de filtros. Hay tres caminos para gestionarla en tests.

Opción 1: deshabilitar filtros (no recomendado)

@WebMvcTest(controllers = DepartamentoController.class)
@AutoConfigureMockMvc(addFilters = false)
class DepartamentoControllerSinSeguridadTest { ... }

addFilters = false desactiva todos los filtros, incluido el de seguridad. Útil para casos donde solo se prueba el handler, pero pierde cobertura: si se rompe la configuración de seguridad, estos tests no lo detectan.

Opción 2: @WithMockUser

@Test
@WithMockUser(roles = "ADMIN")
void crear_comoAdmin_devuelve201() throws Exception {
    mockMvc.perform(post("/api/v1/departamentos")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                { "nombre": "Soporte", "ciudad": "Sevilla" }
                """)
            .with(csrf()))
        .andExpect(status().isCreated());
}

@Test
@WithMockUser(roles = "EMPLEADO")
void crear_comoEmpleado_devuelve403() throws Exception {
    mockMvc.perform(post("/api/v1/departamentos")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                { "nombre": "Soporte", "ciudad": "Sevilla" }
                """)
            .with(csrf()))
        .andExpect(status().isForbidden());
}

Esta forma cubre tanto el caso autorizado como el no autorizado y mantiene la cadena de filtros activa. with(csrf()) añade el token CSRF si la configuración lo exige.

Opción 3: jwt() request post-processor

Si la API protege endpoints con JWT, MockMvc puede simular la autenticación sin firmar tokens reales:

@Test
void buscarPorId_conJwtValido_devuelve200() throws Exception {
    when(service.buscarPorId(7L)).thenReturn(new DepartamentoDto(7L, "QA", "Zaragoza"));

    mockMvc.perform(get("/api/v1/departamentos/{id}", 7L)
            .with(jwt().jwt(jwt -> jwt.subject("alan@certidevs.com"))
                       .authorities(new SimpleGrantedAuthority("SCOPE_read"))))
        .andExpect(status().isOk());
}

Velocidad comparada

La diferencia entre @SpringBootTest y @WebMvcTest se nota desde el primer arranque:

@SpringBootTest
   - Bootstrap del contexto: 2.8 s
   - Por test añadido: ~5 ms
   - Memoria pico: 480 MB

@WebMvcTest(DepartamentoController.class)
   - Bootstrap del contexto: 240 ms
   - Por test añadido: ~3 ms
   - Memoria pico: 110 MB

En una suite con 200 tests de capa web, la diferencia acumulada ronda los 30-40 segundos. Multiplicado por todas las builds del día, son horas de máquina al mes.

Cuándo usar cada uno

| Escenario | Slice recomendado | |-----------|-------------------| | Probar status code, headers, contrato JSON, validación | @WebMvcTest | | Probar ControllerAdvice y mapeo de excepciones | @WebMvcTest | | Probar repository con queries reales | @DataJpaTest con Testcontainers | | Probar serialización Jackson aislada | @JsonTest | | Probar flujo completo HTTP + BD + Kafka | @SpringBootTest | | Probar startup time, autoconfiguraciones, profiles | @SpringBootTest |

La regla práctica es: si el test puede aprobarse sin BD ni red, usa un slice. Reserva @SpringBootTest para los pocos tests de integración real que confirman que las piezas encajan.

Buenas prácticas

  • Indica siempre la clase del controller en @WebMvcTest(MiController.class). Cargar todos los controllers es tres veces más lento.
  • Usa @MockitoBean para servicios y @MockitoSpyBean para colaboradores que quieras observar parcialmente.
  • Verifica el contrato con jsonPath, no con strings completos. Un cambio en el orden de campos no debería romper tests.
  • Mantén los tests de seguridad como primera línea: @WithMockUser, with(jwt()) o with(httpBasic()). No deshabilites filtros salvo en casos puntuales documentados.
  • Empareja cada handler con al menos dos tests: camino feliz y al menos un camino de error (validación, autorización, recurso inexistente).
  • Considera MockMvcResultMatchers.jsonPath con cláusulas $..[?(@.field=='x')] para verificar listas de errores de validación.
  • Si el controller depende de muchos servicios, plantéate refactorizar antes que mockear ocho beans en un test.

@WebMvcTest es la herramienta correcta para validar la capa web aislada. Combinada con @MockitoBean y MockMvc permite una suite rápida y específica que detecta cambios en el contrato HTTP antes de que lleguen a producción. La integración real déjala para los pocos tests con @SpringBootTest que de verdad necesiten levantar todo.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Spring Boot

Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Aplicar @WebMvcTest para cargar exclusivamente la capa web. Mockear dependencias de servicio con @MockitoBean. Verificar respuestas HTTP con MockMvc y jsonPath. Probar validación con @Valid y respuestas de @RestControllerAdvice. Decidir cuándo usar @WebMvcTest frente a @SpringBootTest.