Testcontainers: bases de datos reales en tus pruebas de integración
Deja de simular tu base de datos y pruébala de verdad, levantada en segundos con Docker.
Cuando pruebas un repositorio o un servicio que habla con Postgres o Mongo, te enfrentas a una bifurcación. Puedes simular (mockear) el cliente de la base de datos y verificar que se llamaron los métodos correctos, o puedes ejecutar tu código contra un motor de base de datos real. Los mocks son rápidos y no tienen dependencias externas, pero verifican tus *suposiciones* sobre la base de datos en lugar de su *comportamiento* real. El mock nunca aplica una restricción de unicidad, nunca rechaza una consulta mal formada, nunca aplica un valor por defecto y nunca revela que tu mapeo JPA trunca silenciosamente una columna. Testcontainers cierra esa brecha arrancando una base de datos genuina dentro de un contenedor Docker desechable, dedicado a tu ejecución de pruebas.
Testcontainers es una librerÃa de la JVM que inicia y detiene contenedores Docker de forma programática desde el ciclo de vida de tus pruebas. Declaras un contenedor como un campo, lo anotas, y la librerÃa descarga la imagen, lo inicia antes de las pruebas y lo destruye después. Como es el mismo motor que ejecutas en producción (la versión exacta de Postgres o Mongo que fijas), el dialecto SQL, los Ãndices, la semántica transaccional y los operadores JSON se comportan de forma idéntica. El costo es que Docker debe estar disponible en la máquina que ejecuta las pruebas, y la primera ejecución paga una descarga única de la imagen. Después de eso, el arranque del contenedor suele tardar unos segundos, y una imagen ya descargada lo hace casi instantáneo.
La configuración idiomática en Spring Boot usa dos anotaciones. `@Testcontainers` es una extensión de JUnit 5 que escanea la clase de prueba en busca de campos de contenedor y gestiona su ciclo de vida. `@Container` marca cada campo que contiene un contenedor para que la extensión sepa iniciarlo y detenerlo. Un campo en un `companion object` (estático en términos de Java) se inicia una sola vez para toda la clase y se comparte entre todos los métodos de prueba, que es lo que casi siempre quieres para una base de datos. En cambio, un campo `@Container` a nivel de instancia se reinicia antes de cada prueba, dando aislamiento perfecto a costa de velocidad. Para la mayorÃa de las suites de persistencia, un contenedor compartido más limpieza por prueba o rollback transaccional logra el equilibrio justo.
El problema restante es el cableado: Docker asigna al contenedor un puerto de host aleatorio en cada ejecución, asà que no puedes fijar la URL JDBC en `application.properties`. Esto es lo que resuelve `@DynamicPropertySource`. Escribes un método estático que recibe un `DynamicPropertyRegistry` y registra propiedades cuyos valores se calculan *después* de que el contenedor haya arrancado. Spring las evalúa antes de construir el `ApplicationContext`, de modo que el `DataSource` y cualquier autoconfiguración toman las coordenadas reales y vivas. Para Postgres esto significa la URL JDBC, el usuario y la contraseña; para Mongo es la cadena de conexión. Cada entrada del registro recibe un proveedor (supplier), asà que el valor se resuelve de forma perezosa una vez que se conoce el puerto mapeado.
Aquà está el detalle de secuencia crÃtico que confunde a la gente. JUnit inicia el campo `@Container`, luego el TestContext de Spring invoca tu método `@DynamicPropertySource`, y solo entonces se refresca el contexto y se inyecta tu repositorio `@Autowired`. Si por accidente lees `container.jdbcUrl` en el momento de inicializar el campo en lugar de dentro de la lambda del supplier, el contenedor aún no tendrá puerto y obtendrás un error de conexión rechazada. Pasa siempre una referencia a método o una lambda al registro para diferir la resolución. Con los módulos `PostgreSQLContainer` y `MongoDBContainer`, la librerÃa incluso expone ayudantes tipados como `jdbcUrl`, `username` y `replicaSetUrl` para que no armes cadenas a mano.
¿Por qué importa esto más allá de la pureza? Las pruebas de integración contra un motor real capturan toda una clase de errores que los mocks estructuralmente no pueden. Validan tus migraciones de esquema (Flyway o Liquibase se ejecutan de verdad), prueban que tus consultas compilan contra el dialecto real, sacan a la luz problemas de carga perezosa y N+1, y verifican violaciones de restricciones y comportamiento de cascada. Un mock que devuelve una entidad construida a mano te dejará felizmente desplegar una consulta con una columna mal escrita; un Postgres real la rechaza de inmediato. Los mocks siguen teniendo su lugar para pruebas unitarias rápidas de lógica de negocio pura, pero para la capa de persistencia la pregunta '¿realmente mis datos van y vuelven correctamente?' solo la puede responder una base de datos.
Para mantener estas pruebas rápidas y mantenibles, apóyate en algunos patrones. Usa un único contenedor compartido en una clase base que extiendan todas tus suites `@DataJpaTest` o `@SpringBootTest`, de modo que la imagen se inicie una vez por ejecución en lugar de una vez por clase. Fija la etiqueta de la imagen explÃcitamente (`postgres:16-alpine`) para que las pruebas sean reproducibles y nunca se actualicen en silencio. Habilita la reutilización de contenedores mediante `~/.testcontainers.properties` durante el desarrollo local para evitar reinicios entre ejecuciones, manteniendo un arranque limpio en CI. Y reinicia el estado entre pruebas con un rollback transaccional o un ayudante de truncado en lugar de recrear el contenedor, lo que preserva tanto el aislamiento como la velocidad.
La recompensa es la confianza. Una suite de integración en verde respaldada por Testcontainers te dice que tu código, tu mapeo, tus migraciones y tus consultas funcionan todos juntos contra la misma base de datos que despliegas en producción, sin que nadie necesite instalar Postgres localmente ni compartir una frágil base de datos de staging. Cambias una pequeña cantidad de tiempo de arranque y una dependencia de Docker por pruebas que fallan cuando algo está genuinamente roto y pasan cuando genuinamente funciona. Para la capa de persistencia de un servicio en Kotlin y Spring, ese intercambio casi siempre vale la pena.
@SpringBootTest@Testcontainersclass UserRepositoryIntegrationTest {@Autowiredlateinit var userRepository: UserRepositorycompanion object {@Container@JvmStaticval postgres = PostgreSQLContainer(DockerImageName.parse("postgres:16-alpine")).withDatabaseName("app").withUsername("test").withPassword("test")@DynamicPropertySource@JvmStaticfun props(registry: DynamicPropertyRegistry) {registry.add("spring.datasource.url", postgres::getJdbcUrl)registry.add("spring.datasource.username", postgres::getUsername)registry.add("spring.datasource.password", postgres::getPassword)}}@Testfun `unique email constraint is enforced by the real database`() {userRepository.save(User(email = "ada@kotlin.dev"))// A mock would never throw here - a real Postgres does.assertThrows<DataIntegrityViolationException> {userRepository.saveAndFlush(User(email = "ada@kotlin.dev"))}}}
A full @SpringBootTest with a shared Postgres container. The companion-object container starts once for the class; @DynamicPropertySource wires the random port into Spring before the context is built. Note the supplier lambdas (::method references) defer resolution until the container is up.
@SpringBootTest@Testcontainersabstract class AbstractPostgresTest {companion object {@Container@JvmStaticval postgres = PostgreSQLContainer(DockerImageName.parse("postgres:16-alpine")).withReuse(true)@DynamicPropertySource@JvmStaticfun datasource(registry: DynamicPropertyRegistry) {registry.add("spring.datasource.url", postgres::getJdbcUrl)registry.add("spring.datasource.username", postgres::getUsername)registry.add("spring.datasource.password", postgres::getPassword)}}}class OrderRepositoryTest : AbstractPostgresTest() {@Autowired lateinit var orders: OrderRepository@Testfun `round-trips an order through the real schema`() {val saved = orders.save(Order(total = 4200))val found = orders.findById(saved.id!!).orElseThrow()assertEquals(4200, found.total)}}
A reusable base class pattern. Every suite that extends this shares one container, so the image starts a single time per test run instead of once per class. Subclasses just add their @Autowired beans and @Test methods.
@DataMongoTest@Testcontainersclass ProductDocumentTest {@Autowiredlateinit var template: MongoTemplatecompanion object {@Container@JvmStaticval mongo = MongoDBContainer(DockerImageName.parse("mongo:7"))@DynamicPropertySource@JvmStaticfun props(registry: DynamicPropertyRegistry) {registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl)}}@Testfun `persists and reads a document`() {template.save(Product(name = "Keyboard", price = 120))val all = template.findAll(Product::class.java)assertEquals(1, all.size)}}
MongoDB works the same way - swap the container module and register the single connection-string property the Spring Data Mongo auto-configuration needs.
import kotlinx.coroutines.*class DuplicateKeyException(message: String) : Exception(message)// 'Real' store: enforces a uniqueness rule, like a real database would.class RealStore {private val rows = mutableMapOf<Int, String>()suspend fun insert(id: Int, name: String) {delay(10) // simulate I/Oif (rows.containsKey(id)) throw DuplicateKeyException("id $id exists")rows[id] = name}}// Naive fake (a mock): never enforces the rule, so bugs slip through.class FakeStore {val rows = mutableMapOf<Int, String>()suspend fun insert(id: Int, name: String) { rows[id] = name }}fun main() = runBlocking {val fake = FakeStore()fake.insert(1, "Ada")fake.insert(1, "Grace") // silently overwrites - no errorprintln("Fake accepted duplicate: " + fake.rows)val real = RealStore()real.insert(1, "Ada")try {real.insert(1, "Grace")} catch (e: DuplicateKeyException) {println("Real store caught the bug: " + e.message)}}
Pure Kotlin + coroutines: the SAME conceptual gap Testcontainers fixes, shown without any framework. A fake in-memory store happily accepts a duplicate id, while a 'real' store enforces the constraint - illustrating why testing against real behavior matters. This runs on the kotlinx.coroutines stdlib alone.
Arena IDE🧠Comprueba tu comprensión
0/1 · 0/1 answered1. Why must the JDBC URL be registered through a supplier inside @DynamicPropertySource rather than read directly when the container field is declared?