La lección anterior cerró con una grieta nombrada pero no tapada: todo lo que hemos probado contra la base de datos ha corrido sobre H2 en memoria, y la red de Ribalta funciona sobre PostgreSQL 16. Mientras las pruebas son sencillas la diferencia no se nota, y esa es justamente la trampa: una prueba que pasa en verde con H2 mientras el mismo código falla en producción es peor que no tener prueba, porque además da confianza.

Esta lección elimina esa zona de fe. Veremos con ejemplos concretos qué falla al cambiar de motor, qué es Testcontainers y cómo funciona, cómo levantar un PostgreSQL real y efímero para las pruebas, la diferencia entre conectarlo a mano con @DynamicPropertySource y hacerlo automáticamente con @ServiceConnection, cómo compartir un contenedor entre todas las clases para que la suite no tarde media hora, cómo comprobar de verdad que las migraciones V1…V6 dejan el esquema que las entidades esperan, cómo aislar los datos entre pruebas y cómo usar los mismos contenedores para arrancar la aplicación en desarrollo sin instalar nada. Al terminar, la suite de CicloUrbana estará completa y el módulo cerrado.

Contenido

  1. El problema de probar contra H2
  2. Qué es Testcontainers y cómo funciona
  3. Dependencias
  4. La primera prueba con @Container
  5. @DynamicPropertySource frente a @ServiceConnection
  6. Contenedor compartido: PruebaIntegracionBase
  7. Reutilización con withReuse
  8. Verificar Flyway de verdad
  9. Las consultas que H2 no soporta
  10. Aislamiento de datos entre pruebas
  11. Testcontainers en desarrollo local
  12. Otros contenedores útiles
  13. Coste, integración continua y depuración
  14. Errores Comunes y Consejos
  15. Ejercicios

  1. El problema de probar contra H2

H2 tiene un modo de compatibilidad con PostgreSQL, y funciona sorprendentemente bien. Hasta que no.

Diferencia Qué pasa en CicloUrbana
SQL nativo buscarCaros de 04-06 es nativeQuery = true; cualquier función específica de PostgreSQL (ILIKE, to_tsvector, jsonb_path_query) revienta en H2
Índices parciales La migración V3 crea CREATE INDEX ... WHERE estado <> 'RESUELTA'; H2 no los soporta y la migración falla o se ignora
Secuencias El allocationSize = 50 de 04-03 y el INCREMENT BY real de la secuencia deben coincidir; en H2 los identificadores salen distintos y un desajuste que en Ribalta corrompe datos aquí no aparece
Tipos jsonb, uuid, text[], interval, timestamptz; H2 los aproxima o los rechaza
Restricciones diferidas y ON CONFLICT Un upsert de PostgreSQL no tiene equivalente
Bloqueos El @Lock(PESSIMISTIC_WRITE) de 04-07 sobre Bicicleta se comporta distinto: la condición de carrera de dos usuarios alquilando la misma bicicleta no se puede reproducir en H2
Sensibilidad a mayúsculas H2 pliega los identificadores sin comillas a mayúsculas y PostgreSQL a minúsculas: una tabla Estaciones funciona en uno y no en el otro
Mensajes de error El código SQL de una violación de unicidad difiere, así que el catch que traduce a ConflictoRecursoException puede no dispararse

Y el caso más grave de todos, porque es silencioso: ddl-auto: validate contra un esquema creado por H2 no valida nada útil. En 06-04 desactivamos Flyway en el perfil de prueba y dejamos que Hibernate creara las tablas con create-drop. Eso significa que el esquema de las pruebas lo genera la propia entidad, así que siempre coincide consigo mismo. Una entidad desalineada de las migraciones V1…V6 pasaría todas las pruebas y fallaría en el arranque de producción.

La conclusión no es que H2 sea malo: es rápido y sirve para muchas rodajas. La conclusión es que la suite necesita, además, un tramo que corra contra el motor real.

  1. Qué es Testcontainers y cómo funciona

Testcontainers es una librería que arranca contenedores Docker desde el código de las pruebas y los destruye al terminar. Da bases de datos, colas, almacenamientos y servicios falsos reales y efímeros, sin instalar nada en la máquina ni compartir un servidor entre desarrolladores.

flowchart LR
    T["La prueba JUnit"] --> API["API de Testcontainers"]
    API --> D["Demonio de Docker"]
    D --> C["Contenedor postgres:16-alpine<br/>puerto aleatorio"]
    D --> R["Ryuk (contenedor vigilante)"]
    C --> W["Espera a que esté listo<br/>(wait strategy)"]
    W --> T
    R -. "borra todo si la JVM muere" .-> C

El ciclo es siempre el mismo: descargar la imagen si falta, arrancar el contenedor publicando el puerto interno en uno libre y aleatorio del anfitrión, esperar a que el servicio esté listo —para PostgreSQL, hasta que acepte conexiones— y entregar a la prueba la URL, el usuario y la contraseña reales.

