¿Cómo pruebas código que depende de una base de datos — contenedores de prueba reales, mocks, o una base de datos en memoria?
Los mocks son rápidos pero prueban tus suposiciones, las bases de datos en memoria divergen en silencio del motor real, y Testcontainers te da lo real, de forma hermética, en CI.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo pruebas código que depende de una base de datos — contenedores de prueba reales, mocks, o una base de datos en memoria?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Tres formas de probar código que habla con una base de datos
Pregúntale a diez ingenieros cómo prueban una clase de repositorio y vas a recibir tres respuestas, muchas veces del mismo ingeniero según qué capa esté probando: simular la base de datos con un mock, correr contra una base de datos en memoria, o levantar una real dentro de un contenedor. No son intercambiables, y elegir la herramienta equivocada para la capa equivocada es una de las formas más comunes en que un bug se cuela en un servicio "bien probado".
#Mocks: rápidos, pero prueban tus suposiciones, no tu SQL
Simular la interfaz del repositorio (o el driver de la base de datos) es la herramienta correcta cuando estás probando lógica de negocio que depende de la persistencia, no la persistencia en sí misma. Un motor de precios que llama a orderRepository.findById(id) y luego aplica un descuento no necesita una base de datos real para demostrar que la matemática del descuento es correcta — un stub que devuelve una Order enlatada es más rápido y más claro. El modo de falla aparece cuando usas mocks para probar la consulta en sí: un mock siempre devuelve exactamente lo que le dijiste que devolviera, así que una cláusula WHERE rota, un join equivocado, o un error de tipeo en el nombre de una columna van a pasar todos los tests simulados y solo van a explotar la primera vez que toquen datos reales.
#Bases de datos en memoria: cómodas, y mintiéndote en silencio
H2 y motores en memoria similares son atractivos porque no necesitan Docker, arrancan en milisegundos, y ofrecen "modos de compatibilidad" que aseguran emular PostgreSQL, MySQL u Oracle. En la práctica esa emulación es sintáctica, no semántica. Divergencias comunes que muerden a los equipos en producción:
- JSON/JSONB: el soporte de JSON de H2 no implementa los operadores
jsonbde PostgreSQL (->,->>,@>) con la misma semántica de indexado o contención. - Upserts: el
INSERT ... ON CONFLICT DO UPDATEde PostgreSQL se comporta distinto alMERGEde H2 — los nombres de restricciones, los índices parciales y las referencias aEXCLUDEDno se mapean de forma limpia entre los dos. - Sensibilidad a mayúsculas: los identificadores sin comillas se pliegan de forma distinta entre el modo por defecto de H2 y Postgres, así que una consulta que "simplemente funciona" en la suite de tests puede fallar contra el esquema real.
- Bloqueos y aislamiento: el bloqueo a nivel de fila,
SELECT ... FOR UPDATE, y el comportamiento de los niveles de aislamiento bajo acceso concurrente no se reproducen fielmente en un motor en memoria.
Una suite de tests que está 100% en verde contra H2 todavía puede lanzar una consulta que nunca, ni una sola vez, corrió de verdad contra el motor de base de datos que está corriendo en producción.
#Testcontainers: el motor real, y sigue siendo hermético
Testcontainers levanta la base de datos real de producción — misma imagen, misma versión — dentro de un contenedor Docker acotado a la corrida del test, y lo destruye al terminar. Obtienes fidelidad real sin una base de datos de desarrollo compartida y con estado que los tests puedan corromperse entre sí. El costo es el tiempo de arranque, normalmente mitigado con un patrón de contenedor singleton: levantar un contenedor para todo el proceso de tests y reutilizarlo entre clases de test en lugar de pagar el costo de arranque por cada clase.
@Testcontainersclass OrderRepositoryTest {companion object {@Container@JvmStaticval postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine").withDatabaseName("orders_test").withUsername("test").withPassword("test").withReuse(true) // patrón singleton: sobrevive entre clases de test@JvmStatic@DynamicPropertySourcefun registerProps(registry: DynamicPropertyRegistry) {registry.add("spring.datasource.url", postgres::getJdbcUrl)registry.add("spring.datasource.username", postgres::getUsername)registry.add("spring.datasource.password", postgres::getPassword)}}@Autowiredlateinit var repository: OrderRepository@Testfun `el upsert actualiza los metadatos en el lugar en vez de crear una fila duplicada`() {repository.upsert(orderId = "ord-1", metadataJson = """{"tags": ["urgent"]}""")repository.upsert(orderId = "ord-1", metadataJson = """{"tags": ["urgent", "vip"]}""")assertEquals(1, repository.countByOrderId("ord-1"))assertTrue(repository.findById("ord-1").metadataJson.contains("vip"))}}
// El repositorio bajo prueba depende de una característica real, exclusiva de PostgreSQL:@Query(value = """INSERT INTO orders (order_id, metadata)VALUES (:orderId, CAST(:metadataJson AS JSONB))ON CONFLICT (order_id)DO UPDATE SET metadata = EXCLUDED.metadata""",nativeQuery = true,)fun upsert(orderId: String, metadataJson: String)
Este test pasaría contra H2 corriendo en un modo de compatibilidad con PostgreSQL, con una versión reescrita y simplificada de la consulta — y no te diría nada sobre si la cláusula real ON CONFLICT, el cast real a JSONB, y la restricción única real cooperan de verdad entre sí. Corrido contra Testcontainers, ejercita exactamente el SQL que va a correr en producción.
#Elegir la herramienta correcta según la capa
| Capa bajo prueba | Herramienta correcta | Por qué |
|---|---|---|
| Lógica de negocio que depende de una interfaz de repositorio | Mock / stub | Rápido, aísla la lógica de la persistencia |
| Corrección del repositorio/consultas, migraciones, restricciones | Testcontainers (motor real) | Solo la fidelidad real atrapa bugs reales de SQL |
| Smoke test rápido del cableado del ORM, sin SQL crítico para producción | Base de datos en memoria | Aceptable una vez que aceptas el hueco de fidelidad |
La respuesta más sólida en una entrevista traza la línea de forma explícita: simula en la frontera de la lógica de negocio, usa Testcontainers cada vez que lo que se está probando es la consulta o el esquema en sí, y trata una base de datos en memoria como una comodidad para chequeos de cableado, no como una fuente de verdad sobre corrección.
🧠 Check your understanding
0/2 · 0/2 answered1. ¿Por qué una suite de tests puede pasar al 100% contra una base de datos en memoria H2 y aun así fallar contra PostgreSQL en producción?
2. ¿Para qué se usa el patrón de 'contenedor singleton' en Testcontainers?