Section Software Development
Published on April 12, 2026
Reading 6 min read
Author Gary Fonseca

Why we stopped mocking databases in integration tests

We burned an afternoon chasing a migration bug that mocked tests had cheerfully approved. Here's the policy we shipped instead, and the cost-benefit it implies.

Why we stopped mocking databases in integration tests

A few quarters ago, our integration test suite was 100% green for a migration that absolutely was not going to ship without taking the production DB down for an hour. The tests mocked the database client. The migration was real. Predictably, the mock and reality diverged.

The principle

Integration tests should hit the same engine as production. Anything less and you are testing your mocks, not your code.

This sounds obvious in writing. In practice it is hard, because real databases are slow to spin up, harder to reset, and require thinking about isolation between tests.

What we changed

  • Every CI worker now gets its own ephemeral Postgres via Testcontainers.
  • Migrations run as part of the suite setup — if the migration fails, the suite fails.
  • Test fixtures use RefreshDatabase (or Pest’s equivalent) so the schema is the source of truth, not a factory()->mock() graph.
  • We added a single guardrail: any test that mocks a Connection, PDO, or DatabaseManager requires explicit approval in code review.

The cost

Our suite went from 38 seconds to 2 minutes 14 seconds. We considered that money well spent — the next migration we shipped, the suite caught a not null constraint we had missed on a 50M-row table, and that would have taken the site down for 90 minutes.

What we still mock

External services we don’t own: payment providers, the Hacienda invoicing API, third-party LLM endpoints. The line is boundaries we cannot run locally. Everything inside the boundary, we run for real.