Dos piezas conviene conocerlas por nombre. El puerto aleatorio es lo que permite que varias construcciones corran a la vez en el mismo agente sin colisionar, y es la razón por la que la URL no se puede escribir en el application.yml: no se conoce hasta el arranque. Ryuk es un contenedor auxiliar que Testcontainers levanta y que borra todo lo creado si la JVM muere de forma abrupta; sin él, un kill -9 dejaría contenedores huérfanos consumiendo memoria.

Requisitos: un entorno Docker en funcionamiento (Docker Desktop, Colima, Podman en modo compatible o Docker Engine en Linux) y permiso para usarlo. Si no lo hay, las pruebas fallan al arrancar el contenedor; en el apartado 13 veremos cómo omitirlas con elegancia.

  1. Dependencias

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>1.20.4</version>
            <type>pom</type>
            <scope>import</scope>   <!-- alinea las versiones de todos los módulos -->
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>   <!-- @Testcontainers y @Container -->
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>      <!-- PostgreSQLContainer -->
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>  <!-- @ServiceConnection -->
        <scope>test</scope>
    </dependency>
</dependencies>

Tres notas. El BOM evita mezclar versiones entre junit-jupiter, postgresql y los demás módulos, que es un origen habitual de errores raros; Spring Boot ya gestiona la versión de Testcontainers, así que declarar el BOM es opcional pero recomendable si añades módulos que Boot no conoce. spring-boot-testcontainers es la pieza de Spring Boot 3.1+ que aporta @ServiceConnection y el soporte de desarrollo del apartado 11. Y todo va con <scope>test</scope>: nada de esto se empaqueta.

  1. La primera prueba con @Container

package com.ciclourbana.alquileres;

@SpringBootTest
@Testcontainers            // gestiona el ciclo de vida de los @Container
class AlquilerRepositorioPostgresIT {

    @Container
    @ServiceConnection     // conecta el DataSource automáticamente (apartado 5)
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired private AlquilerRepositorio alquilerRepositorio;

    @Test
    void seConectaAUnPostgresRealYNoAUnaBaseEnMemoria() {
        assertThat(postgres.isRunning()).isTrue();
        assertThat(alquilerRepositorio.count()).isNotNegative();
    }
}

Los detalles importan más de lo que parece:

  • @Testcontainers es la extensión de JUnit 5 que arranca y para los campos @Container.
  • static cambia el ciclo de vida por completo: un campo @Container estático arranca una vez para toda la clase; uno de instancia, una vez por cada método de prueba. Con PostgreSQL, la segunda opción multiplica el tiempo por el número de pruebas y casi nunca compensa.
  • postgres:16-alpine: la etiqueta se fija siempre, y coincidiendo con la versión de producción. Usar latest es garantizar que un día la construcción falla sola.
  • El sufijo IT hace que la ejecute Failsafe en ./mvnw verify y no Surefire en ./mvnw test (06-01). Es lo que mantiene el ciclo corto en segundos.

  1. @DynamicPropertySource frente a @ServiceConnection

El contenedor arranca con una URL desconocida de antemano. Hay dos maneras de que Spring la use.

La forma clásica, con el @DynamicPropertySource que 06-04 dejó preparado:

@DynamicPropertySource
static void configurarFuenteDeDatos(DynamicPropertyRegistry registro) {
    registro.add("spring.datasource.url", postgres::getJdbcUrl);
    registro.add("spring.datasource.username", postgres::getUsername);
    registro.add("spring.datasource.password", postgres::getPassword);
}

Funciona porque el método se ejecuta después de arrancar el contenedor y antes de crear el contexto, y porque registra proveedores (Supplier), no valores: se evalúan en el momento justo.

La forma moderna (Spring Boot 3.1+) es una sola anotación:

@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource @ServiceConnection
Código Tres o más líneas por servicio Una anotación
Nombres de propiedad Escritos a mano: un error tipográfico se descubre tarde Los resuelve Spring Boot
Otros servicios (Redis, Kafka, RabbitMQ) Hay que conocer las claves de cada uno Mismo mecanismo para todos
Ajustes finos (parámetros extra del pool) Sí Combinable con @DynamicPropertySource
Disponible desde Siempre Spring Boot 3.1

La recomendación del curso es @ServiceConnection, y @DynamicPropertySource queda para lo que no es una conexión de servicio: la URL de una API falsa, una bandera de configuración calculada, un directorio temporal. Por dentro, @ServiceConnection funciona con ConnectionDetails, el mismo mecanismo que la autoconfiguración de 02-06 consulta antes de mirar las propiedades.

  1. Contenedor compartido: PruebaIntegracionBase

Un contenedor por clase de prueba parece limpio y arruina la suite: veinte clases son veinte arranques de PostgreSQL, entre uno y tres segundos cada uno, más veinte contextos de Spring distintos si cada clase declara su propia configuración.

La solución es el patrón de contenedor estático compartido, y encaja exactamente con la clase base que 06-04 recomendaba para no fragmentar la caché de contextos:

package com.ciclourbana;

