Testcontainers: Real Databases in Tests
The stale-accounts report was simple: find the five accounts most overdue for re-engagement. The query was simple too:
SELECT id, name, last_login
FROM users
ORDER BY last_login ASC
LIMIT 5;
The integration test seeded three users — two with a last_login, one NULL because they’d never logged in — ran against H2, and asserted the never-logged-in user came first. Never logged in is the stalest of all, after all. Green. Then it shipped to production on Postgres, and the report quietly dropped every never-logged-in account — the exact users the campaign was built for.
The bug wasn’t in the query. It was in the test database: H2 and Postgres disagree about where NULL sorts. H2 treats NULL as smaller than everything (so ASC puts nulls first); Postgres puts nulls last for ASC by default. The test didn’t fail — it lied, because it ran against a database that sorts differently from the one in production. (Yes, writing ORDER BY last_login ASC NULLS FIRST explicitly would have fixed this one query. But you can’t audit your way out of every semantic difference between two databases — and the differences get worse: ILIKE, JSONB operators, ON CONFLICT upserts, RETURNING clauses. H2 can’t even parse some of them.)
The thesis of this post: test against the real database. Not a fake, not an in-memory lookalike — the actual Postgres, in a throwaway Docker container that your test starts, uses, and destroys. That’s what Testcontainers gives you.
What Testcontainers does
Testcontainers is a Java library that starts real Docker containers from your tests and throws them away when the tests finish. For databases, that means your integration test boots an actual Postgres 16 server, runs your schema scripts against it, executes your real SQL, and then the container disappears — no shared dev database to corrupt, no "works on my machine" drift between developers.
With JUnit 5 it looks like this — two annotations do all the lifecycle work:
@Testcontainers(on the class): tells the JUnit extension to manage container startup and shutdown around your tests.@Container(on a field): marks which container to manage. A static field means one container shared by every test method in the class (started once); a non-static field means a fresh container per test method (slower, rarely what you want for databases).
Prerequisite: Docker must be running on your machine — Docker Desktop, or the Docker daemon on Linux. Testcontainers doesn’t ship a database; it orchestrates one. If docker info doesn’t work in your terminal, stop here and fix that first (the Docker fundamentals are covered in this track’s Docker for Java Devs: Layered Jars & Jib).
Setup: three test dependencies
You need the JUnit 5 engine (covered in this track’s JUnit 5 & Mockito: Testing That Catches Bugs), the Testcontainers JUnit 5 integration, the Postgres module, and the Postgres JDBC driver. Pin everything withthe Testcontainers BOM so the module versions can’t drift apart:
<properties>
<testcontainers.version>1.21.4</testcontainers.version>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.11.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.9</version>
<scope>test</scope>
</dependency>
</dependencies>
Decision rule: keep every one of these test-scoped. Testcontainers and its containers must never leak into your production artifact — they’re testing infrastructure, not runtime dependencies. (The build-tool mechanics behind this file are covered in this track’s Maven vs Gradle: Builds Demystified.)
The centerpiece: a real integration test against Postgres 16
No Spring here — that comes in a later track. Just plain JDBC, so you can see exactly where the database boundary is. First, the schema, as a SQL init script at src/test/resources/init.sql (Testcontainers runs it automatically at container startup):
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Now the complete example — repository, domain record, exception, and the integration test in one listing. In a real project these live in separate files (src/main/java for the repository, src/test/java for the test); they’re shown together so the example is complete:
package com.acme.shop;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.junit.jupiter.api.Assertions.*;
// ---------- src/main/java/com/acme/shop/UserRepository.java ----------
// Plain-JDBC repository. The INSERT ... RETURNING clause is Postgres-specific:
// this code can ONLY be tested against real Postgres, which is the whole point.
public class UserRepository {
private final Connection connection;
public UserRepository(Connection connection) {
this.connection = connection;
}
public User save(String name, String email) {
String sql = "INSERT INTO users (name, email) VALUES (?, ?) RETURNING id";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, name);
ps.setString(2, email);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return new User(rs.getLong("id"), name, email);
}
} catch (SQLException e) {
throw new DataAccessException("save failed", e);
}
}
public Optional<User> findByEmail(String email) {
String sql = "SELECT id, name, email FROM users WHERE email = ?";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, email);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return Optional.of(new User(rs.getLong("id"), rs.getString("name"), rs.getString("email")));
}
return Optional.empty();
}
} catch (SQLException e) {
throw new DataAccessException("findByEmail failed", e);
}
}
}
// ---------- domain record + unchecked wrapper (normally their own files) ----------
record User(long id, String name, String email) {}
class DataAccessException extends RuntimeException {
DataAccessException(String message, Throwable cause) {
super(message, cause);
}
}
// ---------- src/test/java/com/acme/shop/UserRepositoryIT.java ----------
@Testcontainers
class UserRepositoryIT {
// Static = one container for the whole class, started once before the first test.
// Pin the image tag: "latest" today is a different database than "latest" next year.
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("shopdb")
.withUsername("shop")
.withPassword("shopsecret")
.withInitScript("init.sql");
private Connection connection;
private UserRepository repo;
@BeforeEach
void setUp() throws SQLException {
// NEVER hardcode a port: the container maps Postgres to a random free host
// port on every run. getJdbcUrl() already contains the right one.
connection = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
try (Statement st = connection.createStatement()) {
st.execute("TRUNCATE users RESTART IDENTITY");
}
repo = new UserRepository(connection);
}
@AfterEach
void tearDown() throws SQLException {
connection.close();
}
@Test
void saveAssignsDatabaseGeneratedIdAndRoundTrips() {
User saved = repo.save("Asha Verma", "asha@example.com");
assertTrue(saved.id() > 0, "id should come from the database sequence");
Optional<User> found = repo.findByEmail("asha@example.com");
assertTrue(found.isPresent());
assertEquals("Asha Verma", found.get().name());
assertEquals(saved.id(), found.get().id());
}
@Test
void findByEmailReturnsEmptyWhenNobodyRegistered() {
assertTrue(repo.findByEmail("nobody@example.com").isEmpty());
}
@Test
void duplicateEmailViolatesUniqueConstraint() {
repo.save("Asha Verma", "asha@example.com");
assertThrows(DataAccessException.class,
() -> repo.save("Asha Clone", "asha@example.com"),
"the UNIQUE constraint on email must reject the second insert");
}
}
Walking through the pieces that matter:
- The container declaration.
new PostgreSQLContainer<>("postgres:16-alpine")names the exact image.withInitScript("init.sql")loads the schema from the test classpath at startup — the same DDL your migrations would apply. Static@Containermeans Postgres boots once per class, not once per test. - No hardcoded ports, ever. Testcontainers maps the container’s port 5432 to a random free port on your machine.
getJdbcUrl()returns the full URL with that port baked in — this is the single most common source of flaky container tests (trap #1 below). - Real assertions against real semantics. The duplicate-email test asserts that Postgres’s
UNIQUEconstraint actually fires — behavior you want verified against the engine that enforces it in production, not a lookalike. AndRETURNING idinsave()is a Postgres-ism an H2 test could never have compiled, let alone run. - Fresh connection per test.
@BeforeEachopens a new JDBC connection and@AfterEachcloses it, so a broken transaction in one test can’t poison the next. (Wrapping the checkedSQLExceptionin an uncheckedDataAccessExceptionfollows the pattern from the Exceptions post; theUsercarrier type is a record.)
Run it with mvn test. The first run pulls the Postgres image (a one-time download); after that you should see something like:
[INFO] Running com.acme.shop.UserRepositoryIT
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.842 s
[INFO] BUILD SUCCESS
Five seconds for a test that talks to a real database — including container startup. That changes what "integration test" means: it’s no longer a heavyweight ceremony you run on Fridays. It’s a unit-test-speed check you run on every save.
Test isolation: every test starts from a clean database
Notice the TRUNCATE users RESTART IDENTITY in setUp(). It’s the most important line in the test that isn’t about Testcontainers at all. A shared container means shared state: if test A inserts a user and test B counts users, B’s result depends on whether A ran first — and JUnit doesn’t promise method order. Without cleanup you get the classic symptom: green alone, red together.
Decision rule: reset state in @BeforeEach, not @AfterEach. A @BeforeEach reset also cleans up after a test that crashed mid-way, so a failing test can’t poison the next one. TRUNCATE ... RESTART IDENTITY is faster than DELETE and resets the BIGSERIAL sequence, so ids stay deterministic across runs.
The alternative is one schema per test class (create schema, set search_path) — useful when tests need conflicting fixtures, but heavier. Decision rule: start with TRUNCATE in @BeforeEach; reach for per-class schemas only when two test classes genuinely need different seed data that can’t coexist.
Beyond Postgres: the other modules
Postgres is the most common case, but Testcontainers ships modules for most of the infrastructure your code touches. Three worth knowing by name:
- Kafka (
org.testcontainers:kafka) — a real broker for testing producers and consumers: serialization, partitioning, consumer-group rebalances. Reach for it when your code speaks the Kafka protocol; don’t when a unit test with a mocked producer would do. - Redis — no dedicated module; use a
GenericContainerwith"redis:7-alpine"and read the port withgetMappedPort(6379). Reach for it when you test cache expiry, TTLs, or Lua scripts — behavior no mock reproduces. - LocalStack (
org.testcontainers:localstack) — emulates S3, SQS, and other AWS services locally. Reach for it when your code uploads files or publishes messages; it’s the only way to test your AWS SDK calls without an AWS account in the loop.
Decision rule: one container per real dependency your code talks to, and none for things you don’t. Every container costs startup seconds (trap #4), so a test class that spins up Postgres and Kafka and Redis had better be testing the interaction between them — otherwise split it.
A note on CI
Testcontainers needs Docker wherever the tests run — including CI. GitHub’s ubuntu-latest runners ship with Docker preinstalled, so mvn test just works there with no extra setup. The full wiring — workflow files, caching, when to run the heavy DB suite (nightly vs per-PR) — is covered in this track’s CI/CD with GitHub Actions for Java Projects. The one thing to decide now: integration tests that boot containers are slower than unit tests, so most teams run them on every PR anyway (five seconds a class is cheap) and reserve the multi-container suites for the nightly build.
When it breaks: 4 Testcontainers traps
Trap 1 — Hardcoded ports
Symptom: Connection refused or "port already in use" — flaky, worse when two builds run on the same machine. The test does DriverManager.getConnection("jdbc:postgresql://localhost:5432/shopdb", ...) and sometimes the port is taken by another container, another branch’s test run, or a local Postgres you forgot was running.
Diagnosis: any literal 5432 (or any port number) in test code or config is the smell.
Fix: never hardcode. Use postgres.getJdbcUrl() (which embeds the mapped port) for JDBC modules, or container.getMappedPort(6379) for generic containers. The port is random by design — your code should never know it in advance.
Trap 2 — Docker isn’t reachable, or Ryuk is blocked
Symptom: every container test fails immediately with Could not find a valid Docker environment, or hangs at startup with an error mentioning ryuk. Two different causes, same family.
Diagnosis: first run docker info in the same environment as the tests. If that fails, Docker Desktop isn’t running (or the daemon isn’t up) — start it. If Docker works but container tests still hang, you’re in a restricted environment: Testcontainers starts a small sidecar called Ryuk that deletes containers after the JVM exits, and some locked-down CI setups or socket proxies block it.
Fix: for the daemon, start Docker before the tests. For Ryuk, set the environment variable TESTCONTAINERS_RYUK_DISABLED=true — but understand the trade: without Ryuk, crashed test runs leave containers behind, so add a periodic docker container prune to that environment. Only disable Ryuk where you must.
Trap 3 — Test pollution: green alone, red together
Symptom: a test passes when run by itself (mvn -Dtest=UserRepositoryIT#duplicateEmailViolatesUniqueConstraint test) and fails in the full suite — or vice versa. Rows left behind by one test change the assertions of another, and the failure depends on execution order.
Diagnosis: run the failing test alone; if it passes, you have shared state. Look for missing cleanup between tests.
Fix: the TRUNCATE ... RESTART IDENTITY in @BeforeEach from the example above — reset in @BeforeEach, never only in @AfterEach, so even a crashed test leaves a clean slate for the next one.
Trap 4 — A fresh container per test class makes the suite crawl
Symptom: the suite takes minutes, and the logs show Postgres booting once per test class. Each @Container static field starts its own container, so ten test classes means ten Postgres startups.
Diagnosis: count container startups in the log vs. test classes. If they’re 1:1, you’re paying full startup per class.
Fix, in two layers. First, share one container across classes with a singleton holder — a small abstract base class whose static container every test class extends (all classes then reuse the same Postgres). Second, for local development, enable container reuse: call .withReuse(true) on the container and set testcontainers.reuse.enable=true in ~/.testcontainers.properties. Reused containers survive between mvn test runs, so the second run starts in milliseconds. But reuse demands the trap-3 discipline: a reused container keeps its data between runs, so without TRUNCATE in @BeforeEach, yesterday’s rows become today’s mystery failures.
What’s next
You can now write integration tests that run against the real Postgres 16: container lifecycle handled by two annotations, connections built from getJdbcUrl() instead of hardcoded ports, state reset in @BeforeEach, and a mental model of the four ways it breaks. The habit to build is the one from the opening story — whenever a test touches the database, ask: "would this test still pass if the database were lying to me?" If the answer is no, reach for a container.
Field check before you move on: take the UserRepositoryIT above, run mvn test twice in a row, and confirm the second run is fast (image cached, and with reuse enabled, near-instant). Then deliberately break isolation: comment out the TRUNCATE line, run the suite, and watch what happens to duplicateEmailViolatesUniqueConstraint on the second run — that failure is trap #3, and now you know exactly what it looks like.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment