Testcontainers, and what comes after it
Testcontainers changed a real thing. Before it, testing against a database meant either an in-memory substitute that behaved differently from production, or a shared test database that every developer stepped on. Testcontainers made 'start a real Postgres for this test class' a three-line annotation, and integration tests went from aspirational to routine.
It's good technology. This isn't an argument against it — it's about the specific places it stops fitting, because those places are predictable and the workarounds are worth knowing before you're in them.
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16");
@Test void findsOrdersByCustomer() {
// real Postgres, real SQL, real constraint violations
}
}The nested Docker problem
The first wall, and the one that produces the most confused debugging. Testcontainers needs a Docker daemon. Your CI job frequently runs inside a container. Containers don't have their own Docker daemon.
There are two escape routes and both have real costs.
- Mount the host's Docker socket into the CI container. Now your test job can control the host's Docker daemon — create privileged containers, mount arbitrary host paths, read other jobs' data. In a multi-tenant CI environment that's a full escape from the sandbox, and it's why many managed CI providers won't allow it.
- Docker-in-Docker with a privileged container. That flag disables most of what makes a container a security boundary, and it's slow: nested storage drivers and network layers add real overhead to every image pull and container start.
There's a third symptom that follows from either: networking gets confusing. Containers started by Testcontainers are siblings of your CI container, not children, so `localhost` doesn't reach them and the port mapping is on the host rather than on you. Teams lose hours to this and the error is just a connection refused.
Parallelism and startup cost
The second wall is arithmetic. Starting a Postgres container is a few seconds — image pull if cold, container start, then waiting for the readiness probe. Fine once. Multiply it by a test suite.
200 test classes × 4s container startup = 13 minutes of pure waiting
... plus schema migrations per container = considerably more
... run 8-way parallel on one machine = 8 Postgres instances competing
for the same page cacheThe standard mitigation is to share one container across the whole suite and isolate tests by transaction rollback or truncation. That works and most mature suites do it, but it reintroduces the coupling Testcontainers existed to remove: tests share a database again, so a test that commits leaks state, one that changes a sequence affects the next, and any test needing a different schema version can't participate.
You end up choosing between isolation and speed, which is exactly the trade you hoped to avoid.
The fixture problem
The third wall is the interesting one, and it's where a different primitive genuinely helps rather than merely being an alternative.
Realistic integration tests need realistic data — not three rows, but a populated database with the shape production has. Building that state takes time: run migrations, seed reference data, insert enough records that query plans resemble reality. Thirty seconds if you're efficient, minutes if you're not.
With containers, you pay that per container. The options are all compromises: bake the data into a custom image (rebuild it whenever the schema changes), share one prepared instance (lose isolation), or accept the setup cost every time (lose the afternoon).
What you actually want is to reach the prepared state once and then get many independent copies of it, cheaply. That's a snapshot-and-fork primitive, and it's the thing containers don't offer.
The microVM shape
Build the environment once — the database, the schema, the seed data, any services it depends on — snapshot the whole machine including its memory, then restore a fresh copy per test. Restore is a memory-and-disk clone rather than a boot, so the cost is well under a second and each copy is genuinely independent: separate kernel, separate memory, separate filesystem.
import { Sandbox } from "@pandastack/sdk";
// Once: build the expensive state and freeze it
const base = await Sandbox.create({ template: "postgres-16" });
await base.exec("psql -f /schema.sql && psql -f /seed-500k-rows.sql");
const snapshot = await base.snapshot();
// Per test: a private copy of that exact state, sub-second
for (const testCase of suite) {
const env = await Sandbox.fork(snapshot);
await run(testCase, env);
await env.delete(); // no cleanup logic, no truncation, no rollback
}The properties that differ from the container approach are worth naming precisely, because the difference isn't just speed:
- No cleanup code. The environment is destroyed, not reset. Truncation scripts and transaction-rollback wrappers stop existing, and so does the class of bug where cleanup was incomplete.
- State is captured after setup, not before. A container image can hold your schema; it can't hold a warm buffer cache, a running process mid-way through something, or a service that took forty seconds to become ready.
- Genuine parallel isolation. Each test gets a separate kernel, so a test that fills the disk, exhausts memory, or changes a sysctl affects nothing else.
- Nesting works. Docker runs inside a VM normally, so tests that themselves start containers just work.
When Testcontainers is still the right answer
Most of the time, honestly. It has advantages that matter.
- It runs on a laptop with no account, no network and no platform. That's a genuinely important property for a test suite.
- The ecosystem is enormous — modules for essentially every database, queue and service you might depend on, maintained by people who know each one's readiness quirks.
- For a suite of tens rather than hundreds of tests, the startup cost is irrelevant and none of the walls above are anywhere near.
- It's already in your project and works, which beats any migration that isn't solving a problem you actually have.
The honest decision rule: if your integration suite finishes in a few minutes and CI isn't fighting Docker-in-Docker, there's nothing here to fix. The microVM approach earns its complexity when setup is expensive, isolation is genuinely required, or you've already tried and failed to make nested containers work in your CI.
A reasonable middle
These aren't exclusive, and the split most teams land on is sensible: Testcontainers for local development, where working offline on a laptop matters most, and snapshot-restored environments in CI, where parallelism and setup cost dominate.
Keep the test code identical between them by depending only on a connection URL. If your tests take `DATABASE_URL` and nothing else, swapping what provides it is a configuration change rather than a rewrite — and that indirection is worth building even if you never switch.
Frequently asked questions
Why does Testcontainers struggle inside CI containers?
Because it needs a Docker daemon and containers do not nest. The two workarounds both have costs: mounting the host's Docker socket gives your test job control of the host daemon, which in multi-tenant CI is a complete escape from the sandbox and is why many providers forbid it; Docker-in-Docker requires a privileged container, disabling most of what makes a container a boundary, and adds real overhead through nested storage and network layers. Networking also becomes confusing, since containers started by Testcontainers are siblings of your CI container rather than children.
How do microVM snapshots speed up integration tests?
By letting you pay expensive setup once instead of per test. You build the environment — database, schema, seed data, dependent services — snapshot the entire machine including its memory, then restore a fresh independent copy per test in well under a second. A container image can hold a schema but cannot hold a warm cache, a running process mid-operation, or a service that took forty seconds to become ready. Restoring after setup captures all of that, and each restored copy has its own kernel and filesystem so parallel tests cannot interfere.
Can I run integration tests in parallel with Testcontainers?
Yes, but the economics get awkward. Container startup of a few seconds multiplied across hundreds of test classes becomes minutes of pure waiting, and running many database containers on one machine means they compete for the same page cache. The usual mitigation is sharing one container across the suite with transaction rollback or truncation between tests, which works but reintroduces exactly the coupling Testcontainers was adopted to remove — tests share state again, committed data leaks, and tests needing different schema versions cannot participate.
Should I replace Testcontainers with microVMs?
Probably not, unless you have hit a specific wall. If your integration suite finishes in a few minutes and CI is not fighting Docker-in-Docker, there is nothing to fix. Testcontainers runs on a laptop with no account and no network, has modules for essentially every dependency, and is already working in your project. The microVM approach earns its complexity when fixture setup is expensive, when parallel isolation is genuinely required, or when nested containers in CI have already proved unworkable.
Can I use both Testcontainers and snapshot-based environments?
Yes, and it is a sensible split: Testcontainers locally, where offline laptop development matters most, and snapshot-restored environments in CI, where parallelism and setup cost dominate. Keep the test code identical by having tests depend only on a connection URL supplied through configuration. If a test reads DATABASE_URL and nothing else, swapping what provides it is a configuration change rather than a rewrite — and that indirection is worth building even if you never end up switching.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.