
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
@Controllero@RestController. - Filtros,
HandlerInterceptor,HandlerMethodArgumentResolver. @ControllerAdvicey@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@Controllerdel 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.jsonPathpermite navegar el JSON con expresiones tipo XPath.$.idapunta a la raíz,$.empleados[0].nombreal 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
@MockBeansigue funcionando en Spring Boot 4 por compatibilidad, pero está marcado como deprecated. Para proyectos nuevos usa siempre@MockitoBean. Para@SpyBeanla 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
@SpringBootTestpara 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
@MockitoBeanpara servicios y@MockitoSpyBeanpara 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())owith(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.jsonPathcon 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.
@WebMvcTestes la herramienta correcta para validar la capa web aislada. Combinada con@MockitoBeanyMockMvcpermite 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@SpringBootTestque de verdad necesiten levantar todo.
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.