Your tests use an in-memory database because it is fast and needs no setup. They pass. Production uses a different engine, and a query that works in tests fails there because of a dialect difference, or a constraint that the in-memory version does not enforce, or locking behaviour that only exists in the real thing.
You did not test your code. You tested that your code works against a substitute with different behaviour, which is a weaker claim than it appears.
What changed in 2026
- Container startup got fast enough to stop being the objection. Better image caching and lighter base images cut the overhead that made this approach feel expensive.
- Container reuse became standard practice. Sharing one instance across a suite, rather than per test, turned a slow approach into a fast one.
- CI runners handled it natively. Container support in hosted CI stopped requiring special configuration.
- Testing beyond databases spread. The same pattern got applied to message brokers, object storage, and cache servers, which is where the compound benefit shows.
What substitutes actually miss
| Behaviour |
In-memory substitute |
Real database |
| SQL dialect |
Approximation |
Exact |
| Constraint enforcement |
Often partial |
Complete |
| Transaction isolation |
Simplified |
Real semantics |
| Locking and deadlocks |
Usually absent |
Present |
| Query plans |
Different engine entirely |
Yours |
| Extensions and types |
Rarely supported |
Available |
| Concurrency behaviour |
Different |
Yours |
The rows that matter most are the ones you cannot see failing. A test suite that never exercises real isolation levels will never catch a lost update. One that never encounters real locking will never see a deadlock. These bugs are invisible until production, which is the worst place to find them.
The dialect row is the one that bites soonest and is at least loud — a query that does not parse fails obviously. The subtler ones fail silently in tests and loudly in production.
Keeping it fast
The reputation for slowness comes almost entirely from doing it wrong. Two decisions account for most of the difference.
Reuse the container across the suite. Starting a container takes seconds. Doing that once per test run is fine; doing it once per test is not. Start it in a suite-level fixture, share it, and tear it down at the end.
Isolate with transaction rollback, not truncation. Wrap each test in a transaction and roll it back afterwards. The database returns to its prior state in microseconds, with no table truncation and no re-seeding.
The caveat: this does not work for code that manages its own transactions or needs to test commit behaviour. For those, truncating the affected tables is the fallback — still much faster than recreating the container.
Run migrations once. Apply your schema when the container starts, not per test. That also means your tests exercise your real migration path, which quietly validates it every run — worth having if you use expand and contract for schema changes.
Parallelise with separate schemas or databases inside the same container rather than separate containers, when you need parallel test execution.
Pin the version
A container running a different major version than production reintroduces the gap you adopted this to close. Pin the image tag to your production version, and update it deliberately when you upgrade — which turns your test suite into an early warning for upgrade incompatibilities.
This is a genuine secondary benefit. Bumping the container image and running the suite tells you a great deal about whether an upgrade is safe, before you attempt it anywhere that matters.
Extensions matter too. If production uses a specific extension, the test image must have it, which sometimes means a custom image rather than the stock one.
Common mistakes
- A container per test. The single reason people conclude this is too slow.
- Truncating tables between tests. Slower than transaction rollback for no benefit in most cases.
- Version drift from production. Recreates the gap you removed.
- Using it for pure unit tests. Logic with no database involvement does not need one.
- Not caching images in CI. Pulling on every run dominates the time.
- Sharing mutable state across parallel tests. Separate schemas, or accept serial execution.
- Ignoring container cleanup. Orphaned containers accumulate on developer machines and CI runners.
FAQ
Is this integration testing or unit testing?
Integration, by most definitions, and the labels matter less than the coverage. The practical point is that code whose behaviour depends on a database should be tested against that database, whatever you call the test.
How slow is it really?
With a reused container and transaction rollback, per-test overhead is small — the fixed cost is the one container start. A suite that takes ten seconds with an in-memory substitute might take fifteen. That is a reasonable price for testing the real thing.
Can I use it locally as well as in CI?
Yes, and you should — the whole benefit is that developers and CI run identical environments. It requires a container runtime on developer machines, which is the main adoption friction.
What about testing against a shared development database instead?
Shared databases produce test interference, order dependence, and state nobody can reason about. A per-run container is isolated by construction, which is most of the value.
Does this replace mocking entirely?
No. Mock external services you do not control — third-party APIs, payment providers. Use real containers for infrastructure you own and deploy.
Where to go next
For the concurrency behaviour real databases expose and substitutes do not, read database isolation levels. For the migration path these tests validate, expand and contract migrations, and for the wider pipeline, CI/CD best practices.