Most teams don't realize how much trust they're trading away when they mock the database. The seam looks clean — your tests are 50× faster, your CI is green, and the assertion library tells a tidy story. But the moment a production migration runs against a real schema, the lie shows up.
Why mocks fail in practice
A mock encodes what you think the database does. A migration runs against what it actually does. The two diverge silently for months until the day they don't.
- Mocks rarely model transactional behavior, so race conditions get masked.
- Foreign-key cascades and partial indexes never get exercised.
- Type coercion bugs (numeric vs text, timezone-aware vs naive) only surface against the real engine.
What we do instead
We run integration tests against a real Postgres in CI. Spin-up is parallelized per worker, and each test runs inside a transaction that rolls back at the end. The whole suite stays under five minutes for codebases with a thousand tests.
// test-utils/with-tx.ts
export async function withTx<T>(fn: (db: Db) => Promise<T>): Promise<T> {
const tx = await db.startTransaction();
try {
return await fn(tx);
} finally {
await tx.rollback();
}
}Tests are evidence, not theatre. The seam your tests run against decides what evidence you have.
The takeaway
If you can run a real database in CI, you should. Mocks are appropriate for the layers above the database — never for the database boundary itself.