@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureMockMvc
public abstract class PruebaIntegracionBase {

    /*
     * Estático y SIN @Container: no queremos que la extensión lo pare al
     * terminar cada clase. Se arranca una vez en el bloque static y vive
     * hasta que muere la JVM; Ryuk se encarga de borrarlo si algo falla.
     */
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("ciclourbana")
                    .withUsername("ribalta")
                    .withPassword("ribalta");

    static {
        POSTGRES.start();
    }

    @DynamicPropertySource
    static void propiedades(DynamicPropertyRegistry registro) {
        registro.add("spring.datasource.url", POSTGRES::getJdbcUrl);
        registro.add("spring.datasource.username", POSTGRES::getUsername);
        registro.add("spring.datasource.password", POSTGRES::getPassword);
    }
}

Y todas las pruebas de integración heredan:

class AlquilerFlujoCompletoIT extends PruebaIntegracionBase {

    @Autowired private MockMvc mockMvc;
    // ... sin una sola anotación de contenedores
}
Contenedor por clase Contenedor compartido
Arranques de PostgreSQL Uno por clase Uno por ejecución
Suite de 20 clases +40 s solo en contenedores +2 s
Aislamiento de datos Total Requiere una estrategia (apartado 10)
Contextos de Spring Riesgo de uno por clase Uno, gracias a la clase base

Por qué el bloque static en vez de @Container. La extensión @Testcontainers para un contenedor static al terminar la clase; heredado, eso significaría pararlo y arrancarlo con cada subclase. Arrancándolo a mano en el bloque estático, la JVM lo mantiene durante toda la ejecución de la suite y Ryuk garantiza la limpieza. Es el detalle que separa una suite de dos minutos de una de doce.

Con @ServiceConnection el patrón es aún más corto, porque desaparece el @DynamicPropertySource; a cambio, la anotación exige el campo @Container, así que la variante habitual es declarar el contenedor en un @TestConfiguration importado por la clase base, que es justo lo que hace el apartado 11.

  1. Reutilización con withReuse

Se puede ir un paso más allá y conservar el contenedor entre ejecuciones de la suite: la primera vez arranca, y las siguientes se reaprovecha el que ya está en marcha.

static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine")
                .withReuse(true);
# ~/.testcontainers.properties  (en el HOME del desarrollador, NO en el repositorio)
testcontainers.reuse.enable=true

Que el interruptor esté en el fichero personal y no en el proyecto es deliberado: la reutilización es una comodidad de desarrollo, no una configuración compartida. Sus condiciones y sus riesgos:

  • Sin testcontainers.reuse.enable=true en el fichero del usuario, withReuse(true) se ignora.
  • El contenedor no se borra al terminar: sigue consumiendo memoria hasta que se pare a mano.
  • Los datos persisten entre ejecuciones. Es la ventaja —arranque instantáneo— y el peligro: una prueba que dependa de que la tabla esté vacía empezará a fallar de forma intermitente.
  • En integración continua se desactiva siempre. Cada construcción debe partir de cero, y el agente es efímero de todas formas.

  1. Verificar Flyway de verdad

Este es el apartado que justifica toda la lección. En el perfil de prueba de 06-04 desactivamos Flyway y dejamos que Hibernate creara el esquema; ahora hacemos lo contrario, que es lo que hace producción:

# src/test/resources/application-integracion.yml
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
  jpa:
    hibernate:
      ddl-auto: validate      # EXACTAMENTE como en producción (04-08)
@ActiveProfiles("integracion")
class MigracionesFlywayIT extends PruebaIntegracionBase {

    @Autowired private Flyway flyway;
    @Autowired private JdbcTemplate jdbc;

    @Test
    void aplicaLasSeisMigracionesSobrePostgresLimpio() {
        MigrationInfo[] aplicadas = flyway.info().applied();

        assertThat(aplicadas).hasSize(6)
                .extracting(i -> i.getVersion().getVersion())
                .containsExactly("1", "2", "3", "4", "5", "6");
        assertThat(aplicadas).allSatisfy(
                i -> assertThat(i.getState().isFailed()).isFalse());
    }

    @Test
    void dejaLasCuatroEstacionesDeRibaltaQueCargaLaMigracionV2() {
        List<String> nombres = jdbc.queryForList(
                "select nombre from estaciones order by nombre", String.class);

        assertThat(nombres).containsExactly(
                "Estación Norte", "Parque del Río", "Plaza Mayor", "Universidad");
    }

    @Test
    void creaElIndiceParcialDeIncidenciasNoResueltasDeLaV3() {
        // Un índice parcial: PostgreSQL lo soporta, H2 no. Aquí sí se puede comprobar.
        assertThat(jdbc.queryForObject(
                "select indexdef from pg_indexes where indexname = 'idx_incidencias_abiertas'",
                String.class)).contains("WHERE");
    }
}

Y la prueba más valiosa del módulo, que cabe en dos líneas:

