{"post":{"slug":"testcontainers-explained-2026","title":"Testcontainers: Testing Against the Real Database","excerpt":"Mocking your database means testing your mock. Running a real one per test suite in a container removes the gap, and the setup cost is smaller than it used to be.","category":"code","date":"August 17, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">Y</span>our 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.\n\nYou 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.\n\n## What changed in 2026\n\n- **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.\n- **Container reuse became standard practice.** Sharing one instance across a suite, rather than per test, turned a slow approach into a fast one.\n- **CI runners handled it natively.** Container support in hosted CI stopped requiring special configuration.\n- **Testing beyond databases spread.** The same pattern got applied to message brokers, object storage, and cache servers, which is where the compound benefit shows.\n\n## What substitutes actually miss\n\n| Behaviour | In-memory substitute | Real database |\n| --- | --- | --- |\n| SQL dialect | Approximation | Exact |\n| Constraint enforcement | Often partial | Complete |\n| Transaction isolation | Simplified | Real semantics |\n| Locking and deadlocks | Usually absent | Present |\n| Query plans | Different engine entirely | Yours |\n| Extensions and types | Rarely supported | Available |\n| Concurrency behaviour | Different | Yours |\n\nThe rows that matter most are the ones you cannot see failing. A test suite that never exercises real [isolation levels](/blog/database-isolation-levels-2026) 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.\n\nThe 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.\n\n## Keeping it fast\n\nThe reputation for slowness comes almost entirely from doing it wrong. Two decisions account for most of the difference.\n\n**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.\n\n**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.\n\nThe 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.\n\n**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](/blog/expand-contract-migrations-2026) for schema changes.\n\n**Parallelise with separate schemas or databases** inside the same container rather than separate containers, when you need parallel test execution.\n\n## Pin the version\n\nA 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.\n\nThis 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.\n\nExtensions 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.\n\n## Common mistakes\n\n- **A container per test.** The single reason people conclude this is too slow.\n- **Truncating tables between tests.** Slower than transaction rollback for no benefit in most cases.\n- **Version drift from production.** Recreates the gap you removed.\n- **Using it for pure unit tests.** Logic with no database involvement does not need one.\n- **Not caching images in CI.** Pulling on every run dominates the time.\n- **Sharing mutable state across parallel tests.** Separate schemas, or accept serial execution.\n- **Ignoring container cleanup.** Orphaned containers accumulate on developer machines and CI runners.\n\n## FAQ\n\n**Is this integration testing or unit testing?**\nIntegration, 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.\n\n**How slow is it really?**\nWith 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.\n\n**Can I use it locally as well as in CI?**\nYes, 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.\n\n**What about testing against a shared development database instead?**\nShared 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.\n\n**Does this replace mocking entirely?**\nNo. Mock external services you do not control — third-party APIs, payment providers. Use real containers for infrastructure you own and deploy.\n\n## Where to go next\n\nFor the concurrency behaviour real databases expose and substitutes do not, read [database isolation levels](/blog/database-isolation-levels-2026). For the migration path these tests validate, [expand and contract migrations](/blog/expand-contract-migrations-2026), and for the wider pipeline, [CI/CD best practices](/blog/ci-cd-best-practices-2026).","hue":306,"metaTitle":"Testcontainers: Testing Against a Real Database","metaDescription":"Why in-memory database substitutes miss real bugs, how to keep container-based tests fast with reuse and transaction rollback, and when not to use them.","keywords":["testcontainers","integration testing database","in memory database testing","docker test containers","test isolation strategy","ci database testing","realistic test environment","testing practices 2026"],"tldr":["**An in-memory substitute tests different behaviour** — dialect differences, constraint enforcement, and locking all diverge from production.","**Reuse one container across a suite** — starting a container per test is where the reputation for slowness comes from.","**Roll back in a transaction rather than truncating** — the fastest way to isolate tests against a shared container.","**Pin the image to your production version** — testing against a different major version reintroduces the gap you removed.","**Skip:** containers for pure unit tests. This is for code that genuinely talks to a database."],"readTime":"4 min read","wordCount":980},"related":[{"slug":"integration-testing-explained-2026","title":"Integration testing explained in 2026: test the seams that unit tests miss","excerpt":"Integration tests catch the failures that happen when two correct pieces connect incorrectly. Here is the 2026 guide to writing fast, reliable integration tests.","category":"code","date":"April 6, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":305},{"slug":"how-to-write-integration-tests-2026","title":"How to write integration tests in 2026","excerpt":"Integration tests catch the bugs unit tests miss by exercising real component boundaries. Here is a practical 2026 guide with patterns that actually ship.","category":"code","date":"March 2, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":305},{"slug":"how-to-write-good-tests-2026","title":"How to write good tests in 2026: testing pyramid revisited, AAA, real smells","excerpt":"The testing pyramid still works in 2026 — most teams just apply it wrong. Here is the practical guide to writing tests that actually pay back.","category":"code","date":"February 11, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":148}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"sports-betting-taxes-explained-2026","title":"Sports Betting Taxes: The Rule That Surprises Winners","excerpt":"Gambling winnings are taxable income and losses are only deductible under narrow conditions. Here is why a break-even year can still produce a tax bill.","category":"finance","date":"August 17, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":38},"older":{"slug":"thread-1-4-explained-2026","title":"Thread 1.4 Explained: Fixing the Multi-Border Mess","excerpt":"Thread meshes worked until you had two border routers from different vendors. Here is what 1.4 fixes, what still needs vendor cooperation, and whether to upgrade.","category":"tech","date":"August 17, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":228}}