06 · Containerized Test Environments¶
Level 3 Module 07 flagged a real limitation of H2: it's fast and isolated, but its SQL dialect isn't identical to production databases, so a passing H2 suite can still hide a real-database bug. Testcontainers solves this by running the actual database (or queue, or cache) in a throwaway Docker container for the duration of the test run — real Postgres, real Kafka, real Redis, torn down automatically when the JVM exits.
1. Why not just install Postgres locally?¶
| Approach | Problem |
|---|---|
| Shared dev/staging Postgres | Module 04's (Level 3) Trap 1 — collisions between tests/engineers |
| Locally installed Postgres | Version drift between machines; manual setup; not in CI by default |
| H2 in "Postgres mode" | Closer, but still not the real engine — some features/quirks differ |
| Testcontainers | Real Postgres, exact pinned version, fresh per run, zero manual setup |
2. Setup¶
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
Testcontainers needs a Docker daemon reachable from wherever the tests run
— the local machine's Docker Desktop/Engine, or Docker-in-Docker on a CI
runner. Without one, every test in this module fails at container startup,
not at an assertion — worth checking docker info first when a
Testcontainers suite mysteriously won't even begin.
3. A real Postgres container per test class¶
package com.example.containers;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class EmployeeRepositoryPostgresTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
private Connection connection;
private EmployeeRepository repository; // same class from Level 3 Module 07
@BeforeEach
void setUp() throws SQLException {
connection = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
repository = new EmployeeRepository(connection);
repository.createTable();
}
@AfterEach
void tearDown() throws SQLException {
// real Postgres, unlike per-test H2, needs explicit cleanup between tests
try (Statement st = connection.createStatement()) {
st.execute("DROP TABLE IF EXISTS employee");
}
connection.close();
}
@Test
void insertAndFindByIdAgainstRealPostgres() throws SQLException {
int id = repository.insert("Ada Lovelace", "Engineering", 95000.00);
Optional<Employee> found = repository.findById(id);
assertTrue(found.isPresent());
assertEquals("Ada Lovelace", found.get().name);
}
@Test
void checkConstraintBehavesIdenticallyToH2() {
assertThrows(SQLException.class,
() -> repository.insert("Bad Data", "Engineering", -500.00));
}
}
@Container static scopes one Postgres instance to the whole test class
(started once, reused across @Test methods, torn down when the class
finishes) — a deliberate trade-off: faster than one container per test, at
the cost of needing the explicit DROP TABLE/re-createTable cleanup in
@BeforeEach/@AfterEach that Level 3 Module 07's per-test H2 database got
for free.
I did not run this against real Testcontainers/Docker in this environment
— no Docker daemon available headlessly here. The EmployeeRepository
class and its SQL are identical to the version verified working against H2
in Level 3 Module 07 (6/6 passing there); this module's contribution is the
Testcontainers wiring around it, reviewed against Testcontainers' documented
JUnit 5 extension API, not executed.
4. Comparable output to what H2 produced (Level 3 Module 07), for contrast¶
# Level 3 Module 07, H2, actually run:
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
# This module, real Postgres via Testcontainers, expected shape (not executed here):
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
The point of running both isn't redundancy — H2 gives near-instant feedback
for everyday development; a smaller, targeted Testcontainers suite (often
tagged @Tag("integration"), per Level 4 Module 01's split) runs less often
and catches the narrower class of bugs that are specific to the real
database engine's actual behavior.
5. Testcontainers for other dependencies¶
// Redis
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
// Kafka
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
// A custom app image, e.g. testing against the real inventory-service from Module 03's contract testing
@Container
static GenericContainer<?> inventoryService = new GenericContainer<>("inventory-service:test")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/health").forStatusCode(200));
The waitingFor(...) strategy matters: a container reporting "started" to
Docker doesn't mean the application inside it has finished booting.
Testcontainers' wait strategies (HTTP health check, log message match, port
open) prevent the classic "connection refused" flake of a test starting a
fraction of a second before its dependency is actually ready — the
container equivalent of Level 3 Module 09's "wait for the condition, not a
duration" lesson.
6. Wiring into CI¶
jobs:
integration-tests:
runs-on: ubuntu-latest # GitHub-hosted runners include Docker by default
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '17', distribution: 'temurin' }
- name: Run Testcontainers-backed integration tests
run: mvn test -Dtest.groups=integration
No extra setup needed on GitHub-hosted runners specifically because Docker is already present; a self-hosted runner needs Docker installed and the runner's user given permission to use it.
7. Testing traps¶
Trap 1 — forgetting Docker isn't available everywhere
A Testcontainers-backed suite silently included in the default
mvn test run breaks for any environment without Docker (some
corporate laptops, some minimal CI images). Tag these tests separately
(@Tag("integration"), per Level 4 Module 01) and document the Docker
requirement clearly.
Trap 2 — no cleanup between tests in a class-scoped container
Reusing one container across a test class (section 3) means table/row state from one test leaks into the next unless explicitly cleaned up — exactly Level 3 Module 07's isolation lesson, just at the container level. Decide deliberately between class-scoped (faster, needs cleanup) and per-test containers (slower, free isolation).
Trap 3 — first-run container pull adds surprising latency
The first execution of a Testcontainers suite on a fresh machine or CI cache pulls the image from a registry — potentially tens of seconds to minutes — which can look like the test itself is hanging. Pre-pulling images in a CI cache step avoids this surprise on every cold run.
Trap 4 — treating a container's readiness as the app's readiness
Without an explicit waitingFor(...) health check (section 5),
"container started" and "server ready to accept requests" are treated
as the same event when they aren't — the exact race condition this
module exists to prevent, reintroduced by skipping the one line that
prevents it.
Trap 5 — running full Testcontainers suites on every save
Container startup (seconds) is far slower than an in-memory H2
connection (milliseconds). Running this suite as part of the fast local
loop (Level 4 Module 02's pre-commit hook) defeats the fast-feedback
goal — keep it in the integration/full tier, not unit.
Cheat sheet¶
| Task | Code |
|---|---|
| Postgres container | new PostgreSQLContainer<>("postgres:16-alpine") |
| Redis container | new GenericContainer<>("redis:7-alpine").withExposedPorts(6379) |
| Kafka container | new KafkaContainer(DockerImageName.parse(...)) |
| Class-scoped container | @Container static |
| Per-test container | @Container (non-static) instance field |
| Get connection string | postgres.getJdbcUrl() / .getUsername() / .getPassword() |
| Wait for real readiness | .waitingFor(Wait.forHttp("/health").forStatusCode(200)) |
| Tag as integration, not unit | @Tag("integration") (Level 4 Module 01) |
How It Actually Works¶
Testcontainers works by talking to the Docker Engine API directly over
its Unix socket (or named pipe on Windows) — /var/run/docker.sock — the
exact same API the docker CLI itself calls; there's no special
integration on Docker's side, Testcontainers is simply an HTTP client for
that API written in Java. When your test class starts a
PostgreSQLContainer, the library sends a "create container" request
naming the pinned image, waits (polling, the same repeated-check pattern
as FluentWait in Module 2.02 and Awaitility in Module 3.09) for the
container's health check or a log-line pattern indicating Postgres has
finished booting and is accepting connections, then reads back the
dynamically-assigned host port Docker mapped to Postgres's internal 5432 —
this is why your JDBC URL isn't a fixed localhost:5432, it's built at
runtime from container.getJdbcUrl(), because a fixed port would collide
the moment two test runs (or two developers) tried to start a container
simultaneously.
The "torn down automatically when the JVM exits" guarantee comes from a
JVM shutdown hook Testcontainers registers via
Runtime.getRuntime().addShutdownHook(...), plus a companion "Ryuk"
container Testcontainers itself starts, which monitors your test JVM's
process and force-removes any container it created if the JVM dies
uncleanly (a crash, a killed CI job) rather than exiting gracefully — a
belt-and-suspenders mechanism specifically because a Docker container,
unlike a JVM object, doesn't get garbage collected just because nothing
references it anymore; without Ryuk, a crashed test run would leave
containers running indefinitely, consuming the CI runner's resources
until someone notices.
Exercise¶
- Confirm Docker is available (
docker info) and buildEmployeeRepositoryPostgresTestexactly as above against Level 3 Module 07'sEmployeeRepository; run it and compare the output to the H2-backed version. - Add a test that exists specifically because it might behave differently on real Postgres versus H2 (a date/time edge case, or a specific SQL function) and confirm both versions actually agree — or find a genuine divergence and document it.
- Convert the container from class-scoped (
static) to per-test (non-static) and measure the difference in total suite run time across 5 tests. - Add a
GenericContainerfor Redis, write one test caching a value and reading it back, and add an explicitwaitingFor(...)check. - Tag your Testcontainers-backed tests
@Tag("integration")and confirm they're excluded from thefastMaven profile built in Level 4 Module 01, but included infull.