La lección anterior dejó AlquilerService bien probado y una lista incómoda de cosas que los mocks no pueden alcanzar. Que @PreAuthorize esté realmente puesta sobre finalizar y que Spring la aplique. Que EstacionController traduzca una RecursoNoEncontradoException en un 404 con cuerpo ProblemDetail. Que findByUsuarioIdAndFinIsNull genere el SQL que creemos. Que anyRequest().denyAll() cierre lo que debe cerrar. Todo eso tiene algo en común: lo que puede fallar no es la lógica, sino el cableado, las anotaciones y el comportamiento del framework, y un mock no sabe nada de eso.
Esta lección levanta el contexto de Spring, pero con criterio. Veremos qué arranca @SpringBootTest y sus cuatro entornos web, la caché de contextos que decide si tu suite tarda cuarenta segundos o siete minutos, las pruebas de rodaja que cargan solo una capa, MockMvc para probar controladores, @DataJpaTest para consultas, TestRestTemplate y WebTestClient para el proceso completo, y las anotaciones de spring-security-test que convertirán en asertos automáticos cada regla del módulo 5. Al final, la promesa que abría el módulo estará cumplida: un ciudadano que intente finalizar el alquiler de otro producirá un 403 comprobado por una prueba, no por buena voluntad.
Contenido
- Qué añade una prueba de integración
@SpringBootTestywebEnvironment- La caché del contexto de aplicación
- Las pruebas de rodaja
@WebMvcTestconMockMvc- Probar errores y validación
MockMvcTester, la API fluida@DataJpaTestTestRestTemplateyWebTestClient- Probar la seguridad del módulo 5
- Configuración específica de pruebas
- Datos con
@SqlyApplicationContextRunner - Errores Comunes y Consejos
- Ejercicios
- Qué añade una prueba de integración
Una prueba de integración en Spring Boot arranca un contexto de aplicación y prueba varias piezas trabajando juntas. Lo que aporta sobre las unitarias es exactamente lo que las unitarias no pueden ver:
| Fallo real posible en CicloUrbana | ¿Lo ve una unitaria? |
|---|---|
Falta @Valid en el @RequestBody, y la validación de 03-04 no se aplica |
No |
El ManejadorGlobalExcepciones no captura EstacionLlenaException |
No |
| Una consulta derivada mal nombrada que Spring Data no sabe traducir | No: falla al crear el contexto |
@Transactional sobre un método private, que el proxy no intercepta |
No |
Una regla de authorizeHttpRequests en el orden equivocado |
No |
Un Instant que Jackson serializa como número en vez de ISO-8601 |
No |
| El cálculo del recargo por exceso de duración | Sí, y ahí debe probarse |
La última fila es el contraste importante: lo que una unitaria puede probar, se prueba en una unitaria. La integración es cara —segundos frente a milisegundos— y se reserva para lo que solo ella detecta.
@SpringBootTest y webEnvironment
@SpringBootTest y webEnvironment@SpringBootTest busca hacia arriba en los paquetes la clase anotada con @SpringBootApplication —por eso la prueba debe estar en com.ciclourbana o por debajo— y arranca el contexto completo: todos los beans, la autoconfiguración de 02-06, la fuente de datos, Flyway y la cadena de filtros de seguridad.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class AlquilerFlujoCompletoIT { /* ... */ }webEnvironment |
Qué levanta | Cómo se llama a la API | Cuándo usarlo |
|---|---|---|---|
MOCK (por defecto) |
Contexto web simulado, sin servidor | MockMvc |
Casi siempre: rápido y suficiente |
RANDOM_PORT |
Tomcat real en un puerto libre | TestRestTemplate, WebTestClient |
Probar serialización, filtros y códigos HTTP reales |
DEFINED_PORT |
Tomcat en el puerto de application.yml |
Igual | Casi nunca: colisiona en integración continua |
NONE |
Contexto sin capa web | Llamando a los beans | Probar servicios y repositorios con el contexto completo |
RANDOM_PORT evita el clásico «Address already in use» cuando dos construcciones corren a la vez en el mismo agente. El puerto asignado se inyecta con @LocalServerPort int puerto, aunque TestRestTemplate ya lo resuelve solo con rutas relativas.
El coste que hay que tener presente: arrancar el contexto completo de CicloUrbana —JPA, Hibernate, HikariCP, Flyway, Spring Security, springdoc— tarda entre dos y seis segundos. Cien pruebas así serían diez minutos, y una suite de diez minutos deja de ejecutarse. La solución tiene dos partes: la caché del apartado siguiente y las rodajas del apartado 4.
- La caché del contexto de aplicación
Esta es la clave del rendimiento de toda la suite, y quien no la entiende acaba con una suite lenta sin saber por qué.
Spring Test no crea un contexto por clase de prueba: mantiene una caché y reutiliza el contexto entre clases siempre que la configuración sea idéntica. La clave de caché se compone de las clases de configuración, los perfiles activos, las propiedades, los inicializadores y otros elementos. Si dos clases piden la misma configuración, el contexto se arranca una vez y las dos van rápidas.
flowchart LR
A["EstacionServiceIT<br/>@SpringBootTest"] --> K["Clave: config + perfiles<br/>+ propiedades + mocks"]
B["AlquilerServiceIT<br/>@SpringBootTest"] --> K
C["SeguridadIT<br/>@SpringBootTest + @MockitoBean"] --> K2["Clave DISTINTA"]
K --> CTX1["Contexto 1 · se arranca una vez"]
K2 --> CTX2["Contexto 2 · otro arranque de 4 s"]
Qué invalida la caché y obliga a un contexto nuevo:
| Elemento | Efecto |
|---|---|
@MockitoBean / @MockitoSpyBean |
Contexto nuevo por cada combinación distinta de beans sustituidos |
@TestPropertySource(properties = ...) |
Contexto nuevo por cada juego de propiedades |
@ActiveProfiles("otro") |
Contexto nuevo por cada combinación de perfiles |
@DirtiesContext |
Destruye el contexto tras la clase o el método |
Distinta rodaja (@WebMvcTest frente a @DataJpaTest) |
Contextos distintos por definición |
Consejos concretos para no fragmentarla:
- Estandariza. Que todas las pruebas de integración usen
@ActiveProfiles("test")y las mismas propiedades. Una clase con un perfil distinto «solo para esta prueba» cuesta cuatro segundos a la suite. - Centraliza en una clase base. Una
PruebaIntegracionBaseanotada una sola vez, de la que heredan todas, garantiza una única clave de caché. Es el patrón que 06-05 extenderá con el contenedor de PostgreSQL. - Agrupa los
@MockitoBean. Si tres clases simulan el mismo bean, que lo declaren igual: comparten contexto. Si cada una simula uno distinto, son tres contextos. @DirtiesContextes el último recurso. Se usa cuando una prueba modifica el contexto de forma irreversible. Cada uso añade un arranque completo; si aparece en varias clases, casi siempre el problema real es una prueba que no limpia lo que ensucia.
Para ver qué está pasando, activa el registro del gestor de caché:
Y verás líneas como Spring test ApplicationContext cache statistics: [size = 4, hitCount = 37, missCount = 4]. Un missCount alto es el síntoma de una suite fragmentada.
- Las pruebas de rodaja
Una rodaja (slice) carga solo la parte del contexto que necesita una capa: los beans de esa capa y su autoconfiguración, y nada más. Son mucho más rápidas que @SpringBootTest y su fallo es mucho más preciso.
| Anotación | Qué autoconfigura | Qué no carga | Para probar |
|---|---|---|---|
@WebMvcTest |
DispatcherServlet, controladores, @ControllerAdvice, Jackson, MockMvc, filtros de seguridad |
@Service, @Repository, JPA, fuente de datos |
EstacionController, AlquilerController |
@DataJpaTest |
JPA, Hibernate, repositorios, TestEntityManager, base de datos embebida, transacción con rollback |
Controladores, servicios, seguridad web | AlquilerRepositorio, entidades, consultas |
@JsonTest |
Jackson y sus ObjectMapper, JacksonTester |
Todo lo demás | Serialización de DTOs de 03-05 |
@RestClientTest |
RestTemplateBuilder/RestClient y MockRestServiceServer |
Todo lo demás | El cliente del servicio externo de 07-06 |
@JdbcTest / @DataJdbcTest |
JdbcTemplate / Spring Data JDBC, sin JPA |
JPA, controladores | Proyectos sin JPA |
La consecuencia práctica de la columna «qué no carga»: en un @WebMvcTest, el EstacionService que el controlador necesita no existe, y el contexto falla al arrancar si no lo proporcionas. Ahí entra @MockitoBean.
@WebMvcTest con MockMvc
@WebMvcTest con MockMvcpackage com.ciclourbana.estaciones;
@WebMvcTest(EstacionController.class) // solo ESTE controlador; sin él, todos
@DisplayName("EstacionController · API pública de estaciones")
class EstacionControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
/** Sustituye el bean real en el contexto por un mock de Mockito.
* Desde Spring Boot 3.4, @MockBean está OBSOLETO en favor de esta. */
@MockitoBean private EstacionService estacionService;
@Test
void devuelveLaEstacionYSusCamposCuandoExiste() throws Exception {
when(estacionService.obtenerPorId(1L))
.thenReturn(new EstacionResponse(1L, "Plaza Mayor", "Plaza Mayor, 1", 24, 9));
mockMvc.perform(get("/api/v1/estaciones/{id}", 1L))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.nombre").value("Plaza Mayor"))
.andExpect(jsonPath("$.capacidad").value(24))
.andExpect(jsonPath("$.contrasenaHash").doesNotExist()); // no filtrar
}
@Test
void creaLaEstacionYDevuelveLaCabeceraLocation() throws Exception {
CrearEstacionRequest peticion =
new CrearEstacionRequest("Mercado Viejo", "Plaza del Mercado, 3", 18);
when(estacionService.crear(any())).thenReturn(
new EstacionResponse(5L, "Mercado Viejo", "Plaza del Mercado, 3", 18, 18));
mockMvc.perform(post("/api/v1/estaciones")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(peticion)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/v1/estaciones/5"));
}
}Anatomía de la llamada, pieza a pieza:
@WebMvcTest(EstacionController.class)limita la rodaja a un controlador. Sin el argumento carga todos, lo que arranca más lento y hace que un fallo en otro controlador rompa esta prueba.@MockitoBean(paqueteorg.springframework.test.context.bean.override.mockito) sustituye el bean en el contexto.@MockBeanestá obsoleto desde Spring Boot 3.4 y desaparecerá; la nueva anotación pertenece al mecanismo genérico de bean overriding de Spring Framework 6.2, del que también forman parte@MockitoSpyBeany@TestBean. Recuerda del apartado 3 que cada combinación distinta de@MockitoBeancrea un contexto nuevo.get(...),post(...),put(...),delete(...)son estáticos deMockMvcRequestBuilders. Acepta variables de ruta como argumentos ("/{id}", 1L), y admite.param(...),.header(...),.contentType(...),.accept(...).status(),jsonPath(),header(),content()son estáticos deMockMvcResultMatchers.jsonPathusa Hamcrest, que es la única excepción a la regla «solo AssertJ» de 06-02..andDo(print())vuelca petición y respuesta a la consola: es lo primero que se añade cuando algo no cuadra.
El aserto jsonPath("$.contrasenaHash").doesNotExist() es más importante de lo que parece: es la prueba de contrato que 03-05 prometía, la que detecta que alguien ha añadido un campo interno a la respuesta pública.
Qué prueba y qué no una rodaja web. Prueba el mapeo de rutas, la deserialización del cuerpo, la validación, la serialización de la respuesta, los códigos de estado, las cabeceras y los manejadores de excepciones. No prueba la lógica del servicio —está simulada— ni la persistencia. Es exactamente la división correcta.
- Probar errores y validación
Los ProblemDetail de 03-06 y las respuestas de validación de 03-04 son contrato público, así que merecen pruebas propias:
@Test
void devuelve404ConFormatoProblemDetailCuandoLaEstacionNoExiste() throws Exception {
when(estacionService.obtenerPorId(99L))
.thenThrow(new RecursoNoEncontradoException("Estación", 99L));
mockMvc.perform(get("/api/v1/estaciones/99"))
.andExpect(status().isNotFound())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.type").value("https://api.ciclourbana.es/errores/no-encontrado"))
.andExpect(jsonPath("$.title").value("Recurso no encontrado"))
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.detail").value(containsString("99")))
.andExpect(jsonPath("$.instance").value("/api/v1/estaciones/99"));
}
@Test
void devuelve400ConLaListaDeCamposInvalidosAlCrearUnaEstacionMalFormada() throws Exception {
String cuerpo = """
{"nombre": "", "direccion": "Calle Falsa, 1", "capacidad": -5}
""";
mockMvc.perform(post("/api/v1/estaciones")
.contentType(MediaType.APPLICATION_JSON).content(cuerpo))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errores", hasSize(2)))
.andExpect(jsonPath("$.errores[*].campo",
containsInAnyOrder("nombre", "capacidad")));
verifyNoInteractions(estacionService); // la petición no llegó al servicio
}Ese último verifyNoInteractions es el aserto que hace valiosa la segunda prueba: comprueba que la validación corta en el borde, que es justo lo que 03-04 pretendía. Y la respuesta de error se prueba aquí, y no en una unitaria, porque la produce la cooperación entre @Valid, MethodArgumentNotValidException y el @RestControllerAdvice: ninguna de las tres piezas por separado la genera.
Para comparar el JSON completo en lugar de campo a campo, content().json(esperado) usa JSONassert e ignora el orden de las claves; con content().json(esperado, true) la comparación es estricta y falla si sobra algún campo.
MockMvcTester, la API fluida
MockMvcTester, la API fluidaSpring Framework 6.2 introduce MockMvcTester, una capa sobre MockMvc con aserciones de AssertJ y sin throws Exception:
@Autowired private MockMvcTester mockMvc; // se autoconfigura en @WebMvcTest
@Test
void devuelveLaEstacionConLaApiFluida() {
assertThat(mockMvc.get().uri("/api/v1/estaciones/{id}", 1L))
.hasStatusOk()
.hasContentType(MediaType.APPLICATION_JSON)
.bodyJson().extractingPath("$.nombre").isEqualTo("Plaza Mayor");
}MockMvc clásico |
MockMvcTester |
|
|---|---|---|
| Estilo | andExpect(...) encadenados |
assertThat(...) de AssertJ |
| Excepciones | throws Exception en cada método |
No |
| Matchers | Hamcrest | AssertJ |
| Madurez | Ubicuo: toda la documentación y los ejemplos | Reciente |
Es más coherente con el resto del curso, pero este módulo usa MockMvc clásico por una razón práctica: es lo que encontrarás en cualquier proyecto y en cualquier respuesta que busques. Conviene conocer las dos y no mezclarlas dentro de una misma clase.
@DataJpaTest
@DataJpaTest@DataJpaTest
@DisplayName("AlquilerRepositorio · consultas de la red de Ribalta")
class AlquilerRepositorioTest {
@Autowired private AlquilerRepositorio alquilerRepositorio;
@Autowired private TestEntityManager em;
@Test
void findByUsuarioIdAndFinIsNullDevuelveSoloLosAlquileresEnCurso() {
Usuario marta = em.persist(UsuariosDePrueba.nuevaMarta());
em.persist(AlquileresDePrueba.enCursoDe(marta));
em.persist(AlquileresDePrueba.finalizadoDe(marta));
em.flush(); // fuerza el SQL: sin esto, la consulta puede no verlos
em.clear(); // vacía la caché de primer nivel: lecturas reales
List<Alquiler> enCurso = alquilerRepositorio.findByUsuarioIdAndFinIsNull(marta.getId());
assertThat(enCurso).hasSize(1)
.allSatisfy(a -> assertThat(a.getFin()).isNull());
}
}Tres características que hay que entender:
Base de datos embebida por defecto. Si H2 está en el classpath, @DataJpaTest sustituye la fuente de datos real por una en memoria. Es rápido y es la razón de ser de la lección 06-05: H2 no es PostgreSQL, y hay consultas que aquí pasan y en Ribalta fallan. Para usar la fuente de datos configurada se añade @AutoConfigureTestDatabase(replace = Replace.NONE).
Transacción con rollback automático. Cada prueba corre en una transacción que se deshace al terminar, así que las pruebas no se contaminan entre sí y no hay que limpiar nada. Sus dos implicaciones:
- Todo ocurre en la misma sesión de Hibernate, así que una entidad persistida sigue en la caché de primer nivel y una consulta podría devolverla sin tocar la base de datos. De ahí el
em.flush()y elem.clear(): sin ellos la prueba puede pasar aunque el SQL generado sea incorrecto. Es el error más común de@DataJpaTest. - El rollback esconde los fallos de restricciones diferidas y no prueba el comportamiento del
commitreal. Si eso importa,@Transactional(propagation = NOT_SUPPORTED)sobre la prueba, limpiando a mano.
TestEntityManager es una envoltura sobre el EntityManager con métodos pensados para pruebas: persist, persistFlushFind, flush, clear, refresh. Se usa para preparar datos; lo que se verifica es siempre el repositorio, que es lo que se está probando.
TestRestTemplate y WebTestClient
TestRestTemplate y WebTestClientCon RANDOM_PORT hay un Tomcat real y se llama a la API por HTTP de verdad, dentro del mismo proceso:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class EstacionApiIT {
@Autowired private TestRestTemplate cliente; // rutas relativas: resuelve el puerto
@Test
void listaLasCuatroEstacionesDeRibaltaPaginadas() {
ResponseEntity<String> respuesta =
cliente.getForEntity("/api/v1/estaciones?size=10", String.class);
assertThat(respuesta.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(respuesta.getBody()).contains("Plaza Mayor", "Universidad");
}
}WebTestClient es la alternativa moderna, fluida y válida también para WebFlux; requiere spring-webflux en el classpath de prueba y se autoconfigura con @AutoConfigureWebTestClient.
MockMvc |
TestRestTemplate / WebTestClient |
|
|---|---|---|
| Servidor | No: simula el DispatcherServlet |
Tomcat real |
| Serialización | Real (Jackson) | Real |
| Capa de red y filtros de Servlet | No los atraviesa | Sí, todos |
| Velocidad | Milisegundos | Decenas de milisegundos + arranque |
| Acceso a los beans internos | Sí, el contexto está a mano | Sí |
| Uso recomendado | El 90 % de las pruebas de la capa web | Unos pocos caminos críticos completos |
TestRestTemplate tiene una diferencia útil frente a RestTemplate: no lanza excepción con los códigos 4xx y 5xx, lo que permite afirmar sobre un 404 sin envolver la llamada en un try.
- Probar la seguridad del módulo 5
Aquí se cumple la promesa del cierre del módulo 5. Primero, la dependencia:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>| Anotación / utilidad | Qué hace |
|---|---|
@WithMockUser(roles = "OPERARIO") |
Autenticación simulada, sin tocar la base de datos |
@WithAnonymousUser |
Petición explícitamente anónima |
@WithUserDetails("[email protected]") |
Carga el usuario real con ServicioDetallesUsuario: necesita datos |
@WithSecurityContext |
Base para anotaciones propias |
SecurityMockMvcRequestPostProcessors.user(...) |
Lo mismo, pero por petición: .with(user(...)) |
...jwt() / ...csrf() |
Simular un token de recurso OAuth2 o añadir el testigo CSRF |
Un detalle que hay que fijar: @WithMockUser(roles = "ADMIN") añade la autoridad ROLE_ADMIN, porque le pone el prefijo. Con authorities = "ROLE_ADMIN" se indica la autoridad literal. Confundirlos —escribir roles = "ROLE_ADMIN" y acabar con ROLE_ROLE_ADMIN— es un clásico que produce un 403 inexplicable.
En una rodaja web hay que importar la configuración de seguridad, porque @WebMvcTest carga los filtros pero no tu ConfiguracionSeguridad:
@WebMvcTest(AlquilerController.class)
@Import({ConfiguracionSeguridad.class, ServicioJwt.class})
class AlquilerControllerSeguridadTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private AlquilerService alquilerService;
@MockitoBean private ServicioDetallesUsuario servicioDetallesUsuario;
@Test
@WithAnonymousUser
void exigeAutenticacionParaConsultarLosAlquileres() throws Exception {
mockMvc.perform(get("/api/v1/alquileres")).andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "CIUDADANO")
void permiteAlCiudadanoConsultarSusAlquileres() throws Exception {
mockMvc.perform(get("/api/v1/alquileres")).andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "CIUDADANO")
void prohibeAlCiudadanoAccederAlPanelDeOperarios() throws Exception {
mockMvc.perform(get("/api/v1/interno/bicicletas")).andExpect(status().isForbidden());
}
@Test
void devuelve401SinTokenEnLugarDeRedirigirAlFormulario() throws Exception {
mockMvc.perform(post("/api/v1/alquileres/9/finalizar"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.status").value(401)); // ProblemDetail, no HTML
}
}Una anotación propia evita repetir la misma configuración en treinta pruebas:
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "[email protected]", roles = "CIUDADANO")
public @interface ConCiudadano { }
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "[email protected]", roles = "OPERARIO")
public @interface ConOperario { }Y la prueba que cierra el círculo del módulo 5: la regla de @PreAuthorize con SeguridadAlquileres.esPropietario. En 06-03 probamos el bean en aislamiento; lo que falta es comprobar que Spring aplica la anotación, y eso exige el contexto con la seguridad de método activa:
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SeguridadAlquileresIT {
@Autowired private MockMvc mockMvc;
@Test
@WithUserDetails("[email protected]") // CIUDADANO, id 7, cargado de verdad
void elCiudadanoNoPuedeFinalizarElAlquilerDeOtro() throws Exception {
// El alquiler 9 pertenece al ciudadano 12, no a Marta
mockMvc.perform(post("/api/v1/alquileres/9/finalizar")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"estacionDestinoId\": 2}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.title").value("Acceso denegado"));
}
@Test
@WithUserDetails("[email protected]")
void elCiudadanoSiPuedeFinalizarElSuyo() throws Exception {
mockMvc.perform(post("/api/v1/alquileres/4/finalizar") // el 4 sí es de Marta
.contentType(MediaType.APPLICATION_JSON)
.content("{\"estacionDestinoId\": 2}"))
.andExpect(status().isOk());
}
}Estas dos pruebas son la respuesta a la pregunta que abría el módulo. El agujero que 05-05 cerró queda ahora vigilado por asertos que se ejecutan en cada construcción; si alguien borra la anotación, reordena la expresión SpEL o renombra el bean, la suite se pone en rojo. Y obsérvese el par: una prueba de denegación y una de permiso. Sin la segunda, una regla que denegara siempre —el fallo más común al escribir SpEL, que no comprueba el compilador— pasaría inadvertida.
Sobre csrf(): CicloUrbana lo desactivó justificadamente en 05-02 por ser una API sin sesión, así que aquí no hace falta. En una aplicación con sesión, un POST sin .with(csrf()) devuelve 403 y desconcierta; ese 403 es la señal de que falta el testigo, no un problema de roles.
- Configuración específica de pruebas
# src/test/resources/application-test.yml
spring:
datasource:
url: jdbc:h2:mem:ribalta;DB_CLOSE_DELAY=-1
jpa:
hibernate:
ddl-auto: create-drop # en pruebas sí; en producción, validate (04-08)
show-sql: true
flyway:
enabled: false # el esquema lo crea Hibernate en esta rodaja
ciclourbana:
red:
capacidad-minima: 8
umbral-bateria: 20
jwt:
secreto: clave-de-prueba-de-al-menos-43-caracteres-base64-para-hs256
expiracion-acceso: 15m
logging:
level:
org.springframework.security: DEBUGRecuerda de 06-01: el fichero se llama application-test.yml, no application.yml. Un application.yml en src/test/resources no se fusiona con el de producción, lo sustituye.
| Mecanismo | Alcance | Efecto en la caché |
|---|---|---|
@ActiveProfiles("test") |
Clase | Nueva clave por combinación de perfiles |
@TestPropertySource(properties = "...") |
Clase | Nueva clave por juego de propiedades |
@DynamicPropertySource |
Clase, valores calculados en ejecución | Igual; es la puerta a Testcontainers (06-05) |
@TestConfiguration + @Import |
Clase | Nueva clave |
@DynamicPropertySource merece una mirada, porque es lo que hará posible la lección siguiente: permite calcular propiedades después de que arranque algo externo pero antes de que se cree el contexto.
@DynamicPropertySource
static void propiedadesDinamicas(DynamicPropertyRegistry registro) {
registro.add("ciclourbana.notificaciones.url", servidorFalso::getUrl);
}Y @TestConfiguration aporta beans solo para las pruebas que la importen —a diferencia de @Configuration, que sería recogida por el escaneo y afectaría a todo:
@TestConfiguration
public class ConfiguracionRelojFijo {
@Bean
@Primary // gana al Clock de ConfiguracionComun
public Clock relojCongelado() {
return Clock.fixed(Instant.parse("2026-03-14T09:00:00Z"), ZoneOffset.UTC);
}
}Importarla con @Import(ConfiguracionRelojFijo.class) hace determinista cualquier prueba de integración que dependa del tiempo, con la misma idea de 06-02 pero a escala de contexto.
- Datos con
@Sql y ApplicationContextRunner
@Sql y ApplicationContextRunner@Sql ejecuta guiones antes o después de una prueba:
@Test
@Sql(scripts = "/datos/estaciones-ribalta.sql") // antes
@Sql(scripts = "/datos/limpiar.sql", executionPhase = AFTER_TEST_METHOD)
void listaLasEstacionesCargadasPorElGuion() throws Exception { /* ... */ }Puesta sobre la clase se aplica a todos sus métodos, y varios guiones se ejecutan en orden. Frente a preparar los datos con el TestEntityManager o los repositorios, @Sql gana cuando el juego de datos es grande o cuando hace falta un estado que las entidades no permiten construir; pierde en que el guion se desincroniza del esquema en cuanto llega una migración nueva. Para CicloUrbana la regla es: datos pequeños y expresivos con TestEntityManager y las fábricas de 06-02; juegos grandes y estables con @Sql.
Y para el otro extremo del espectro, ApplicationContextRunner —que ya apareció en 02-06— prueba la configuración misma sin arrancar ningún contexto completo, en milisegundos:
@Test
void elArranqueFallaSiFaltaElSecretoJwt() {
new ApplicationContextRunner()
.withUserConfiguration(ConfiguracionJwt.class)
.run(contexto -> assertThat(contexto)
.hasFailed()
.getFailure()
.hasMessageContaining("jwt.secreto"));
}
@Test
void registraLaTarifaJubiladoAutomaticamenteAlEstarEnElClasspath() {
new ApplicationContextRunner()
.withUserConfiguration(TarifaEstandar.class, TarifaEstudiante.class,
TarifaJubilado.class, SelectorTarifa.class)
.run(contexto -> assertThat(contexto.getBean(SelectorTarifa.class).disponibles())
.containsKeys("estandar", "estudiante", "jubilado"));
}
@Test
void noRegistraElStarterCuandoLaPropiedadEstaDesactivada() {
new ApplicationContextRunner()
.withPropertyValues("ciclourbana.red.enabled=false")
.withConfiguration(AutoConfigurations.of(RedAutoConfiguration.class))
.run(contexto -> assertThat(contexto).doesNotHaveBean(RedProperties.class));
}Es la herramienta ideal para verificar @ConditionalOnProperty, @ConditionalOnMissingBean y la validación de @ConfigurationProperties del módulo 2: comprueba comportamiento de contexto sin pagar el arranque ni fragmentar la caché.
Errores Comunes y Consejos
Usar @SpringBootTest para todo. Es el camino directo al cono de helado de 06-01. Antes de escribirlo, pregúntate qué capa estás probando: casi siempre hay una rodaja que basta y cuesta una décima parte.
Seguir usando @MockBean. Está obsoleto desde Spring Boot 3.4. Sustitúyelo por @MockitoBean (y @SpyBean por @MockitoSpyBean); el cambio es mecánico y evita una migración forzosa más adelante.
Fragmentar la caché de contextos sin darse cuenta. Un @TestPropertySource puesto «solo para esta prueba» añade un arranque completo. Antes de añadir configuración a una clase, mira si puede vivir en el perfil test común.
Olvidar em.flush() y em.clear() en @DataJpaTest. La consulta se responde desde la caché de primer nivel y la prueba pasa aunque el SQL sea incorrecto. Es el falso positivo más peligroso de esta lección.
Poner application.yml en src/test/resources. Sustituye al de producción en vez de complementarlo. Usa application-test.yml con @ActiveProfiles("test").
@WebMvcTest sin @Import de la configuración de seguridad. Las pruebas pasan porque no hay reglas que aplicar, dando una falsa sensación de cobertura de seguridad.
Escribir solo pruebas de denegación. Comprobar los 403 sin comprobar los 200 deja pasar una regla que deniega siempre, que es el fallo más común de SpEL. Cada regla necesita su par.
Consejo: una clase base para las pruebas de integración. Concentra @SpringBootTest, @ActiveProfiles("test") y la configuración común en un solo sitio: una clave de caché, un arranque, y un único punto donde 06-05 enchufará el contenedor de PostgreSQL.
Consejo: .andDo(print()) es tu primera herramienta. Ante un fallo incomprensible en MockMvc, volcar petición y respuesta resuelve la mayoría de los casos en treinta segundos.
Consejo: nombra las pruebas de integración *IT. Failsafe las separa de las rápidas (06-01), así ./mvnw test sigue durando segundos y ./mvnw verify lo comprueba todo.
Ejercicios
Ejercicio 1
Escribe EstacionControllerTest completo con @WebMvcTest cubriendo cuatro casos del contrato público: listado paginado que devuelve un PaginaResponse con sus campos contenido, pagina y totalElementos; el 404 con ProblemDetail de una estación inexistente; el 400 al crear con capacidad -5, verificando que el servicio no se llegó a invocar; y el 409 con código ESTACION_DUPLICADA cuando el servicio lanza EstacionDuplicadaException. Explica por qué las cuatro son rodajas y ninguna necesita @SpringBootTest.
Ejercicio 2
Crea la anotación @ConCiudadano del apartado 10 y escribe la matriz de acceso de CicloUrbana como pruebas: para las rutas GET /api/v1/estaciones, POST /api/v1/estaciones, GET /api/v1/alquileres y GET /api/v1/interno/bicicletas, comprueba el resultado esperado para anónimo, ciudadano, operario y administrador. Diseña la prueba de forma que añadir una ruta nueva cueste una línea, no un método. Pista: @ParameterizedTest con @CsvSource y SecurityMockMvcRequestPostProcessors.user(...).
Ejercicio 3
Un compañero informa de que la suite ha pasado de 45 segundos a 6 minutos tras añadir doce clases de prueba. El registro de la caché dice [size = 11, hitCount = 3, missCount = 11]. Diagnostica el problema, explica qué significan esos números y propón un plan concreto de tres pasos para volver a un solo contexto compartido.
Soluciones
Solución 1
@WebMvcTest(EstacionController.class)
class EstacionControllerTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private EstacionService estacionService;
@Test
void devuelveElListadoPaginadoConSusMetadatos() throws Exception {
when(estacionService.listar(any())).thenReturn(new PaginaResponse<>(
List.of(new EstacionResponse(1L, "Plaza Mayor", "Plaza Mayor, 1", 24, 9)),
0, 20, 4L, 1, true, true));
mockMvc.perform(get("/api/v1/estaciones?page=0&size=20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.contenido", hasSize(1)))
.andExpect(jsonPath("$.contenido[0].nombre").value("Plaza Mayor"))
.andExpect(jsonPath("$.totalElementos").value(4));
}
@Test
void devuelve404ConProblemDetailCuandoNoExiste() throws Exception {
when(estacionService.obtenerPorId(99L))
.thenThrow(new RecursoNoEncontradoException("Estación", 99L));
mockMvc.perform(get("/api/v1/estaciones/99"))
.andExpect(status().isNotFound())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.status").value(404));
}
@Test
void devuelve400SinLlamarAlServicioCuandoLaCapacidadEsNegativa() throws Exception {
mockMvc.perform(post("/api/v1/estaciones").contentType(MediaType.APPLICATION_JSON)
.content("{\"nombre\":\"X\",\"direccion\":\"C/1\",\"capacidad\":-5}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(estacionService);
}
@Test
void devuelve409ConElCodigoDeNegocioCuandoElNombreEstaDuplicado() throws Exception {
when(estacionService.crear(any())).thenThrow(new EstacionDuplicadaException("Plaza Mayor", 1L));
mockMvc.perform(post("/api/v1/estaciones").contentType(MediaType.APPLICATION_JSON)
.content("{\"nombre\":\"Plaza Mayor\",\"direccion\":\"C/1\",\"capacidad\":24}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.codigo").value("ESTACION_DUPLICADA"));
}
}Por qué ninguna necesita @SpringBootTest: las cuatro verifican exclusivamente lo que vive en la capa web —mapeo de rutas, deserialización, validación declarativa, serialización de la respuesta, códigos de estado y el @RestControllerAdvice—, y todo eso lo carga @WebMvcTest. Lo que hay por debajo está simulado a propósito: la lógica de EstacionService ya se prueba en unitarias (06-02) y la persistencia se probará con @DataJpaTest y Testcontainers. Levantar JPA, Hikari, Flyway y la seguridad para comprobar un jsonPath sería pagar cuatro segundos por prueba sin detectar ni un fallo más.
Solución 2
@WebMvcTest
@Import(ConfiguracionSeguridad.class)
class MatrizDeAccesoTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private EstacionService estacionService;
@MockitoBean private AlquilerService alquilerService;
@MockitoBean private ServicioDetallesUsuario servicioDetallesUsuario;
@ParameterizedTest(name = "{0} {1} como {2} -> {3}")
@CsvSource({
"GET, /api/v1/estaciones, ANONIMO, 200",
"GET, /api/v1/estaciones, CIUDADANO, 200",
"POST, /api/v1/estaciones, CIUDADANO, 403",
"POST, /api/v1/estaciones, OPERARIO, 403",
"POST, /api/v1/estaciones, ADMIN, 201",
"GET, /api/v1/alquileres, ANONIMO, 401",
"GET, /api/v1/alquileres, CIUDADANO, 200",
"GET, /api/v1/interno/bicicletas,CIUDADANO, 403",
"GET, /api/v1/interno/bicicletas,OPERARIO, 200",
"GET, /api/v1/interno/bicicletas,ADMIN, 200"
})
void aplicaLaMatrizDeAccesoDeCicloUrbana(String verbo, String ruta,
String rol, int esperado) throws Exception {
var peticion = "POST".equals(verbo)
? post(ruta).contentType(MediaType.APPLICATION_JSON).content(CUERPO_VALIDO)
: get(ruta);
if (!"ANONIMO".equals(rol)) {
peticion = peticion.with(user("[email protected]").roles(rol));
}
mockMvc.perform(peticion).andExpect(status().is(esperado));
}
}Comentario: la tabla ES la especificación de seguridad, y añadir una ruta cuesta exactamente una línea. Se usa .with(user(...)) en lugar de @WithMockUser porque el rol es un parámetro y una anotación no puede recibirlo. Nótese que el if que decide si añadir el usuario no es la «lógica condicional en una prueba» que 06-02 prohibía: no elige qué comprobar —el aserto es siempre el mismo— sino cómo construir el escenario que la fila describe. La línea que separa ambas cosas es si el if afecta a los asertos.
Un detalle final de diseño: aparecen ahí las filas de 200 y 201, no solo las de 401 y 403. Sin ellas, una configuración que denegara absolutamente todo pasaría la mitad de la tabla en verde.
Solución 3
Qué significan los números. size = 11 son once contextos distintos cacheados; missCount = 11, once arranques completos; hitCount = 3, solo tres reutilizaciones. Traducido: cada clase de prueba está levantando su propio contexto. Con cuatro segundos de arranque, ahí están los cinco minutos y medio de más.
Diagnóstico. No es que las pruebas sean lentas: es que sus claves de caché son todas distintas. Las causas habituales, en orden de frecuencia: cada clase declara un @MockitoBean diferente; alguna añade @TestPropertySource con una propiedad concreta; hay perfiles distintos (@ActiveProfiles("test") en unas, nada o "dev" en otras); y algún @DirtiesContext heredado destruye el contexto tras cada clase.
Plan de tres pasos:
- Crear una clase base común con
@SpringBootTest,@ActiveProfiles("test")y@AutoConfigureMockMvc, y hacer que las doce hereden de ella. Solo esto suele reducir once contextos a dos o tres. - Mover a
application-test.ymltoda propiedad que hoy esté en un@TestPropertySource, y eliminar los@DirtiesContextque no estén justificados por escrito. Si una prueba ensucia el contexto, arreglar la prueba —limpiando lo que crea— es más barato que reconstruir el contexto entero. - Reclasificar. Revisar las doce y preguntarse cuáles necesitan de verdad el contexto completo: las que solo prueban la capa web pasan a
@WebMvcTest, las de consultas a@DataJpaTest, y las que prueban configuración condicional aApplicationContextRunner. Las que sobrevivan como@SpringBootTestcompartirán contexto gracias al paso 1.
Y la comprobación objetiva de que ha funcionado: volver a mirar el registro. El objetivo es size de 2 o 3 y un hitCount muy superior al missCount.
Conclusión
CicloUrbana ya no confía: comprueba. Sabes qué añade una prueba de integración sobre las unitarias —el cableado, las anotaciones y el comportamiento del framework, que ningún mock puede ver— y qué no debe hacerse en ella. Conoces @SpringBootTest y sus cuatro entornos web, con MOCK como opción por defecto y RANDOM_PORT reservado para los pocos caminos que merecen un Tomcat de verdad. Y, sobre todo, entiendes la caché de contextos: qué la forma, qué la invalida —@MockitoBean, @TestPropertySource, @ActiveProfiles, @DirtiesContext— y por qué una clase base común y un perfil test compartido son la diferencia entre una suite de cuarenta segundos y una de seis minutos que nadie ejecuta.
Dominas las rodajas: @WebMvcTest con MockMvc para el contrato público —códigos de estado, cabecera Location, jsonPath, el ProblemDetail de 03-06, la validación de 03-04 y el aserto de contrato que impide que un campo interno se filtre—, @DataJpaTest con su base embebida, su rollback automático y la trampa de la caché de primer nivel que hace pasar pruebas con SQL incorrecto si falta flush y clear, más @JsonTest, @RestClientTest y las variantes JDBC. Sabes que @MockBean está obsoleto desde Spring Boot 3.4 y que su sustituto es @MockitoBean, y conoces MockMvcTester como la alternativa fluida y moderna a la API clásica.
Y has convertido en asertos automáticos todo el módulo 5: spring-security-test con @WithMockUser, @WithAnonymousUser, @WithUserDetails, la anotación propia @ConCiudadano y los post-processors user(...), jwt() y csrf(); la matriz de acceso de 05-02 escrita como una tabla parametrizada donde añadir una ruta cuesta una línea; y las dos pruebas que cierran el círculo del curso hasta aquí: Marta recibe un 403 al intentar finalizar el alquiler ajeno y un 200 al finalizar el suyo, con el par completo, porque una regla que deniega siempre es el fallo más frecuente de SpEL. Sabes configurar el perfil test, calcular propiedades en ejecución con @DynamicPropertySource, aportar beans solo de prueba con @TestConfiguration —incluido el Clock congelado a escala de contexto—, cargar datos con @Sql y verificar la configuración condicional del módulo 2 con ApplicationContextRunner sin pagar un solo arranque.
Queda una grieta, y es grande. Todo lo probado contra la base de datos en esta lección ha corrido sobre H2 en memoria, y producción es PostgreSQL 16. No son lo mismo: difieren en dialecto, en tipos, en funciones, en el comportamiento de las secuencias y en lo que aceptan como SQL nativo. La consulta nativa de 04-06 que H2 no entiende, el índice parcial de la migración V3, la validación de ddl-auto: validate contra el esquema real que dejan V1…V6 de 04-08: nada de eso está comprobado, y algunas de esas pruebas pasan hoy en verde mientras el código fallaría en Ribalta. La lección siguiente, Pruebas con Testcontainers, levanta un PostgreSQL real y efímero en Docker para cada suite, lo conecta con @ServiceConnection y convierte esa última zona de fe en una zona de asertos.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