@Test
void elEsquemaDeLasMigracionesCoincideConLasEntidades() {
    // Si esta prueba pasa, es que ddl-auto: validate no protestó al crear el
    // contexto sobre el esquema que dejan V1..V6. Una entidad con un campo
    // que ninguna migración creó habría hecho fallar el arranque.
    assertThat(true).isTrue();
}

Parece una broma y es exactamente lo contrario. Con ddl-auto: validate, el contexto no arranca si una entidad no encaja con el esquema, así que el simple hecho de que la clase se ejecute demuestra la alineación. Es la prueba que detecta el escenario que abría esta lección: alguien añade @Column private String observaciones; a Incidencia y olvida la migración V7. Con H2 y create-drop, todo verde; aquí, la construcción se detiene con Schema-validation: missing column [observaciones].

Si prefieres un aserto explícito en lugar de un isTrue() que parece decorativo, la forma honesta es comprobar el esquema:

@Test
void laTablaIncidenciasTieneLasColumnasQueLaEntidadDeclara() {
    assertThat(jdbc.queryForList(
            "select column_name from information_schema.columns where table_name='incidencias'",
            String.class))
        .contains("id", "bicicleta_id", "tipo", "descripcion", "estado",
                  "nivel_detectado", "denuncia_policial", "creado_en", "version");
}

  1. Las consultas que H2 no soporta

Con PostgreSQL real, las consultas nativas de 04-06 pasan a ser comprobables:

class ConsultasNativasIT extends PruebaIntegracionBase {

    @Autowired private AlquilerRepositorio alquilerRepositorio;
    @Autowired private TestEntityManager em;

    @Test
    void buscarCarosUsaSqlNativoDePostgresYFiltraPorImporte() {
        em.persist(unAlquiler().finalizadoConImporte("3.50").construir());
        em.persist(unAlquiler().finalizadoConImporte("12.80").construir());
        em.flush();

        List<Alquiler> caros = alquilerRepositorio.buscarCaros(new BigDecimal("10.00"));

        assertThat(caros).hasSize(1)
                .first().extracting(Alquiler::getImporte)
                .isEqualTo(new BigDecimal("12.80"));
    }

    @Test
    void laBusquedaPorNombreIgnoraMayusculasYAcentosComoEnRibalta() {
        // ILIKE es de PostgreSQL: en H2 esta consulta ni siquiera compila
        assertThat(estacionRepositorio.buscarPorNombreAproximado("plaza"))
                .extracting(Estacion::getNombre).containsExactly("Plaza Mayor");
    }

    @Test
    void laSecuenciaAsignaIdentificadoresCoherentesConAllocationSize50() {
        Estacion a = em.persistFlushFind(EstacionesDePrueba.plazaMayor());
        Estacion b = em.persistFlushFind(EstacionesDePrueba.universidad());

        // Con allocationSize=50 e INCREMENT BY 50 los ids son consecutivos en
        // memoria; si la migración declara INCREMENT BY 1, aparecen huecos de 50.
        assertThat(b.getId()).isEqualTo(a.getId() + 1);
    }
}

La tercera es especialmente interesante porque comprueba la coherencia entre dos ficheros que nadie relaciona: el allocationSize = 50 de la anotación en Estacion y el INCREMENT BY de la secuencia en V1__crear_esquema_inicial.sql. Un desajuste ahí no rompe nada visiblemente: simplemente hace que los identificadores salten de cincuenta en cincuenta o, peor, que dos instancias de la aplicación asignen el mismo. Es un fallo que solo se ve con el motor real.

  1. Aislamiento de datos entre pruebas

Con un contenedor compartido, los datos que deja una prueba los ve la siguiente. Hay tres estrategias:

Estrategia Cómo Ventajas Inconvenientes
Transacción con rollback (@Transactional sobre la prueba) Todo se deshace al terminar Rapidísimo, cero código de limpieza No sirve si el código bajo prueba gestiona sus propias transacciones; no prueba el commit; oculta restricciones diferidas
Limpieza explícita (@Sql con un TRUNCATE, o un @BeforeEach que borra) Estado conocido antes de cada prueba Funciona con cualquier código; prueba el commit de verdad Hay que mantener el orden de borrado por las claves ajenas
Contenedor por clase Un @Container no compartido Aislamiento perfecto Segundos por clase: solo para casos excepcionales

La política de CicloUrbana, y el porqué:

  • @Transactional por defecto en las pruebas que solo leen o cuya escritura no involucra transacciones propias. Es lo que hace @DataJpaTest y funciona bien.
  • Limpieza explícita en las pruebas de flujo completo por HTTP, porque ahí el commit ocurre dentro del servidor y el rollback de la prueba no lo alcanza. Es un error clásico: poner @Transactional sobre una prueba con MockMvc que escribe, ver que los datos persisten y no entender por qué.
@Sql(scripts = "/datos/limpiar-ribalta.sql", executionPhase = BEFORE_TEST_METHOD)
class AlquilerFlujoCompletoIT extends PruebaIntegracionBase { /* ... */ }
-- limpiar-ribalta.sql: el orden importa por las claves ajenas
TRUNCATE TABLE incidencias, alquileres, bicicletas, estaciones, usuarios
    RESTART IDENTITY CASCADE;

