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

  1. Qué añade una prueba de integración
  2. @SpringBootTest y webEnvironment
  3. La caché del contexto de aplicación
  4. Las pruebas de rodaja
  5. @WebMvcTest con MockMvc
  6. Probar errores y validación
  7. MockMvcTester, la API fluida
  8. @DataJpaTest
  9. TestRestTemplate y WebTestClient
  10. Probar la seguridad del módulo 5
  11. Configuración específica de pruebas
  12. Datos con @Sql y ApplicationContextRunner
  13. Errores Comunes y Consejos
  14. Ejercicios

  1. 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.

  1. @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.

  1. 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 PruebaIntegracionBase anotada 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.
  • @DirtiesContext es 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é:

logging:
  level:
    org.springframework.test.context.cache: DEBUG

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.

  1. 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.

  1. @WebMvcTest con MockMvc

package 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 (paquete org.springframework.test.context.bean.override.mockito) sustituye el bean en el contexto. @MockBean está 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 @MockitoSpyBean y @TestBean. Recuerda del apartado 3 que cada combinación distinta de @MockitoBean crea un contexto nuevo.
  • get(...), post(...), put(...), delete(...) son estáticos de MockMvcRequestBuilders. Acepta variables de ruta como argumentos ("/{id}", 1L), y admite .param(...), .header(...), .contentType(...), .accept(...).
  • status(), jsonPath(), header(), content() son estáticos de MockMvcResultMatchers. jsonPath usa 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.

  1. 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.

  1. MockMvcTester, la API fluida

Spring 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.

  1. @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 el em.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 commit real. 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.

  1. TestRestTemplate y WebTestClient

Con 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.

  1. 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.

  1. 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: DEBUG

Recuerda 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.

  1. Datos con @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:

  1. 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.
  2. Mover a application-test.yml toda propiedad que hoy esté en un @TestPropertySource, y eliminar los @DirtiesContext que 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.
  3. 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 a ApplicationContextRunner. Las que sobrevivan como @SpringBootTest compartirá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

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados