1. Testcontainers vs Docker Compose
Si ya has levantado PostgreSQL mediante Docker Compose, la pregunta «¿para qué otro Docker?» es absolutamente normal. Parece que simplemente puedes ejecutar las pruebas contra una base de datos local y todo será correcto. El problema es que la base de datos local de cada estudiante (y en CI) es un pequeño universo con sus propias configuraciones, datos residuales y «uy, olvidé aplicar las migraciones».
Testcontainers lo resuelve de manera muy pragmática: la prueba misma levanta su propio PostgreSQL aislado, ella misma proporciona a Spring los parámetros de conexión y ella misma lo apaga. Es decir, la prueba deja de depender de lo que tengas ejecutándose ahora en Docker Desktop, de qué datos quedaron después de los experimentos de ayer y de si alguien cambió el puerto en .env. Para las pruebas de servicio esto es especialmente importante, porque no verifican una sola consulta, sino un escenario completo donde «una pequeña discrepancia en la BD» se convierte en «¿por qué mi pedido a veces se crea y a veces no?».
Conviene fijarlo en una tabla sencilla, como una «matriz de decisiones», en lugar de una lista de reglas:
| Enfoque | Qué ejecutamos | Ventajas | Inconvenientes | Cuándo es adecuado |
|---|---|---|---|---|
| H2 (en memoria) | Nada externo | Muy rápido | No es PostgreSQL, puede ocultar problemas | Señal rápida para comprobaciones simples cuando no importan las particularidades de PostgreSQL |
| PostgreSQL local mediante Compose | Manual/script | PostgreSQL real | No aislado, depende del entorno | Desarrollo local, comprobaciones manuales, ejecución de la aplicación |
| PostgreSQL mediante Testcontainers | Lo levanta la prueba | Aislado, más cercano a producción, excelente para CI | Más lento, necesita Docker | Pruebas de integración de servicio, migraciones, restricciones, bloqueo optimista, SQL nativo |
2. ¿Qué es Testcontainers? «JUnit → Docker → PostgreSQL → Spring»
Testcontainers se puede entender como «una biblioteca que crea contenedores», pero eso es demasiado vago. Es mucho más útil pensar así: la prueba JUnit adquiere una dependencia de un “recurso externo”, y Testcontainers se encarga del ciclo de vida de ese recurso. Es decir, inicia el contenedor antes de las pruebas, proporciona los parámetros de conexión y garantiza que el contenedor se detenga correctamente.
A continuación se muestra un esquema que ayuda a los principiantes a no confundirse sobre quién inicia qué. Aquí no hay «magia de Spring», solo una secuencia de pasos:
flowchart TD T[Método de prueba se ejecuta en JUnit 6] --> TC["@Testcontainers gestiona el contenedor"] TC --> D[Docker lanza la imagen postgres] D --> P[(PostgreSQL en contenedor)] T --> S[Spring Boot Test Context inicia la aplicación] S -->|lee propiedades datasource| P S -->|Flyway aplica migraciones| P T -->|invoca servicios| S
Observa un punto importante: Spring Boot no «levanta Docker». Docker lo levanta Testcontainers, y Spring Boot simplemente recibe los parámetros habituales de DataSource (url, username, password) y funciona como si fuera un «PostgreSQL normal en algún lugar cercano». Por eso @DynamicPropertySource es el puente clave: conecta el mundo del contenedor con el mundo de la configuración de Spring.
Y sí, detrás de esto está Docker. Así que si Docker no está en ejecución, Testcontainers se quejará claramente. Sin milagros: los contenedores sin Docker no arrancan, como un hervidor sin electricidad.
3. Añadir Testcontainers en Gradle y preparar el proyecto
Antes de escribir el código de la prueba, hay que hacer dos cosas aburridas pero importantes: agregar las dependencias y asegurarse de que el entorno de pruebas realmente pueda ejecutar contenedores. Con las dependencias es sencillo: necesitamos el módulo de Testcontainers para JUnit 6 y el módulo para PostgreSQL. Es importante que estas dependencias sean testImplementation, porque en tiempo de ejecución de la aplicación no se necesita Testcontainers. Esta configuración es suficiente: las pruebas de servicio lo usarán sin necesidad de inventar una nueva configuración para cada clase.
Para shop-data-jpa (Gradle) normalmente se ve así:
dependencies {
// Conjunto básico para pruebas de Spring Boot (JUnit 6 dentro del starter)
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Integración de Testcontainers con JUnit 6 (API Jupiter)
testImplementation("org.testcontainers:junit-jupiter")
// Contenedor PostgreSQL para pruebas
testImplementation("org.testcontainers:postgresql")
}
Si ya tienes spring-boot-starter-test, solo añades dos líneas. Luego hay que recordar la realidad infraestructural: Testcontainers ejecuta un contenedor Docker, por lo que en tu máquina debe estar instalado y ejecutándose Docker (en Windows/macOS suele ser Docker Desktop). Esto no es una «configuración de Spring», sino una condición del juego.
Otro detalle importante para nuestro curso: si en tu proyecto ya hay Flyway y las migraciones están en db/migration, al iniciar @SpringBootTest sobre una BD limpia de contenedor, Flyway aplicará las migraciones automáticamente. Esto es una gran ventaja: pruebas los servicios sobre un esquema que corresponde al proyecto, no sobre «lo que salga».
4. Levantar un contenedor PostgreSQL en JUnit 6: @Testcontainers y @Container
Ahora pasamos a lo más interesante: cómo se ve un PostgreSQL en contenedor dentro de una clase de prueba. Aquí es importante no confundir los dos roles. La anotación @Testcontainers le dice a JUnit 6: «esta clase de prueba tiene contenedores; su ciclo de vida lo gestiona Testcontainers». La anotación @Container marca un campo específico como contenedor: «este contenedor debe iniciarse/detenerse».
El esqueleto mínimo del contenedor es:
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class PostgresContainerBaseTest {
// El contenedor se levanta una vez por clase (más rápido, pero importante la disciplina de datos)
@Container
static PostgreSQLContainer
postgres =
new PostgreSQLContainer<>("postgres:17") // Se fija la versión de la imagen para evitar sorpresas
.withDatabaseName("shop")
.withUsername("shop")
.withPassword("shop");
}
Aquí es importante la palabra static. Si el contenedor es static, se levanta una vez para toda la clase (normalmente más rápido). Si el contenedor no es static, se levanta para cada método de prueba (más limpio en cuanto a aislamiento, pero notablemente más lento). Para pruebas de integración de servicio en un proyecto de aprendizaje, un valor predeterminado razonable es static, y se garantiza la limpieza de los datos mediante una preparación cuidadosa o una limpieza explícita.
Y sí, la imagen postgres:17 es una imagen Docker común. No existe ningún «PostgreSQL especial para pruebas»: es un PostgreSQL normal, solo que temporal.
5. Pasar los parámetros a Spring Boot: @DynamicPropertySource
El contenedor por sí solo no sirve de nada si Spring Boot sigue leyendo la configuración de DataSource de application-test.yml e intenta conectarse a la dirección antigua. Nuestra tarea es decirle a Spring: «toma la URL, el usuario y la contraseña del contenedor». Para eso se usa @DynamicPropertySource: es una forma de agregar propiedades programáticamente al entorno de Spring antes de que se inicie el contexto.
Se ve así:
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
// La URL incluye el puerto aleatorio asignado por Docker
registry.add("spring.datasource.url", postgres::getJdbcUrl);
// El usuario y la contraseña también se toman del contenedor para evitar desincronización con la configuración
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
Observa que el método debe ser static. Es uno de esos pequeños detalles que lo rompen todo «sin razón aparente» si se omiten. La lógica es simple: Spring debe obtener estas propiedades antes de crear los beans, por lo que no puede esperar a tener una instancia de la clase de prueba.
Otro aspecto pragmático: a veces en application-test.yml quedan configuraciones antiguas como spring.datasource.url=jdbc:h2:mem:testdb. Con @DynamicPropertySource se sobrescribirán, pero es mejor mantener la configuración de prueba ordenada para no tener que adivinar qué se aplicó realmente. En las pruebas, la transparencia beneficia a todos: a ti y al futuro equipo.
6. Prueba de integración de servicio sobre PostgreSQL
Cuando el contenedor está levantado y Spring se ha conectado a él, la prueba de servicio se ve casi igual que un @SpringBootTest normal. Esto es un punto psicológico importante: no necesitas «reescribir las pruebas para contenedores». Simplemente cambias la fuente de la base de datos por una más realista.
A continuación, un ejemplo del esqueleto de una prueba que vive en el contexto de shop-data-jpa y verifica que el contexto se levanta y el servicio está disponible:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class OrderServicePostgresIT extends PostgresTestBase {
// Obtenemos el bean del contexto: un mínimo importante para entender que la aplicación realmente arrancó
@Autowired
OrderService orderService;
@Test
void contextStarts() {
// Comprobación didáctica: el servicio se levantó y se inyectó mediante DI
org.junit.jupiter.api.Assertions.assertNotNull(orderService);
}
}
Para que esto funcione, necesitamos una abstracción base PostgresTestBase que contenga el contenedor y @DynamicPropertySource. Es conveniente tenerla en el paquete de prueba com.example.shopdatajpa.testinfra o cerca, para no copiar lo mismo en cada prueba.
Ejemplo de dicha base:
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
abstract class PostgresTestBase {
// Base para todas las pruebas de integración:
// - aquí se declara el contenedor postgres
// - aquí también se pasan las propiedades mediante @DynamicPropertySource
}
Ahora agreguemos un pequeño diagnóstico muy útil: asegurémonos de que realmente estamos conectados a PostgreSQL y no accidentalmente a H2 por alguna configuración.
import javax.sql.DataSource;
import java.sql.Connection;
@Autowired
DataSource dataSource;
@Test
void usesPostgreSql() throws Exception {
// Nos conectamos al DataSource que Spring ensambló (importante: no directamente al contenedor)
try (Connection c = dataSource.getConnection()) {
String db = c.getMetaData().getDatabaseProductName();
System.out.println("DB = " + db); // Esperamos ver: DB = PostgreSQL
}
}
Esto no es un «assert real», pero como paso didáctico es muy útil. Si ves H2, significa que el contenedor no se conectó y la prueba no está haciendo lo que debería.
Y ahora un poco más cerca del sentido de las pruebas de servicio: verificación de un caso de uso. Ejemplo simplificado: creemos datos de prueba con repositorios, llamemos a placeOrder(), luego releamos los datos y verifiquemos el resultado. Aquí es importante la forma de verificación: el servicio ejecuta un escenario transaccional, y los repositorios se usan como «sensores» para medir el estado final en la base de datos.
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
@Test
void placeOrderCreatesOrder() {
// Preparación de datos de entrada: producto + existencias en almacén
Long productId = testData.createProduct("SKU-900", new BigDecimal("10.00"));
testData.setStock(productId, 5);
// Acción: invocamos el escenario de negocio a nivel de servicio
Long orderId = orderService.placeOrder(productId, 2);
// Verificación del resultado: releemos la entidad desde la BD (el repositorio actúa como «sensor»)
CustomerOrder order = orderRepository.findById(orderId).orElseThrow();
org.junit.jupiter.api.Assertions.assertEquals(2, order.getItems().size());
}
En este fragmento uso intencionadamente testData como abstracción didáctica, porque la preparación real de datos para un pedido suele ocupar más de 10 líneas. El sentido es otro: no estás obligado a «escribir SQL a mano» para la preparación. Puedes preparar los datos mediante los repositorios y ejecutar el caso de uso a través del servicio.
Y otro detalle importante directamente relacionado con el tema de hoy. Si estás probando un rollback, es especialmente importante releer los datos de los repositorios después de la excepción, en lugar de mirar las entidades ya cargadas. El PostgreSQL en contenedor no «cura» el problema, pero hace la prueba más honesta: el rollback y las restricciones se comportan lo más cerca posible de la base de datos del proyecto.
7. Cuándo realmente se necesita Testcontainers
Cuando alguien ve Testcontainers por primera vez, siente la tentación de decir: «¡Genial! Ahora todas las pruebas serán con PostgreSQL y olvidaremos para siempre las sorpresas». Ese deseo es comprensible. Y peligroso — sobre todo por la velocidad: los contenedores tardan más en levantarse, el contexto tarda más en iniciarse, y al final las pruebas empiezan a «castigarte» por cada cambio.
Una estrategia saludable para nuestro curso es la siguiente. Las pruebas estrechas de repositorio se quedan en @DataJpaTest porque dan una señal rápida y verifican cosas muy concretas. Las pruebas de integración de servicio, donde son importantes las transacciones, rollback, restricciones, bloqueo optimista y migraciones, son buenas candidatas para Testcontainers. No es «todo en contenedores», sino «contenedores donde aportan información nueva».
También es útil tener en mente una fórmula de ingeniería simple: Testcontainers no te añade nuevos assertions. Simplemente hace que el entorno esté más cerca de la verdad de PostgreSQL. Si tu prueba ya es débil (solo verifica assertNotNull(service)), el contenedor no la volverá inteligente. Pero si la prueba realmente verifica un invariante de negocio y el comportamiento de las transacciones, el contenedor aportará confianza de que lo has comprobado en el SGBD correcto.
8. Errores típicos al trabajar con Testcontainers
Error n.º 1: Docker no está ejecutándose y esperamos que «Spring se las arregle de alguna manera».
La causa más frecuente de fallos en las pruebas con contenedores es trivial: Docker está apagado o no disponible (especialmente después de una actualización del sistema). Es importante aceptarlo: Testcontainers no es una «dependencia de Java», es un puente hacia Docker. Si Docker no está disponible, las pruebas no podrán levantar la base de datos, y eso es normal.
Error n.º 2: se levantó el contenedor, pero Spring igual se conectó a otro lugar.
Esto ocurre cuando olvidaste @DynamicPropertySource, escribiste mal las claves spring.datasource.* o dejaste en el perfil de prueba algo que sobrescribe las propiedades más tarde. El síntoma es «la conexión se realiza, pero la base de datos no es Postgres». En un proyecto de aprendizaje es útil tener una prueba de diagnóstico que imprima DatabaseProductName.
Error n.º 3: olvidaste que @DynamicPropertySource debe ser static.
Este error es especialmente molesto para los principiantes: el código parece correcto, el contenedor está declarado, pero las propiedades no se aplican. La causa casi siempre es que el método no es static (o el contenedor está declarado en un ámbito incorrecto). La disciplina aquí es simple: el contenedor y el método de propiedades deben ser estáticos para que Spring pueda usarlos antes de iniciar el contexto.
Error n.º 4: «el contenedor está, por lo tanto la base de datos está limpia» (pero no lo está).
Si el contenedor es static, la base de datos vive durante toda la clase de prueba. Esto significa que los datos creados por una prueba pueden afectar a la siguiente. A veces esto no se manifiesta de inmediato, sino «en la tercera ejecución». No se cura con magia, sino con disciplina: o una limpieza cuidadosa de los datos en @BeforeEach (en el orden correcto debido a las FK), o una preparación de datos bien pensada para que las pruebas no entren en conflicto.
Error n.º 5: convertir la prueba con contenedor en una enciclopedia de verificaciones.
Aparece la tentación: ya que la prueba es lenta y honesta, metamos todo — rollback, migraciones, bloqueo optimista y además una consulta de informe. Al final, cuando falla, no entiendes qué se rompió exactamente. Una prueba con contenedor, como cualquier buena prueba, debe verificar una única promesa principal del caso de uso, simplemente sobre una base de datos más realista.
GO TO FULL VERSION