# Día 20: Testing de Integración Real con Testcontainers y PostgreSQL

> Pruebas de Extremo a Extremo sin Depender de Simuladores H2 en Memoria

- Plan: [JavaMastery](https://aprende-java.webs-economicas.es/), día 20 de 30
- Fase 5: Testing de Integración, APIs y Carga
- Dedicación: 4h
- Profundidad: para dominar
- Revisado: 2026-10-29
- Versión interactiva: https://aprende-java.webs-economicas.es/dias/testcontainers-postgresql/

## Al terminar este día sabrás

- Justificar por qué los tests de integración deben usar la misma base de datos que producción.
- Levantar PostgreSQL en tests con Testcontainers y `@ServiceConnection`.
- Elegir entre test de rebanada y test completo según lo que quieres verificar.
- Mantener rápida una suite grande reutilizando contenedores y contextos.


## Conceptos

### 1. El Peligro de los Falsos Positivos con H2 en Memoria

H2 tiene dialectos, funciones de bloqueo, tipos de datos JSON y comportamientos transaccionales diferentes a PostgreSQL o MySQL. Un test que pasa verde en H2 puede fallar en producción.

**Cómo funciona por dentro.**

H2 en memoria es rápido y no necesita instalación, pero no es PostgreSQL. Incluso en modo de compatibilidad difiere en tipos (`JSONB`, arrays, `UUID`, rangos), funciones (`ON CONFLICT`, `ILIKE`, funciones de ventana avanzadas, `generate_series`), en cómo trata mayúsculas, en el bloqueo y en los niveles de aislamiento.

El resultado son falsos positivos: tests en verde con consultas que fallan en producción, y falsos negativos: código válido para PostgreSQL que no puedes probar porque H2 no lo entiende. Además, tus migraciones Flyway escritas para PostgreSQL a menudo no se ejecutan en H2, así que acabas manteniendo dos esquemas.

Testcontainers arranca la imagen oficial de PostgreSQL (la misma versión que producción) en Docker en pocos segundos. La confianza que da un test contra el motor real compensa con creces el coste de arranque.

*Consultas que H2 no reproduce fielmente*

```sql
-- Upsert: en H2 la sintaxis y el comportamiento difieren
INSERT INTO etiqueta (nombre, usos) VALUES ('java', 1)
ON CONFLICT (nombre) DO UPDATE SET usos = etiqueta.usos + 1;

-- JSONB con operadores e índice GIN
SELECT id FROM tarea WHERE metadatos @> '{"origen": "jira"}';

-- Bloqueo pesimista saltando filas bloqueadas (colas de trabajo)
SELECT id FROM trabajo WHERE estado = 'PENDIENTE'
ORDER BY creado_en LIMIT 10
FOR UPDATE SKIP LOCKED;

-- Búsqueda sin distinguir mayúsculas
SELECT * FROM tarea WHERE titulo ILIKE '%spring%';
```

- **En una línea:** `Levantar un contenedor real de PostgreSQL 16 con Testcontainers.`
- **Por qué importa:** Garantiza que el software se pruebe exactamente contra el mismo motor de base de datos que corre en producción.
- **Error típico:** Confiar en que tu app funciona porque los tests pasan en H2 y encontrarse fallos de sintaxis SQL al desplegar.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** Testing de Integración, Docker, PostgreSQL

### 2. @ServiceConnection: Configuración Automática en Spring Boot 3.1+

Anotación que detecta el contenedor de Testcontainers e inyecta dinámicamente las credenciales y URLs de conexión en Spring Boot sin escribir configuración manual.

**Cómo funciona por dentro.**

Antes de Spring Boot 3.1, después de arrancar el contenedor había que pasar su URL, usuario y contraseña al contexto de Spring con `@DynamicPropertySource`, porque el puerto es aleatorio. Con `@ServiceConnection`, Spring Boot reconoce el tipo de contenedor (PostgreSQL, MySQL, Kafka, RabbitMQ, Redis, MongoDB...) y crea automáticamente los `ConnectionDetails` que usa la autoconfiguración.

Además, puedes declarar los contenedores como beans en una clase `@TestConfiguration`. Así se comparten entre todos los tests que importan esa configuración, y Spring gestiona su ciclo de vida junto al del contexto.

Esa misma configuración sirve para desarrollo local: con un `main` de test que use `SpringApplication.from(App::main).with(ContenedoresConfig.class)`, ejecutas la aplicación con todas sus dependencias levantadas en Docker, sin `docker-compose` manual.

*Contenedores como beans con @ServiceConnection*

```java
@TestConfiguration(proxyBeanMethods = false)
class ContenedoresConfig {

    @Bean
    @ServiceConnection                          // Boot crea url/usuario/contraseña del DataSource
    PostgreSQLContainer<?> postgres() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }

    @Bean
    @ServiceConnection
    KafkaContainer kafka() {
        return new KafkaContainer(DockerImageName.parse("apache/kafka:3.8.0"));
    }
}

@SpringBootTest
@Import(ContenedoresConfig.class)
class TareaFlujoCompletoIT {
    @Autowired TareaService servicio;

    @Test
    void creaYRecuperaUnaTarea() {
        var creada = servicio.crear(new CrearTareaRequest("Probar Testcontainers", null, 5));
        assertThat(servicio.obtener(creada.id()).titulo()).isEqualTo("Probar Testcontainers");
    }
}

// src/test/java/.../TestTaskFlowApplication.java: arrancar la app en local con los contenedores
public static void main(String[] args) {
    SpringApplication.from(TaskFlowApplication::main).with(ContenedoresConfig.class).run(args);
}
```

- **En una línea:** `@Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");`
- **Por qué importa:** Reduce decenas de líneas de código de infraestructura en las clases de prueba.
- **Error típico:** Escribir a mano métodos `@DynamicPropertySource` repetitivos en cada clase de test.
- **Java 17 frente a 21:** Disponible tanto en proyectos Java 17 como en Java 21.
- **Se conecta con:** Spring Boot 3.1+, Testcontainers, DX

### 3. Reutilización de Contenedores y Ciclo de Vida de Tests

Declarar los contenedores como campos estáticos (`static`) permite que un único contenedor de Docker atienda toda la batería de pruebas sin reiniciarse por cada método.

**Cómo funciona por dentro.**

Arrancar un contenedor cuesta unos segundos, y arrancar un contexto de Spring otros tantos. En una suite con cientos de clases de test, hacerlo en cada una es inviable. Hay dos cachés que conviene aprovechar.

Spring guarda en caché los contextos de test: si dos clases de test tienen exactamente la misma configuración (mismas anotaciones, mismos `@MockitoBean`, mismas propiedades), reutilizan el mismo contexto. Cada variación crea un contexto nuevo, así que conviene agrupar configuraciones en unas pocas clases base.

Para los contenedores, declararlos `static` hace que vivan durante toda la clase en lugar de cada método; el patrón singleton los comparte entre todas las clases de la JVM. En local, `testcontainers.reuse.enable=true` en `~/.testcontainers.properties` junto con `.withReuse(true)` deja el contenedor vivo entre ejecuciones, para que relanzar un test tarde menos de un segundo.

*Contenedor estático por clase y reutilización en local*

```java
@SpringBootTest
@Testcontainers
class TareaRepositoryIT {

    @Container                                  // static: uno para toda la clase
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withReuse(true);                       // solo tiene efecto si está habilitado en local

    @Autowired TareaRepository repo;

    @BeforeEach
    void limpiar() {
        repo.deleteAll();                       // el contenedor es compartido: limpia el estado
    }
}

// ~/.testcontainers.properties (solo en tu máquina, nunca en CI)
// testcontainers.reuse.enable=true
```

- **En una línea:** `static PostgreSQLContainer<?> postgres = ...`
- **Por qué importa:** Hace que las pruebas de integración con base de datos real corran en segundos en lugar de minutos.
- **Error típico:** Instanciar el contenedor en un campo no estático haciendo que Docker levante y apague un PostgreSQL por cada test.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** JUnit Lifecycle, Docker, Rendimiento CI/CD

### 4. Tests de Rebanada con @DataJpaTest y @WebMvcTest

Anotaciones de prueba que solo levantan una rebanada específica de Spring (solo JPA o solo la capa Web) sin instanciar todos los beans de la app.

**Cómo funciona por dentro.**

`@SpringBootTest` arranca la aplicación completa: todos los beans, la autoconfiguración y, si quieres, un servidor web real. Es la prueba más fiel y también la más lenta. Los tests de rebanada (slice) cargan solo una capa.

`@DataJpaTest` carga entidades, repositorios, `EntityManager` y Flyway, y envuelve cada test en una transacción que se revierte al final. Por defecto sustituye el `DataSource` por uno embebido, así que con Testcontainers hay que añadir `@AutoConfigureTestDatabase(replace = NONE)`. `@WebMvcTest(TareaController.class)` carga el controlador, los filtros, Jackson, la validación y el manejo de errores, con `MockMvc` para hacer peticiones sin servidor; los servicios se sustituyen con `@MockitoBean` (o `@MockBean` antes de Spring Boot 3.4).

Usa rebanadas para verificar una capa a fondo: consultas complejas en el repositorio o el contrato HTTP de un controlador (códigos, JSON, validación). Usa `@SpringBootTest` para unos pocos flujos completos.

*Rebanada web y rebanada de datos*

```java
@WebMvcTest(TareaController.class)
class TareaControllerTest {
    @Autowired MockMvc mvc;
    @MockitoBean TareaService servicio;          // Spring Boot 3.4+ (antes @MockBean)

    @Test
    void devuelve400ConErroresPorCampo() throws Exception {
        mvc.perform(post("/api/tareas")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"titulo": "", "prioridad": 99}
                    """))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errores[*].campo").value(containsInAnyOrder("titulo", "prioridad")));
    }
}

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(ContenedoresConfig.class)
class TareaRepositoryTest {
    @Autowired TareaRepository repo;

    @Test
    void buscaPendientesOrdenadasPorPrioridad() {
        // cada test se revierte al terminar: no hace falta limpiar
    }
}
```

- **En una línea:** `@DataJpaTest @AutoConfigureTestDatabase(replace = NONE)` contra el contenedor.
- **Por qué importa:** Permite probar consultas de repositorio o serializaciones JSON con máxima rapidez.
- **Error típico:** Usar siempre `@SpringBootTest` completo para probar una sola consulta de un repositorio.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** Testing Slices, Spring Boot Test, MockMvc

### 5. La Pirámide de Testing en Proyectos Empresariales

70% tests unitarios puros (milisegundos), 20% tests de integración con Testcontainers, 10% pruebas de extremo a extremo.

**Cómo funciona por dentro.**

La pirámide de tests propone muchas pruebas unitarias en la base (rápidas, baratas, aisladas), un número medio de pruebas de integración en el centro (componentes reales: base de datos, mensajería, HTTP) y pocas pruebas de extremo a extremo en la cúspide (el sistema completo desplegado, lentas y frágiles).

La idea de fondo no es la forma exacta sino el coste y la confianza: cuanto más arriba, más confianza da cada test y más caro es escribirlo, ejecutarlo y mantenerlo. En servicios backend con poca lógica de dominio y mucha integración, muchos equipos usan un «trofeo» con más peso en integración, gracias a que Testcontainers la ha abaratado.

Un reparto razonable para un microservicio Spring: tests unitarios para el dominio y los casos de uso, tests de rebanada para repositorios y controladores, unos pocos `@SpringBootTest` con Testcontainers para los flujos críticos y tests de contrato si otros equipos consumen tu API.

*Cómo se reparte en un proyecto real*

```text
src/test/java/com/acme/taskflow/
├── dominio/                        unitarios puros, sin Spring        (cientos, < 1 ms cada uno)
│   ├── TareaTest.java
│   └── PlanificadorSprintTest.java
├── aplicacion/                     casos de uso con dobles en memoria (decenas)
│   └── CompletarTareaServiceTest.java
├── adaptadores/
│   ├── web/TareaControllerTest.java          @WebMvcTest            (decenas)
│   └── persistencia/TareaRepositoryTest.java @DataJpaTest + PostgreSQL
└── TareaFlujoCompletoIT.java       @SpringBootTest + Testcontainers   (unos pocos)

./mvnw test     -> unitarios y rebanadas   (feedback en segundos)
./mvnw verify   -> además los *IT          (antes de cada push / en CI)
```

- **En una línea:** `Tests unitarios para lógica de negocio; Testcontainers para verificar SQL real y Flyway.`
- **Por qué importa:** Mantiene las suites de CI/CD rápidas y evita pipelines lentos que bloquean los despliegues.
- **Error típico:** Tener 0 tests unitarios y 100 tests lentos de integración que tardan 30 minutos.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** CI/CD, Calidad, Testing


## Sesión de 90 minutos

### Bloque 1: Configurar Testcontainers y Docker (0 - 25 min)

- **Qué estudiar:** Dependencias en pom.xml: `testcontainers` y `postgresql`.
- **Pregunta a responder:** ¿Cómo verificar que Docker está corriendo y accesible para Java?
- **Entregable:** Test básico que arranca un contenedor de PostgreSQL y verifica que está `isRunning()`.

### Bloque 2: Integración con Spring Boot (@ServiceConnection) (25 - 50 min)

- **Qué estudiar:** Anotación `@ServiceConnection` en Spring Boot 3.1+.
- **Pregunta a responder:** ¿Cómo evitar escribir `@DynamicPropertySource` a mano?
- **Entregable:** Test `@SpringBootTest` que inserta un registro y lo lee de PostgreSQL en Docker.

### Bloque 3: Verificación con Flyway (50 - 80 min)

- **Qué estudiar:** Ejecución automática de migraciones Flyway sobre el contenedor efímero.
- **Pregunta a responder:** ¿Cómo asegurar que el esquema completo se despliega en el test?
- **Entregable:** Suite de pruebas completa verificando persistencia real.

### Cierre y Reflexión (80 - 90 min)

- **Qué estudiar:** Repaso de la pirámide de testing.
- **Pregunta a responder:** ¿Qué porcentaje de tests en tu suite deben ser unitarios vs integración?
- **Entregable:** Progreso guardado en la app.


## Explícalo desde cero

### Por qué Testcontainers revolucionó el mundo de Java

- **Qué entender:** Antes de Testcontainers, para correr tests de integración necesitabas que cada desarrollador tuviera un PostgreSQL instalado y configurado a mano en su máquina con los mismos usuarios y puertos. Si alguien tenía otra versión, el test fallaba. Testcontainers automatiza esto con Docker: levanta una BD limpia y efímera en un puerto aleatorio y la destruye al terminar.
- **Qué suele explicarse mal:** Explicarlo como "un plugin de Maven", cuando en realidad es una librería Java que se comunica con la API de Docker mediante su socket local.
- **Ejemplo sencillo:** Alquilar un coche limpio en el aeropuerto para hacer tu viaje y devolverlo al terminar, en lugar de obligar a cada empleado a comprarse el mismo modelo de coche para ir a trabajar.
- **Señal de alerta:** Si intentas correr Testcontainers en una máquina sin el daemon de Docker iniciado.


## Preguntas

### Nivel 1 (Básico): ¿Por qué Testcontainers asigna puertos aleatorios en el host en lugar del puerto 5432 estándar de PostgreSQL?

Para evitar colisiones si el desarrollador ya tiene un PostgreSQL corriendo en su máquina local en el puerto 5432 y para permitir ejecutar múltiples builds de integración en paralelo.

**Respuesta razonada:** Para evitar colisiones. Si el contenedor publicara siempre el 5432 del host, chocaría con un PostgreSQL instalado en tu máquina, con otro proyecto ejecutando tests a la vez o con dos tests en paralelo en el mismo agente de integración continua. Con un puerto libre aleatorio, cualquier número de contenedores puede convivir.

Dentro del contenedor PostgreSQL sigue escuchando en 5432; Testcontainers mapea ese puerto a uno libre del host y lo expone con `getMappedPort(5432)` o directamente en `getJdbcUrl()`. Por eso nunca se escribe la URL a mano en los tests: se obtiene del contenedor, y con `@ServiceConnection` ni siquiera eso.

### Nivel 2 (Intermedio): ¿Cómo interactúa Flyway con Testcontainers en un test `@SpringBootTest`?

Al arrancar el contexto de prueba, Spring Boot se conecta al contenedor de PostgreSQL real y Flyway ejecuta automáticamente todos los scripts de migración `V1`, `V2`... verificando que el esquema real de producción se cree limpiamente.

**Respuesta razonada:** Flyway se autoconfigura al arrancar el contexto de Spring y usa el mismo `DataSource`, que con `@ServiceConnection` apunta al contenedor. El orden es: Testcontainers arranca PostgreSQL vacío, Spring crea el `DataSource`, Flyway aplica todas las migraciones de `db/migration` y después se crean los beans de JPA (que con `ddl-auto=validate` comprueban el esquema).

Esto tiene un valor añadido: cada ejecución de los tests valida tus migraciones reales contra la versión real de PostgreSQL. Un error de sintaxis en `V7__...sql` se detecta en tu máquina, no al desplegar. Si el contenedor se comparte entre clases, las migraciones se aplican una sola vez y los datos de prueba hay que limpiarlos entre tests.

### Nivel 3 (Entrevista Senior): ¿Qué es el patrón "Singleton Container" en Testcontainers y por qué se utiliza en suites de prueba grandes?

Es una clase base abstracta de prueba donde se define el contenedor estático una sola vez. Todas las clases de test heredan de esa clase base, garantizando que el contenedor Docker se levanta solo una vez para toda la suite de pruebas del proyecto.

**Respuesta razonada:** Consiste en arrancar los contenedores una sola vez por JVM, normalmente en un bloque `static` de una clase base abstracta de la que heredan todos los tests de integración, sin la anotación `@Container` (que los pararía al terminar cada clase). Testcontainers lanza además un contenedor auxiliar llamado Ryuk que elimina todo cuando la JVM termina, así que no quedan restos.

Se usa porque en suites grandes el coste dominante es arrancar contenedores: con 80 clases de test, pasar de 80 arranques de PostgreSQL a uno ahorra minutos en cada ejecución. El precio es que el estado se comparte, así que cada test debe limpiar o aislar sus datos (borrar tablas, usar identificadores únicos o confiar en el rollback de `@DataJpaTest`).


## Reto del tutor

**Reto:** Un candidato dice: "Para no depender de Docker, mis tests de integración usan H2 con dialecto `PostgreSQLDialect`". Evalúa esta afirmación técnica.

- **Lo que está bien:** Reconoce que H2 tiene una opción de emulación de dialecto PostgreSQL.
- **Respuesta superficial:** Decir que "es mejor Docker porque es más moderno".
- **Lo que falta de fondo:** No entender que el modo de compatibilidad de H2 solo emula la sintaxis básica, pero no soporta tipos nativos como JSONB, UUIDs nativos, extensiones PostGIS, bloqueos transaccionales pesados ni secuencias idénticas a PostgreSQL real.
- **Respuesta senior:** Es un compromiso técnico obsoleto: la emulación de H2 da una falsa sensación de seguridad. Funciona para CRUDs triviales, pero fallará estrepitosamente en cuanto uses una función nativa de PostgreSQL (e.g. `jsonb_extract_path`, índices GIN, o consultas nativas). En la industria actual, el estándar indiscutible para pruebas de persistencia fiables es Testcontainers corriendo el motor PostgreSQL exacto de producción.


## Katas

### Kata 20.1: Test de repositorio con PostgreSQL real en Testcontainers

Configura un test con `@Testcontainers` y `@Container @ServiceConnection static PostgreSQLContainer<?> postgres`. Escribe un test que guarde una entidad con Spring Data JPA y verifique su persistencia real.


## Reglas de oro

- No uses H2 para probar código SQL de producción; usa Testcontainers.
- Declara siempre los contenedores como `static` para no penalizar el tiempo de ejecución.
- Usa `@ServiceConnection` para vincular automáticamente el contenedor con Spring Boot.


## Proyecto guiado «Pedidos»: Tests de integración con PostgreSQL real

Probar repositorios y transacciones contra la misma base de datos que en producción.

**Tareas:**

- Configura Testcontainers con PostgreSQL y `@ServiceConnection`.
- Prueba con `@DataJpaTest` el listado paginado y la consulta sin N+1.
- Prueba el caso de uso completo de confirmar un pedido con `@SpringBootTest`.

**Está listo cuando:**

- Los tests de integración pasan en un ordenador con Docker y sin configuración manual.
- Un mismo contenedor se reutiliza en toda la suite.


## Fuentes oficiales

- [Testcontainers para Java](https://java.testcontainers.org/)
- [Testcontainers: módulo de PostgreSQL](https://java.testcontainers.org/modules/databases/postgres/)
- [Spring Boot: Testcontainers](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html)

