Testcontainers: Real Dependencies, Reproducible Tests
Spinning up real Postgres + Kafka + Redis for every test sounds expensive. It isn't, if you do reuse and singletons right.
Testcontainers is the library that took "I'll spin up a real database for this test" from aspirational to fast enough that it's the default. It runs Docker containers from your test code, waits for them to be healthy, hands you the connection details, and tears them down at the end.
1. The naive use (and why it's slow)
@BeforeEach
void setup() {
postgres = new PostgreSQLContainer<>("postgres:16");
postgres.start();
// ... migrate, seed
}
Container per test. Each one takes 2–5 seconds to start. A suite of 500 tests = 30 minutes of container startup. This is where "testcontainers is slow" rumours come from.
2. The right shape: one container per suite
// JUnit 5: singleton container for the whole class hierarchy
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16")
.withReuse(true);
static { POSTGRES.start(); }
The container starts once, all tests share it. Per-test, you isolate via transactions:
@BeforeEach
void beginTransaction() { jdbc.execute("BEGIN"); }
@AfterEach
void rollback() { jdbc.execute("ROLLBACK"); }
Every test sees a clean DB state, but the container itself only starts once. 500 tests now take about as long as 500 transactions — under a minute.
3. Reuse across runs with TC_REUSE
Set testcontainers.reuse.enable=true in ~/.testcontainers.properties and tag the container .withReuse(true). The container survives between test runs on the same machine. Now incremental local development is almost instant — your Postgres is just sitting there.
CI usually keeps the default (no reuse) so each build starts clean.
4. Beyond Postgres: composing real systems
The pattern composes. A common shape for a backend integration suite:
Network net = Network.newNetwork();
PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16")
.withNetwork(net).withNetworkAliases("db");
KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
.withNetwork(net);
GenericContainer redis = new GenericContainer("redis:7-alpine")
.withExposedPorts(6379).withNetwork(net);
postgres.start(); kafka.start(); redis.start();
The whole stack starts in parallel. Your application connects to postgres.getJdbcUrl(), kafka.getBootstrapServers(), redis.getMappedPort(6379). Tear-down is automatic at JVM exit.
5. The traps
5.1 Schema drift between test and prod
Run your real migrations against the container, every test run. If the container is reused, migrate idempotently. The fastest way to ship a broken migration is to seed test DBs from a SQL dump and skip the migration in tests.
5.2 Long pull times in CI
The first run on a new CI image downloads images. Pre-warm them in the CI base image. A 200 MB Postgres pull turning into 2 GB of layers because someone FROM'd a heavy base is real, and visible in the first build of the morning.
5.3 Mismatched versions
Pin the image version. postgres:latest in tests, postgres:16 in prod = the day Postgres 17 ships, your tests break "spontaneously."
5.4 Port collisions
Testcontainers maps ports randomly by default. Use getMappedPort(...) rather than hard-coding 5432 in connection strings.
6. When testcontainers is wrong
- Pure logic units — no DB needed, no container needed. Run them in ms.
- Cloud-only services (DynamoDB, BigQuery) — use a local emulator if one exists (LocalStack covers many AWS services); otherwise fall back to a thin abstraction + recorded fixtures.
- Anything that cannot run in Docker at all (Apple Silicon + some older images can be painful).
The win
When integration tests against real Postgres run in 50ms per test, the entire "we use mocks because real DBs are slow" argument collapses. The team writes more honest tests, catches more real bugs, and ships faster. Testcontainers is one of the few infrastructure-of-testing investments that pays back inside a month.