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 afactory()->mock()graph. - We added a single guardrail: any test that mocks a
Connection,PDO, orDatabaseManagerrequires 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.