Testing de integración con Testcontainers: adiós a los mocks frágiles
Los mocks de base de datos dan falsa seguridad: el test pasa pero el código falla en producción. Testcontainers levanta contenedores Docker reales durante los tests, eliminando la brecha entre el entorno de test y producción.
Los mocks de base de datos son cómodos hasta que dejan de serlo. Mockar un repositorio significa que estás testeando que tu código llama al método correcto del mock, no que tu código funciona correctamente con una base de datos real. Las diferencias entre el comportamiento del mock y el comportamiento real del motor de base de datos —transacciones, constraints, índices, comportamiento de tipos— son exactamente donde ocurren los bugs que los tests no detectan.
Testcontainers resuelve esto levantando contenedores Docker reales (PostgreSQL, Redis, MongoDB, Kafka, lo que necesites) como parte de la suite de tests, y eliminándolos al terminar.
Cómo funciona
Testcontainers tiene librerías para Node.js, Python, Java, Go y otros lenguajes. El principio es el mismo: defines qué contenedor necesitas, el test framework lo levanta antes del suite, tu código lo usa como si fuera la base de datos de producción, y todo se elimina al finalizar.
import { PostgreSqlContainer } from "@testcontainers/postgresql"
import { PrismaClient } from "@prisma/client"
describe("UserRepository (integration)", () => {
let container: StartedPostgreSqlContainer
let prisma: PrismaClient
beforeAll(async () => {
// Levanta un contenedor PostgreSQL real
container = await new PostgreSqlContainer("postgres:16")
.withDatabase("testdb")
.withUsername("testuser")
.withPassword("testpass")
.start()
// Conecta Prisma al contenedor
prisma = new PrismaClient({
datasources: {
db: { url: container.getConnectionUri() }
}
})
// Ejecuta las migraciones reales contra este PostgreSQL
await prisma.$executeRaw`CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`
}, 60_000) // timeout generoso para pull de imagen
afterAll(async () => {
await prisma.$disconnect()
await container.stop()
})
it("guarda y recupera un usuario correctamente", async () => {
await prisma.user.create({
data: { email: "test@example.com" }
})
const user = await prisma.user.findUnique({
where: { email: "test@example.com" }
})
expect(user).not.toBeNull()
expect(user?.email).toBe("test@example.com")
})
it("lanza error al insertar email duplicado", async () => {
await prisma.user.create({ data: { email: "dup@test.com" } })
await expect(
prisma.user.create({ data: { email: "dup@test.com" } })
).rejects.toThrow()
})
})El segundo test es la clave
Nota el segundo test: verifica que el constraint de unicidad lanza un error. Con un mock, este test probablemente pasaría si simplemente configuras el mock para que rechace, pero no estarías probando que el constraint existe en el esquema. Con Testcontainers, el constraint lo valida PostgreSQL real —exactamente como en producción.
Consideraciones de velocidad
El primer beforeAll tarda más de lo usual porque descarga la imagen Docker si no está en caché localmente. En CI, esto puede ser un problema si cada suite levanta su propio contenedor. Soluciones:
- Reutilizar el contenedor entre suites con Testcontainers ryuk-disabled y conexión compartida
- Pre-pull de imágenes en CI como paso separado del pipeline
- Paralelizar las suites en CI para que el tiempo de setup se diluya
En máquinas de desarrollo con la imagen en caché, el startup de un contenedor PostgreSQL toma 1-3 segundos —perfectamente aceptable.
Cuándo usar Testcontainers vs mocks
Testcontainers para:
- Lógica que depende de comportamientos específicos del motor (transacciones, constraints, queries complejos)
- Tests de repositorios y servicios de persistencia
- Verificar que las migraciones producen el esquema esperado
Mocks para:
- Aislar la lógica de negocio de la infraestructura en tests unitarios
- Tests de casos de uso donde el repositorio es un detalle que no importa probar
- Tests que deben ser instantáneos y no pueden depender de Docker
No es una elección binaria: una suite bien diseñada tiene ambos, cada uno donde agrega más valor.