Engineering

The cost of mocking the database

Mocked tests pass while production migrations break. Here's a pattern we use to keep tests fast without losing the truth of a real database.

Imran Ali · Principal Engineer · April 18, 2026 · 7 min read

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.

Want more?

Subscribe for one thoughtful essay a month — written by the engineers and designers doing the work.