Testcontainers para Postgres y Mongo
Un repositorio simulado deja pasar sin problema una migración rota o una clave duplicada; un Postgres y un Mongo reales, levantados solo para la prueba, no lo permiten.
Es probable que hasta ahora, en cada prueba de integración del servicio de valuación, hayas simulado `VehicleRepository` o el repositorio de auditoría de Mongo en alguna capa — y esa es la decisión correcta para una prueba unitaria, pero esconde en silencio toda una categoría de errores. Un mock devuelve exactamente lo que le dijiste que devolviera. Nunca rechaza una inserción en `pricing_rule` por violar una restricción única real, nunca falla una consulta porque tu JPQL compila pero el SQL generado no coincide con el dialecto real de Postgres, y nunca revela que una consulta de Mongo escrita contra un campo que en realidad no está indexado funcionará en silencio en la prueba pero se arrastrará en producción. Los mocks codifican tus suposiciones sobre la base de datos. A veces esas suposiciones son incorrectas, y solo lo real te lo dice.
Testcontainers cierra esa brecha dándole a tu suite de pruebas un Postgres real y desechable y un MongoDB real y desechable — cada uno corriendo en un contenedor Docker de verdad, iniciado desde cero (o reutilizado) para la clase de prueba, y eliminado automáticamente cuando termina la JVM. No es una simulación del comportamiento de Postgres; es Postgres, el mismo binario que corre tu base de datos de producción, solo que acotado a una única ejecución de pruebas. La integración con JUnit 5 son dos anotaciones: `@Testcontainers` en la clase de prueba le indica a JUnit que gestione el ciclo de vida del contenedor, y `@Container` marca el campo que contiene la instancia del contenedor.
En la práctica declaras los contenedores como campos de un `companion object` para que se compartan entre todos los métodos de prueba de la clase en lugar de reiniciarse en cada uno — arrancar un contenedor de Postgres o Mongo toma un tiempo real (normalmente de uno a varios segundos), y pagar ese costo una vez por clase en lugar de una vez por prueba es la diferencia entre una suite que corre en diez segundos y una que tarda diez minutos. Testcontainers incluye un proceso en segundo plano llamado Ryuk que vigila contenedores huérfanos y los mata incluso si tu JVM se cae a mitad de la prueba, así que nunca terminas con un cementerio de contenedores de Postgres abandonados en tu máquina.
La pieza que hace esto realmente utilizable dentro de una prueba de Spring es `@DynamicPropertySource`. Un contenedor no conoce su puerto de host hasta que Docker le asigna uno al arrancar — no puedes escribir a mano `jdbc:postgresql://localhost:5432/valuation` en `application-test.yml` porque ese puerto no será 5432, y será distinto en cada ejecución. `@DynamicPropertySource` es un método estático que corre después de que los contenedores arrancan pero antes de que se cargue el contexto de Spring, y te permite leer la URL JDBC real o la cadena de conexión de Mongo del contenedor e inyectarla como propiedad de Spring justo en el momento en que Spring la necesita.
Una vez conectado esto, pruebas que antes eran imposibles con un mock se vuelven directas. Puedes insertar una fila de `pricing_rule` con código de regla `HIGH_MILEAGE_DEDUCTION`, luego intentar insertar una segunda fila con el mismo código, y afirmar que Spring Data lanza una `DataIntegrityViolationException` — porque el índice único real sobre `rule_code` está haciendo un trabajo real, el mismo trabajo que hará en producción el día en que el script de despliegue de alguien intente sembrar una regla que ya existe. Un mock habría aceptado felizmente ambas inserciones sin decirte que algo estaba mal.
La misma lógica aplica al lado de Mongo del rastro de auditoría. Los documentos de `valuation_event` se escriben de forma fire-and-forget desde una corrutina en segundo plano, y las consultas que corren tus dashboards y tu herramienta de soporte contra esa colección — encontrar el evento más reciente de un VIN, encontrar todos los eventos en un rango de fechas — dependen del planificador de consultas real de Mongo y, eventualmente, de los índices que definas. Ejecutar esas consultas contra un `MongoDBContainer` real detecta el caso en que una consulta es sintácticamente válida pero semánticamente incorrecta (operador equivocado, nombre de campo equivocado, una comparación de fecha contra un string) mucho antes de que un ingeniero de soporte lo descubra durante un incidente.
La contrapartida honesta es la velocidad: una clase de pruebas basada en Testcontainers tarda más en arrancar que una construida enteramente sobre mocks, porque Docker tiene que descargar una imagen (una vez, luego queda en caché) y arrancar un proceso real de base de datos. Por eso mismo estas pruebas se reservan específicamente para la capa de repositorios y persistencia — el puñado de pruebas que de verdad necesita demostrar que las consultas SQL y de Mongo se comportan correctamente contra el motor real — mientras que el grueso mucho mayor de pruebas de la capa de servicio y de lógica de negocio sigue usando mocks rápidos en memoria. Obtienes corrección real donde importa y un ciclo de retroalimentación rápido en todo lo demás.
@Testcontainers@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class PricingRuleRepositoryIntegrationTest {companion object {@Container@JvmStaticval postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine").withDatabaseName("valuation").withUsername("test").withPassword("test")@Container@JvmStaticval mongo: MongoDBContainer = MongoDBContainer("mongo:7.0")@DynamicPropertySource@JvmStaticfun registerDynamicProperties(registry: DynamicPropertyRegistry) {registry.add("spring.datasource.url", postgres::getJdbcUrl)registry.add("spring.datasource.username", postgres::getUsername)registry.add("spring.datasource.password", postgres::getPassword)registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl)}}@Autowiredlateinit var pricingRuleRepository: PricingRuleRepository}
A test class that boots a real Postgres and a real Mongo container once, then wires their dynamic connection details into the Spring test context.
@Testfun `saving two pricing rules with the same rule code violates the unique constraint`() {val original = PricingRuleEntity(ruleCode = "HIGH_MILEAGE_DEDUCTION",thresholdMiles = 100_000,adjustmentPercent = BigDecimal("-0.08"))pricingRuleRepository.saveAndFlush(original)val duplicate = original.copy(id = null)assertThrows<DataIntegrityViolationException> {pricingRuleRepository.saveAndFlush(duplicate)}}
A real unique-constraint violation that only shows up against the actual Postgres engine — a mock would let both inserts succeed.
@Testfun `finds only the valuation event that falls inside the requested date range`() {val vin = "1HGCM82633A004352"valuationEventRepository.save(ValuationEvent(vin = vin, decidedAt = Instant.parse("2026-08-01T00:00:00Z"), outcome = "APPROVE"))valuationEventRepository.save(ValuationEvent(vin = vin, decidedAt = Instant.parse("2026-08-15T00:00:00Z"), outcome = "NO_OFFER"))val results = valuationEventRepository.findByVinAndDecidedAtBetween(vin,Instant.parse("2026-08-10T00:00:00Z"),Instant.parse("2026-08-20T00:00:00Z"))assertEquals(1, results.size)assertEquals("NO_OFFER", results.first().outcome)}
A real Mongo query against the valuation_event audit collection, proving the date-range filter actually matches what the field mapping and index expect.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. Why spin up a real Postgres container for the pricing_rule repository tests instead of pointing the same tests at an in-memory H2 database configured in "Postgres compatibility mode"?