RESTART IDENTITY reinicia las secuencias, de modo que los identificadores son predecibles entre pruebas; CASCADE evita tener que acertar el orden exacto. Y una advertencia de peso: este guion vive en src/test/resources y jamás debe poder ejecutarse contra otra base de datos. Que la URL la fabrique un contenedor efímero es, además de comodidad, una protección.

  1. Testcontainers en desarrollo local

Spring Boot 3.1 dio a Testcontainers un segundo uso que no tiene que ver con probar: arrancar la aplicación en desarrollo con sus dependencias reales, sin instalar PostgreSQL ni mantener un docker-compose a mano.

// src/test/java/com/ciclourbana/ConfiguracionContenedores.java
@TestConfiguration(proxyBeanMethods = false)
public class ConfiguracionContenedores {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresDeDesarrollo() {
        return new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);
    }
}

// src/test/java/com/ciclourbana/TestCicloUrbanaApplication.java
public class TestCicloUrbanaApplication {

    public static void main(String[] args) {
        SpringApplication.from(CicloUrbanaApplication::main)
                .with(ConfiguracionContenedores.class)
                .run(args);
    }
}
./mvnw spring-boot:test-run

Con esa orden, la aplicación arranca con su main real, pero el contexto incluye además el contenedor: Flyway aplica V1…V6 sobre un PostgreSQL 16 limpio y http://localhost:8080/api/v1/estaciones responde con las cuatro estaciones de Ribalta. Un desarrollador nuevo clona el repositorio y trabaja sin instalar ninguna base de datos. Y como es un @Bean en un @TestConfiguration, la misma clase puede importarse desde PruebaIntegracionBase y compartir la definición del contenedor entre desarrollo y pruebas.

La alternativa, también de Spring Boot 3.1, es el soporte de Docker Compose:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-docker-compose</artifactId>
    <optional>true</optional>
</dependency>

Con un compose.yaml en la raíz, la aplicación levanta los servicios al arrancar y los para al terminar, conectándolos igual que @ServiceConnection.

Testcontainers en desarrollo spring-boot-docker-compose
Dónde se define Código Java (@TestConfiguration) compose.yaml
Se comparte con las pruebas Sí, la misma clase No directamente
Lo usan otras herramientas No Sí: docker compose up sin Java
Control fino (esperas, inicialización) Total, en Java El de Compose

Ninguna es superior: si el equipo ya vive con docker compose, el segundo encaja mejor; si lo que quieres es que las pruebas y el desarrollo compartan exactamente la misma definición, el primero.

  1. Otros contenedores útiles

Testcontainers tiene módulos para casi todo, y CicloUrbana los irá necesitando:

Módulo Para qué Dónde aparecerá
postgresql La base de datos real Esta lección
redis (o GenericContainer con redis:7-alpine) Caché distribuida La caché de 09-02
WireMock / MockServer Simular una API externa (el proveedor de pagos de Ribalta) Comunicación entre servicios, 07-06
kafka Mensajería entre servicios 07-05 y 07-06
localstack AWS en local: S3, SQS, DynamoDB Despliegue en AWS, 08-03
selenium / Playwright Pruebas de extremo a extremo con navegador Fuera del alcance de este curso
GenericContainer Cualquier imagen sin módulo propio Comodín

La misma idea se aplica a todos: @Container más @ServiceConnection cuando el módulo lo soporte, o @DynamicPropertySource para el resto —por ejemplo, la URL de un WireMock que sustituya al servicio de pagos.

  1. Coste, integración continua y depuración

Qué cuesta. La descarga de la imagen ocurre una vez (unos 80 MB para postgres:16-alpine); el arranque, entre uno y tres segundos; cada prueba, lo que tarde su SQL. Con el contenedor compartido del apartado 6, veinte clases de integración añaden unos pocos segundos al total.

Cuándo NO usar Testcontainers:

  • Para probar lógica de negocio pura. CalculadoraTarifa con un contenedor sería absurdo.
  • Para probar la capa web. @WebMvcTest con @MockitoBean (06-04) es dos órdenes de magnitud más rápido.
  • Para consultas triviales que H2 resuelve igual. @DataJpaTest sigue siendo válido para el grueso de los repositorios.
  • Donde no hay Docker. En ese caso, o se omite el tramo o no se pueden ejecutar esas pruebas.

La proporción sana vuelve a ser la pirámide de 06-01: cientos de unitarias, decenas de rodajas y un puñado de pruebas con contenedor que cubran lo que solo el motor real revela —migraciones, SQL nativo, secuencias, bloqueos, tipos.

En integración continua hace falta un ejecutor con Docker disponible. La orden es la de siempre:

./mvnw verify      # Surefire ejecuta *Test; Failsafe, las *IT con contenedor

