Testcontainers
Real Postgres, Kafka and Redis in tests, container reuse, and the CI configuration that makes it fast.
An integration test against H2 proves that your code works against H2. Production runs PostgreSQL 16, whose RETURNING, jsonb, partial indexes, ON CONFLICT, timezone handling and locking rules H2 either lacks or imitates loosely — and every one of those differences is a test that is green on Tuesday and a deploy that fails on Wednesday. Testcontainers starts the real thing in Docker for the duration of the test run, and this repository has run every backend test that way since the Java service was written.
Why the real dependency
The argument is not purity; it is the list of things that only the real database catches. A migration that PostgreSQL rejects (ALTER TABLE ... ADD COLUMN ... NOT NULL without a default on a populated table). A query that uses DISTINCT ON. An entity whose ddl-auto=validate passes against Hibernate's own DDL and fails against the schema Flyway actually created. A SELECT ... FOR UPDATE SKIP LOCKED that H2 parses and does not honour. The same applies one level up: a Kafka listener tested against an in-memory broker has never met a rebalance, and a Redis client tested against a Map has never met a TTL.
The cost is Docker on the developer machine and in CI, and seconds of startup — and the startup is the thing to manage, because it is paid per container, not per test.
The Postgres container, as this repository runs it
@TestConfiguration(proxyBeanMethods = false)
public class PostgresTestcontainer {
@Bean
@ServiceConnection
PostgreSQLContainer postgres() {
PostgreSQLContainer container = new PostgreSQLContainer("postgres:16-alpine");
container.start();
SchemaMigrate.migrate(container.getJdbcUrl(), container.getUsername(), container.getPassword());
return container;
}
}Three decisions are in those lines. postgres:16-alpine is the version production runs, pinned — a floating latest is a test that changes under you. @ServiceConnection (Boot 3.1+) reads the container's host, port and credentials into spring.datasource.* with no @DynamicPropertySource boilerplate; the same annotation works for Kafka, Redis, Mongo and the rest. And the schema is applied by the same migration code the deploy runs, before Spring sees the database, so ddl-auto=validate checks the entities against the schema production actually has rather than one Hibernate generated for the test. A test class @Imports this configuration, or a composed @IntegrationTest annotation does it for all of them.
The alternative form, for a test that owns its container rather than sharing Spring's:
@Testcontainers
class ContentImportTest {
@Container static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine");
...
}static means one container per class; an instance field means one per test method, which is almost never what you want.
Kafka, Redis and the rest
@Bean @ServiceConnection KafkaContainer kafka() { return new KafkaContainer("apache/kafka:3.9.0"); }
@Bean @ServiceConnection GenericContainer<?> redis() { return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379); }The Kafka container is the one that changes what you can test: a real broker means a real consumer group, real rebalances when you start a second listener, real acks=all and a real dead-letter topic. Test the listener from the Kafka course end to end — produce with KafkaTemplate, wait for the side effect with Awaitility (await().atMost(5, SECONDS).untilAsserted(...)), never Thread.sleep — and the retry topic and DLT tests become possible at all. For a module with no Spring, GenericContainer plus getHost() and getMappedPort(6379) is the whole API.
Making it fast: one container per JVM, and reuse
The naive layout starts a container per test class, and a suite of twenty classes spends its time in Docker. Two fixes, in order of how much they buy:
- One container per JVM. A
staticcontainer in a shared base class, or the@ServiceConnectionbean above in a configuration every test imports, is started once and shared: Spring's context cache (the slices lesson) keeps the bean, and the container lives until the JVM exits. Test isolation then comes from rolled-back transactions or aTRUNCATEin@BeforeEach, not from a fresh database. - Reuse across runs.
withReuse(true)on the container plustestcontainers.reuse.enable=truein~/.testcontainers.propertiesleaves the container running after the JVM exits and finds it again on the next run — startup drops from seconds to milliseconds on a developer machine. It is a local convenience, not a CI setting: a reused container carries the last run's state, so it works only with tests that clean up.
For scale, this repository's numbers from one run of the quality gate, on a laptop: the Spring Boot test suite against a Postgres container completes in about 40 seconds including the container's start, and the HTTP contract suite — 260 tests against the built jar and a throwaway docker run Postgres — in about 3 minutes, of which the jar build and the container start are the fixed cost and the tests themselves are seconds each.
CI
CI needs a Docker daemon the tests can reach. On GitHub Actions the Ubuntu runners have one, and nothing else is required; on a Kubernetes-based CI the usual answers are a Docker-in-Docker sidecar or Testcontainers Cloud, and the usual mistake is a runner where DOCKER_HOST is set but the socket is not mounted. Pull the images in a cached layer or accept the first-run pull; pin the tags, as above, so the pull is cacheable at all. And make the tests skip with a message rather than fail when Docker is absent — assumeTrue(DockerClientFactory.instance().isDockerAvailable()) — so a developer without Docker sees "skipped: no Docker" and not a stack trace from deep inside the container library. This repository's guard scripts follow that rule; the Java-fixture guard prints its skip reason and the build carries on.