Spring Boot y Spring Cloud
Testing en Spring Boot 3: slices, MockMvc, Testcontainers y caché de contextos
Intermedio8 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Aprenderás a probar un servicio Spring Boot 3 al nivel adecuado para cada aspecto: tests unitarios simples para la lógica, @WebMvcTest con MockMvc para los controladores, @DataJpaTest contra una base de datos real en Testcontainers para los repositorios, @SpringBootTest para las comprobaciones de extremo a extremo, y las reglas de caché de contextos y de sustitución de beans que mantienen toda la suite rápida y fiable.
Lo que aprenderás
- Elegir entre un test unitario, un slice de test y un @SpringBootTest completo para un fragmento de código concreto
- Probar controladores, validación y respuestas de error con @WebMvcTest y MockMvc
- Ejecutar los tests de repositorio contra una base de datos real con @DataJpaTest y @ServiceConnection de Testcontainers
- Sustituir colaboradores con @MockitoBean y entender cómo afectan las sustituciones de beans a la caché de contextos
- Escribir tests de extremo a extremo con @SpringBootTest, un puerto aleatorio y un cliente HTTP real
- Mantener rápida una suite de tests que crece controlando el número de contextos de aplicación distintos
Un servicio Spring Boot tiene varios tipos de código, cada uno con sus propias formas de fallar: reglas de negocio que pueden estar mal, controladores que pueden mapear mal el JSON o los códigos de estado, consultas que pueden no ser válidas en la base de datos real y una configuración que puede inyectar el bean equivocado. Un único tipo de test no sirve para todo eso. Arrancar la aplicación completa para cada comprobación hace que la suite sea lenta y los fallos, difusos; mockearlo todo la hace rápida e inútil. Esta página plantea un enfoque por capas sobre Spring Boot 3 con JUnit 5 y las herramientas que incorpora spring-boot-starter-test, y explica la mecánica de la caché de contextos, que decide si una suite de quinientos tests tarda veinte segundos o diez minutos.
Qué te da el starter de test
spring-boot-starter-test incluye JUnit Jupiter, AssertJ, Mockito, Hamcrest, JSONassert, JsonPath, Awaitility y el soporte de test del propio Spring (spring-test, spring-boot-test, spring-boot-test-autoconfigure). El starter ya no incluye el motor vintage de JUnit 4 desde Boot 2.4; los tests usan @Test de org.junit.jupiter.api, y el antiguo @RunWith(SpringRunner.class) queda sustituido por la extensión que @SpringBootTest y los slices ya declaran.
Los niveles, del más rápido y acotado al más lento y amplio:
| Nivel | Anotación | Arranca | Úsalo para |
|---|---|---|---|
| Unitario | ninguna | nada | Lógica de dominio, mappers, cálculos |
| Slice web | @WebMvcTest | infraestructura MVC + controladores seleccionados | Mapeo de peticiones, JSON, validación, respuestas de error, reglas de seguridad |
| Slice de datos | @DataJpaTest | JPA, repositorios, una base de datos | Consultas, mapeos, migraciones |
| Otros slices | @JsonTest, @RestClientTest, @DataJdbcTest, @WebFluxTest… | un aspecto cada uno | Serialización, clientes HTTP |
| Contexto completo | @SpringBootTest | todo, opcionalmente un servidor real | Cableado, flujos de extremo a extremo, configuración |
La regla práctica: lleva cada comprobación al nivel más bajo que pueda demostrarla. Una regla de redondeo no necesita Spring; un código de estado no necesita base de datos; una consulta JPQL sí necesita base de datos, pero no Tomcat.
Tests unitarios: sin contenedor
Como las aplicaciones Boot usan inyección por constructor, la mayoría de las clases se pueden instanciar directamente:
class PricingServiceTest {
private final TaxRates rates = mock(TaxRates.class);
private final PricingService pricing = new PricingService(rates);
@Test
void appliesReducedRateToBooks() {
when(rates.rateFor(Category.BOOKS)).thenReturn(new BigDecimal("0.055"));
Money total = pricing.priceWithTax(new Item("ISBN-1", Category.BOOKS, Money.of("20.00")));
assertThat(total).isEqualTo(Money.of("21.10"));
verifyNoMoreInteractions(rates);
}
}
Aquí no hay nada específico de Spring, y precisamente de eso se trata. Si una clase es difícil de construir en un test, la causa suele ser la inyección por campo o una dependencia del ApplicationContext; corrige el diseño en lugar de recurrir a @SpringBootTest. El @ExtendWith(MockitoExtension.class) de Mockito, con @Mock e @InjectMocks, elimina el código repetitivo cuando hay varios colaboradores.
Controladores con @WebMvcTest
@WebMvcTest(OrderController.class) construye un contexto que contiene solo la capa web y el controlador indicado, autoconfigura MockMvc y espera que tú proporciones las dependencias del controlador:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService orders;
@Test
void returnsOrderAsJson() throws Exception {
when(orders.get(42L)).thenReturn(sampleOrder(42L));
mvc.perform(get("/api/orders/42").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.lines", hasSize(2)));
}
@Test
void rejectsInvalidBodyWithProblemDetail() throws Exception {
mvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerId": null, "lines": []}
"""))
.andExpect(status().isBadRequest())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.errors[*].field", containsInAnyOrder("customerId", "lines")));
verifyNoInteractions(orders);
}
@Test
void mapsDomainExceptionTo404() throws Exception {
when(orders.get(7L)).thenThrow(new OrderNotFoundException(7L));
mvc.perform(get("/api/orders/7"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Order not found"));
}
}
MockMvc maneja el DispatcherServlet directamente, sin socket, así que el test ejercita la resolución de argumentos, la validación, la conversión de mensajes, @RestControllerAdvice y los filtros, justo lo que se les escaparía a los tests unitarios de la clase controlador. Desde Boot 3.4 (Spring Framework 6.2), MockMvcTester ofrece lo mismo con estilo AssertJ: assertThat(mvc.get().uri("/api/orders/42")).hasStatusOk().bodyJson().extractingPath("$.id").isEqualTo(42).
Con Spring Security en el classpath, el slice incluye la configuración de seguridad, de modo que las peticiones no autenticadas reciben un 401 a menos que añadas @WithMockUser de spring-security-test o adjuntes un post-processor jwt(). Es una ventaja, porque te permite probar las reglas de autorización endpoint por endpoint sin un servidor de tokens.
Repositorios con @DataJpaTest y Testcontainers
@DataJpaTest configura JPA, los repositorios de Spring Data y un TestEntityManager, y hace que cada test sea transaccional con rollback al final, de modo que ningún test ve los datos de otro. Por defecto sustituye el DataSource por una base de datos embebida, algo cómodo pero engañoso; apúntalo mejor al motor con el que despliegas:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired OrderRepository orders;
@Autowired TestEntityManager em;
@Test
void findWithLinesFetchesLinesInOneQuery() {
Order order = em.persistFlushFind(orderWithLines(3));
em.clear(); // detach so the next load hits the database
Optional<Order> loaded = orders.findWithLines(order.getId());
assertThat(loaded).isPresent();
assertThat(loaded.get().getLines()).hasSize(3); // no LazyInitializationException: fetch join worked
}
@Test
void expireStaleOrdersOnlyTouchesNewOrders() {
em.persist(order(OrderStatus.NEW, daysAgo(10)));
em.persist(order(OrderStatus.SHIPPED, daysAgo(10)));
em.flush();
int updated = orders.expireStaleOrders(OrderStatus.EXPIRED, Instant.now().minus(7, DAYS));
assertThat(updated).isEqualTo(1);
}
}
@ServiceConnection (Boot 3.1+) lee el host, el puerto y las credenciales del contenedor y configura el DataSource a partir de ellos, en lugar del antiguo método @DynamicPropertySource que fijaba spring.datasource.url a mano. Flyway se ejecuta contra el contenedor igual que en producción, así que el test también valida las migraciones. Un contenedor static se arranca una vez por clase de test; para compartir uno en toda la suite, decláralo en una clase @TestConfiguration que importen todos los tests, o usa la función de reutilización de Testcontainers.
Como el slice hace rollback después de cada test, el código que depende de lo que ocurre al hacer commit (@TransactionalEventListener, asignación por SEQUENCE, triggers de la base de datos) no se disparará. Anota un test concreto con @Transactional(propagation = NOT_SUPPORTED) o @Commit cuando sea precisamente ese comportamiento lo que estás probando, y limpia de forma explícita.
De extremo a extremo con @SpringBootTest
Cuando la pregunta es «¿funciona todo junto?», arranca todo:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderFlowTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired TestRestTemplate http; // pre-configured with the random port
@MockitoBean PaymentGateway gateway; // the one dependency that must not be real
@Test
void placingAnOrderReturns201AndPersistsIt() {
when(gateway.charge(any())).thenReturn(Payment.approved("txn-1"));
ResponseEntity<OrderResponse> response = http.postForEntity("/api/orders",
new CreateOrderRequest(1L, List.of(new OrderLine("ABC-12345", 2))), OrderResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getHeaders().getLocation()).hasPath("/api/orders/" + response.getBody().id());
ResponseEntity<OrderResponse> fetched = http.getForEntity(response.getHeaders().getLocation(), OrderResponse.class);
assertThat(fetched.getBody().status()).isEqualTo("PAID");
}
}
RANDOM_PORT arranca el servidor embebido en un puerto libre y deja disponible TestRestTemplate (o WebTestClient, si WebFlux está en el classpath) con la URL base ya configurada. El entorno por defecto, MOCK, arranca el contexto sin servidor y te permite usar MockMvc con @AutoConfigureMockMvc, que es más rápido y suficiente cuando la capa de red no es lo que se está probando.
@SpringBootTest encuentra la clase @SpringBootConfiguration subiendo por el árbol de paquetes desde el test, y por eso la clase de la aplicación está en el paquete raíz. Las propiedades del test van en properties = "..." sobre la anotación, en un src/test/resources/application-test.yml activado con @ActiveProfiles("test"), o en @TestPropertySource; las dos primeras opciones son las que menos sorpresas dan.
Mantén pocos tests de este tipo y con un alcance amplio. Uno que cubra un flujo de usuario realista vale más que diez que repitan lo que ya verifican los slices.
Sustituir beans y lo que cuesta
@MockitoBean sustituye (o añade) un bean en el contexto por un mock de Mockito que se reinicia tras cada método de test; @MockitoSpyBean envuelve el bean real. Ambos vienen de Spring Framework 6.2 y reemplazan a @MockBean/@SpyBean de Boot, obsoletos desde la 3.4. Para algo más elaborado que un mock, una clase @TestConfiguration con métodos @Bean, importada con @Import o anidada como estática en el test, aporta un fake escrito a mano y suele ser más clara que hacer stubbing de una docena de llamadas.
El precio de sustituir beans se paga en arranques de contexto. Spring guarda en caché los contextos de aplicación entre clases de test usando su configuración como clave: las clases de configuración, los perfiles activos, las propiedades, el conjunto de beans sustituidos, el entorno web y más. Dos clases de test con claves idénticas comparten un contexto; cualquier diferencia supone otro arranque completo. Una suite en la que cada clase declara sus propios campos @MockitoBean crea casi un contexto por clase y se pasa la mayor parte del tiempo arrancando Spring.
Hábitos que mantienen bajo ese número:
- Define los mocks y fakes compartidos una sola vez, en una clase de test base o en una
@TestConfigurationcomún, y reutiliza el mismo conjunto de anotaciones en todas las clases. - Evita
@DirtiesContextsalvo cuando un test corrompa de verdad un estado compartido: esa anotación descarta el contexto entero. - Pon las propiedades de test en un único perfil en lugar de en atributos
propertiespor clase. - Usa slices; un contexto
@WebMvcTestarranca en una fracción del tiempo del completo y se guarda en caché por separado.
Para ver qué está pasando, cuenta los banners de Boot (o las líneas «Started … in x seconds») en el log de los tests; logging.level.org.springframework.test.context.cache=DEBUG muestra los aciertos y fallos de la caché.
Otros slices que conviene conocer
@JsonTest construye solo la configuración de Jackson y te da un JacksonTester<T> para serializar y deserializar un DTO de ida y vuelta, útil para formatos de fecha y el tratamiento de null. @RestClientTest(InventoryClient.class) configura un MockRestServiceServer vinculado al RestClient.Builder autoconfigurado, de modo que puedes comprobar las peticiones salientes y programar respuestas sin red. @DataJdbcTest, @DataMongoTest, @DataRedisTest y @WebFluxTest siguen el mismo patrón. Todos los slices aceptan @Import para beans adicionales y excludeAutoConfiguration para recortar lo que cargan.
Para jobs de Spring Batch, @SpringBatchTest añade JobLauncherTestUtils; para trabajo programado o asíncrono, el await().atMost(...).untilAsserted(...) de Awaitility sustituye a Thread.sleep en las aserciones.
Errores comunes
@SpringBootTest para todo. Suites lentas, fallos difusos y tests que pasan porque algún mock devolvió null. Empieza por el nivel unitario y sube solo cuando la comprobación lo necesite.
Mockear el repositorio en un test de servicio y llamarlo test de integración. No demuestra nada sobre la consulta. El comportamiento de un repositorio necesita @DataJpaTest con un motor real.
H2 en los tests, PostgreSQL en producción. Las consultas nativas, on conflict, las columnas JSON y el comportamiento de las secuencias difieren. Testcontainers te quita la excusa.
Olvidar que el slice no lo incluye todo. Un @WebMvcTest no recogerá un converter @Component ni un @Service que necesite el controlador; el mensaje de error indica el bean que falta. Añádelo con @Import o con un mock.
Hacer aserciones sobre el getMessage() de las excepciones o sobre la salida del log. Ambos cambian con las versiones de las librerías. Comprueba los códigos de estado, los cuerpos de respuesta y el estado persistido.
Un conjunto distinto de @MockitoBean en cada clase. Cada combinación es un contexto nuevo. Unifica.
Tests que dependen del orden o de datos residuales. El slice de datos hace rollback, pero @SpringBootTest no, salvo que añadas @Transactional, y las bases de datos de Testcontainers persisten durante toda la clase. Restablece el estado de forma explícita, o haz que cada test cree lo que necesita con claves únicas.
Puntos clave
- Prueba cada aspecto al nivel más bajo que pueda demostrarlo: tests unitarios simples para la lógica, slices para las capas web y de datos, y unos pocos flujos
@SpringBootTestpara el cableado. @WebMvcTestconMockMvccubre el enrutamiento, el JSON, la validación, las respuestas de error y las reglas de seguridad sin servidor ni base de datos.@DataJpaTestcon@AutoConfigureTestDatabase(replace = NONE)y un@ServiceConnectionde Testcontainers prueba las consultas y las migraciones contra el motor de base de datos real.@MockitoBeansustituye colaboradores en el contexto; cada conjunto distinto de sustituciones, propiedades o perfiles crea otro contexto en caché, así que mantén configuraciones uniformes.@SpringBootTest(webEnvironment = RANDOM_PORT)conTestRestTemplateofrece verdaderas comprobaciones de extremo a extremo; úsalas con moderación y haz que cuenten.- Vigila los arranques de contexto en el log: el número de contextos, no el número de tests, es lo que determina la duración de la suite.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Por qué mis tests de Spring Boot son tan lentos?
- Casi siempre porque la suite arranca muchos contextos de aplicación distintos. Spring guarda un contexto en caché y lo reutiliza en todas las clases de test con la misma configuración, pero cualquier diferencia (otro conjunto de campos @MockitoBean, otras propiedades, otro perfil activo, @DirtiesContext) crea uno nuevo, y cada arranque cuesta segundos. Agrupa los mocks en una configuración de test compartida, evita las propiedades por clase y cuenta en el log cuántas veces aparece el banner de Boot.
- ¿Qué sustituye a @MockBean en Spring Boot 3.4?
- @MockitoBean y @MockitoSpyBean de Spring Framework 6.2, en el paquete org.springframework.test.context.bean.override.mockito. Boot marcó como obsoletos sus propios @MockBean y @SpyBean en la 3.4 en favor de estos. En la mayoría de los casos el comportamiento es el mismo: el bean del contexto se sustituye por un mock de Mockito que se reinicia tras cada test, y la sustitución cuenta como una diferencia para la caché de contextos.
- ¿Los tests de repositorio deben usar H2 o la base de datos real?
- El motor de base de datos real, a través de Testcontainers. H2 acepta SQL que PostgreSQL o MySQL rechazan y carece de sus tipos y funciones, así que una suite en verde con H2 puede ocultar una migración o una consulta nativa que falla. Por defecto, @DataJpaTest sustituye tu DataSource por uno embebido; desactiva ese comportamiento con @AutoConfigureTestDatabase(replace = NONE) y apúntalo a un contenedor con @ServiceConnection.
- ¿@WebMvcTest carga mis servicios y repositorios?
- No. Crea un contexto solo con la capa web: controladores, controller advice, filtros, converters, beans WebMvcConfigurer y Spring Security si está presente. Cada servicio del que dependa un controlador debe proporcionarse, normalmente como @MockitoBean; si falta, el contexto no arranca y da un error de dependencia no satisfecha, que es justo lo que debe hacer el slice.