Y cuando falta Docker, la forma elegante de no romper la construcción es la suposición de 06-02:

@BeforeAll
static void requiereDocker() {
    assumeTrue(DockerClientFactory.instance().isDockerAvailable(),
               "Docker no disponible: se omiten las pruebas con contenedor");
}

La configuración concreta del ejecutor —Docker en Docker, cachés de imágenes, servicios auxiliares— es materia de 08-05, Integración y Entrega Continua.

Depuración. Cuando una prueba con contenedor falla de forma incomprensible, la causa suele estar en los registros del propio servicio:

static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine")
                .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("postgres")))
                .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(60)));

// Y en cualquier punto de la prueba:
System.out.println(POSTGRES.getLogs());

withLogConsumer redirige la salida del contenedor a tu registro —donde aparecerá el syntax error at or near que H2 nunca habría dado—, y waitingFor ajusta la estrategia de espera cuando un agente lento hace que el arranque exceda el tiempo por defecto.

Errores Comunes y Consejos

Usar latest como etiqueta de imagen. La construcción funciona hoy y falla dentro de tres meses sin que nadie haya tocado nada. Fija la versión, y que sea la de producción.

Un @Container de instancia en lugar de static. Arranca un PostgreSQL por cada método de prueba. Es la causa número uno de suites de contenedores insoportablemente lentas.

Poner @Container sobre el contenedor de la clase base. La extensión lo para al terminar cada subclase y lo vuelve a arrancar. Arráncalo en el bloque static y deja que Ryuk limpie.

@Transactional sobre una prueba que escribe por HTTP. El commit ocurre dentro del servidor y el rollback de la prueba no lo alcanza: los datos persisten y la siguiente prueba falla. Con MockMvc que escribe, limpieza explícita.

Suponer que el puerto es 5432. Testcontainers publica uno aleatorio: usa getJdbcUrl() o @ServiceConnection, nunca una URL escrita a mano.

Dejar Flyway desactivado en las pruebas con contenedor. Se pierde justo lo que se venía a comprobar. En el perfil de integración, flyway.enabled: true y ddl-auto: validate.

withReuse(true) en integración continua. Contamina construcciones sucesivas con datos anteriores y produce fallos intermitentes imposibles de reproducir. Es una comodidad local, y por eso el interruptor vive en el HOME del desarrollador.

Consejo: una imagen, una clase base, un contenedor. Toda prueba *IT de CicloUrbana hereda de PruebaIntegracionBase. Un solo arranque de PostgreSQL, un solo contexto de Spring y una suite que sigue durando lo que debe.

Consejo: cuando una prueba pase con H2 y falle con PostgreSQL, celébralo. Acabas de encontrar un fallo de producción antes de que llegara a Ribalta. Ese es exactamente el trabajo de esta lección.

Ejercicios

Ejercicio 1

Crea PruebaIntegracionBase según el apartado 6 y migra a ella la SeguridadAlquileresIT de 06-04, cambiando además el perfil para que Flyway se aplique y ddl-auto sea validate. Comprueba con el registro de la caché de contextos que sigue habiendo un solo contexto y que el contenedor arranca una sola vez aunque haya varias clases *IT. Documenta el tiempo antes y después.

Ejercicio 2

Escribe MigracionesFlywayIT con cuatro comprobaciones: que se aplican seis migraciones sin fallos; que V2 deja las cuatro estaciones de Ribalta con sus capacidades correctas (24, 30, 18, 36); que existe el índice sobre alquileres(inicio) que crea V4; y que la restricción de unicidad sobre estaciones.nombre es efectiva —insertando un duplicado y esperando la excepción—. Después provoca deliberadamente el fallo: añade un campo observaciones a la entidad Incidencia sin escribir la migración V7, ejecuta y anota el mensaje exacto que produce.

Ejercicio 3

Un compañero propone eliminar todas las pruebas con H2 y ejecutarlo absolutamente todo con Testcontainers, «porque así probamos contra lo real». Escribe una respuesta razonada de tres párrafos: qué tiene de acertada la propuesta, qué problemas concretos crearía en CicloUrbana, y cuál es el reparto que recomiendas, con la lista de qué tipo de prueba usa cada motor y por qué.

Soluciones

Solución 1

La clase base es la del apartado 6, con el perfil de integración añadido; la prueba migrada queda así:

@ActiveProfiles("integracion")
class SeguridadAlquileresIT extends PruebaIntegracionBase {

    @Autowired private MockMvc mockMvc;

    @Test
    @WithUserDetails("[email protected]")
    void elCiudadanoNoPuedeFinalizarElAlquilerDeOtro() throws Exception {
        mockMvc.perform(post("/api/v1/alquileres/9/finalizar")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"estacionDestinoId\": 2}"))
                .andExpect(status().isForbidden());
    }
}

Un cambio importante que el enunciado esconde: con Flyway activo, los usuarios y alquileres de prueba ya no los crea Hibernate, sino que deben venir de una migración de datos de prueba o de un @Sql. Es más trabajo y a cambio se prueba el mismo esquema que Ribalta.

Cifras típicas en un portátil moderno, y lo que enseñan:

Escenario Contenedores Contextos Tiempo
Antes: cada *IT con su @Container y sus anotaciones 4 4 ~38 s
Después: PruebaIntegracionBase 1 1 ~11 s

El registro debe mostrar [size = 1, hitCount = 3, missCount = 1] con cuatro clases, y una sola línea Creating container for image: postgres:16-alpine en toda la ejecución. Si aparece más de una, el contenedor no se está compartiendo, y la causa casi siempre es un @Container olvidado en una subclase o una anotación que cambia la clave de caché.

Solución 2

@ActiveProfiles("integracion")
class MigracionesFlywayIT extends PruebaIntegracionBase {

    @Autowired private Flyway flyway;
    @Autowired private JdbcTemplate jdbc;

    @Test
    void aplicaLasSeisMigracionesSinFallos() {
        assertThat(flyway.info().applied()).hasSize(6)
                .allSatisfy(i -> assertThat(i.getState().isFailed()).isFalse());
    }

    @Test
    void laV2DejaLasCuatroEstacionesDeRibaltaConSuCapacidad() {
        assertThat(jdbc.queryForList(
                "select nombre, capacidad from estaciones order by nombre"))
            .extracting(f -> f.get("nombre"), f -> f.get("capacidad"))
            .containsExactly(
                    tuple("Estación Norte", 30), tuple("Parque del Río", 18),
                    tuple("Plaza Mayor", 24), tuple("Universidad", 36));
    }

    @Test
    void laV4CreaElIndiceSobreLaFechaDeInicioDeLosAlquileres() {
        assertThat(jdbc.queryForList(
                "select indexname from pg_indexes where tablename = 'alquileres'",
                String.class)).contains("idx_alquileres_inicio");
    }

    @Test
    void laRestriccionDeUnicidadDelNombreDeEstacionEsEfectiva() {
        assertThatThrownBy(() -> jdbc.update(
                "insert into estaciones (id, nombre, direccion, capacidad) "
                        + "values (99, 'Plaza Mayor', 'Otra dirección', 10)"))
                .isInstanceOf(DuplicateKeyException.class);
    }
}

Comentario sobre la cuarta: comprueba una garantía que ninguna prueba anterior podía dar. Que EstacionService lance EstacionDuplicadaException es una comprobación de aplicación, y no protege de dos peticiones simultáneas que consulten a la vez y ambas encuentren libre el nombre. La restricción UNIQUE de la base de datos sí, y aquí queda verificada. Que la excepción que llega sea DuplicateKeyException —traducida por el @Repository de 02-01— y no una SQLException es un detalle que también depende del motor real.

El fallo provocado produce, al crear el contexto, algo como:

org.hibernate.tool.schema.spi.SchemaManagementException:
Schema-validation: missing column [observaciones] in table [incidencias]

Y ese mensaje es el resultado que se buscaba: el error aparece en la construcción, no en el despliegue. Con H2 y create-drop, la columna se habría creado sola a partir de la entidad, las pruebas habrían pasado en verde y el fallo habría esperado al arranque en Ribalta, donde validate sí encuentra la tabla real.

Solución 3

Qué tiene de acertada. El fondo del argumento es correcto y es la tesis de esta lección: probar contra un motor distinto del de producción es probar otra cosa. Todo lo que dependa del dialecto, de los tipos, de las secuencias, de los índices o del comportamiento transaccional solo es verificable con PostgreSQL, y una suite que no incluya ese tramo tiene un punto ciego peligroso, precisamente porque está pintado de verde.

Qué problemas crearía. El primero, el tiempo: sustituir cada @DataJpaTest por una prueba con contenedor convierte una suite de segundos en una de minutos, y una suite lenta deja de ejecutarse durante el desarrollo —el mecanismo exacto del cono de helado de 06-01—. El segundo, la dependencia de Docker: quien no lo tenga, o el ejecutor que no lo permita, se queda sin ninguna prueba, no solo sin las de integración. El tercero, el aislamiento: con un contenedor compartido hay que gestionar la limpieza en cada prueba, y con uno por clase el coste se dispara. Y el cuarto, más de fondo: la mayoría de las pruebas de repositorio de CicloUrbana verifican consultas derivadas triviales que se comportan igual en cualquier motor; ejecutarlas contra PostgreSQL no detecta ni un fallo más y cuesta cien veces más.

El reparto recomendado.

Tipo de prueba Motor Motivo
Unitarias (CalculadoraTarifa, AlquilerService con mocks) Ninguno No tocan la base de datos
@WebMvcTest del contrato de la API Ninguno El servicio está simulado
@DataJpaTest de consultas derivadas y JPQL sencillo H2, rápido Se comportan igual en ambos motores
Migraciones Flyway y ddl-auto: validate PostgreSQL Es literalmente lo que se está comprobando
SQL nativo, ILIKE, índices parciales, jsonb PostgreSQL H2 no los soporta
Secuencias y allocationSize PostgreSQL La coherencia entidad-esquema solo se ve aquí
Bloqueos y concurrencia (@Lock de 04-07) PostgreSQL H2 no reproduce el comportamiento
Flujo completo por HTTP de los caminos críticos PostgreSQL Es la prueba más cercana a producción

En una frase: H2 para lo que es común a todos los motores, PostgreSQL para lo que es propio de PostgreSQL. Y una salvaguarda que hace innecesario el debate: si una prueba con H2 pasa y su equivalente con contenedor falla, la respuesta correcta no es discutir, es mover esa prueba al tramo con contenedor, porque acaba de demostrar que pertenece a él.

Conclusión

El módulo 6 se cierra con CicloUrbana probada de arriba abajo. Sabes por qué H2 no basta —dialecto, tipos, funciones, índices parciales, secuencias, bloqueos, mensajes de error— y, sobre todo, por qué su peligro no es que falle, sino que pase en verde mientras el código fallaría en Ribalta. Conoces Testcontainers por dentro: el demonio de Docker, el contenedor efímero con su puerto aleatorio, la estrategia de espera y el vigilante Ryuk que limpia si la JVM muere. Sabes declarar las dependencias con el BOM, escribir la primera prueba con @Testcontainers y @Container, y conectar la fuente de datos de las dos formas posibles, con la preferencia clara por @ServiceConnection y @DynamicPropertySource reservado para lo que no es una conexión de servicio. Dominas el patrón que decide el rendimiento de todo el tramo —contenedor static arrancado en la clase base PruebaIntegracionBase, un solo arranque y un solo contexto de Spring— y sabes cuándo withReuse(true) ayuda en local y por qué jamás debe activarse en integración continua.

Has convertido en asertos la última zona de fe del proyecto: las migraciones V1…V6 aplicadas sobre un PostgreSQL limpio, las cuatro estaciones que carga V2, el índice parcial de V3 que H2 no sabe crear, la restricción UNIQUE que ninguna comprobación de aplicación puede sustituir y, la más valiosa de todas, ddl-auto: validate haciendo fallar la construcción cuando una entidad se desalinea del esquema —el fallo que antes esperaba al despliegue—. Has probado las consultas nativas de 04-06 y la coherencia entre el allocationSize de la entidad y el INCREMENT BY de la secuencia. Sabes aislar los datos eligiendo entre rollback, limpieza explícita y contenedor por clase, y por qué @Transactional no sirve cuando la escritura ocurre por HTTP. Y has descubierto que los mismos contenedores arrancan la aplicación en desarrollo con ./mvnw spring-boot:test-run, de modo que quien clone CicloUrbana trabaje sin instalar una base de datos, con spring-boot-docker-compose como alternativa y con Redis, WireMock, Kafka y LocalStack esperando su turno en los módulos siguientes.

Mira lo que ha cambiado desde el principio del módulo. CicloUrbana pasó de tener una sola prueba vacía generada por Initializr a una suite con forma de pirámide: cientos de unitarias con JUnit 5 y AssertJ que fijan las tarifas de Ribalta al céntimo y se ejecutan en segundos; dobles de Mockito que fuerzan la bicicleta descargada, la estación llena y la caída del repositorio; rodajas que verifican cada código de estado, cada ProblemDetail y cada campo del JSON público; la matriz de acceso completa del módulo 5 escrita como una tabla donde Marta recibe su 403 al tocar el alquiler ajeno y su 200 al finalizar el suyo; y un tramo final contra PostgreSQL 16 real que garantiza que el esquema, las consultas y las migraciones se comportan como en producción. La pregunta con la que arrancaba el módulo —«¿cómo sabemos que funciona?»— tiene por fin una respuesta que no depende de la memoria de nadie: ./mvnw verify.

Y sin embargo CicloUrbana todavía no está lista para vivir en el mundo. Es correcta, pero no es operable. Nadie puede preguntarle si está sana antes de mandarle tráfico, ni si la base de datos responde, ni qué versión está desplegada. No distingue entornos: la misma configuración vale para el portátil de un desarrollador y para el servidor del ayuntamiento, con el mismo nivel de registro y los mismos secretos. No ejecuta nada por su cuenta: nadie caduca los alquileres olvidados de madrugada, nadie recalcula la ocupación de las estaciones cada minuto, y el correo de confirmación se envía en el mismo hilo que atiende la petición, haciendo esperar al ciudadano. Y sigue siendo un JAR que hay que arrancar a mano en una máquina con Java 21 instalado. El módulo 7, Funciones Avanzadas de Spring Boot, resuelve todo eso: Actuator y sus sondas de salud, los perfiles que separan desarrollo de producción, las tareas programadas y la ejecución asíncrona, el empaquetado en Docker con imágenes en capas, y la puerta de entrada a los microservicios y a la comunicación tolerante a fallos entre servicios. La red de Ribalta funciona y está demostrado; ahora hay que ponerla en marcha de verdad.

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