Integration Testing with Testcontainers: Real Databases in Your Java Tests
Integration testing with Testcontainers means your Java tests start real infrastructure, such as PostgreSQL, Redis or Kafka, in throwaway Docker containers, run against it, and remove it afterwards. Testcontainers is an open-source Java library that does the starting, waiting and cleaning up for you. Your tests check the real SQL, the real constraints and the real network calls, not a mock's idea of them.
In microservices, each service owns its database and talks to brokers and caches. Most bugs hide in those conversations, so testing them for real matters.
1. Why Mocks and In-Memory Databases Are Not Enough
Imagine a cinema booking service. Its repository has a method that books seat F12 for a screening. With a mocked repository, the unit test passes, because the mock does whatever you tell it. With an in-memory database such as H2, the test passes too, until the code uses something PostgreSQL-specific: an ON CONFLICT clause, a JSONB column, a partial index, or locking behaviour under concurrent requests. H2 understands only part of PostgreSQL's SQL and behaves differently in several places.
So the tests are green, and production fails on the first query H2 couldn't check. The fix is simple to say: test against the same database engine and version you run in production. Testcontainers makes that practical, because each test run gets a fresh, real database in a few seconds and nobody has to install or share one.
2. Key Terms
- Docker image: a packaged piece of software, such as
postgres:17, ready to run. - Container: a running, isolated instance of an image. It is cheap to start and to throw away.
- Mapped port: the port on your machine that forwards to a port inside the container. Testcontainers picks a free random one so parallel test runs never clash.
- Wait strategy: the rule Testcontainers uses to decide that a container is ready, such as "this log line appeared" or "this HTTP endpoint answers".
- Ryuk: a small helper container that Testcontainers starts alongside yours. It removes all containers from a test run when the JVM exits, even if the JVM crashes.
- Integration test: a test that checks your code together with a real external part, such as a database or a message broker, instead of a fake.
3. How Integration Testing with Testcontainers Works
- The test calls
start()on a container object. - Testcontainers talks to Docker through its API, usually the Unix socket
/var/run/docker.sock. On the first container of a run it also starts Ryuk. - Docker pulls the image if it isn't cached locally, creates the container and maps its ports to random free ports on your machine.
- The wait strategy runs.
start()returns only when the container is really ready, not just running. - Your test connects using
getHost()andgetMappedPort(), or helpers such asgetJdbcUrl(). - Cleanup:
stop()orclose()removes the container. If the JVM dies first, Ryuk notices the lost connection and removes it anyway.
PostgreSQLContainer waits until the log line "database system is ready to accept connections" appears twice. A home-made script that connects after the first message often fails randomly with "connection refused". The module also starts Postgres with fsync=off, which makes writes faster in tests and would be unsafe in production.4. Code Examples
These programs use Testcontainers 2.0.5 with Java 21 and need a running Docker engine. Each uses a plain main method so you can see every step; Q1 shows the usual JUnit 5 form. Add these Maven dependencies: org.testcontainers:testcontainers-postgresql:2.0.5 and org.postgresql:postgresql.
4.1 Example 1: A Repository Against Real PostgreSQL
The code under test is a tiny seat-booking repository. The container is created, used and removed inside one try block.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import org.testcontainers.postgresql.PostgreSQLContainer;
public class SeatRepositoryIT {
// The code under test: a tiny repository for cinema seat bookings
record Booking(String seat, String customer) {}
static void book(Connection db, int screening, String seat, String customer) throws Exception {
try (PreparedStatement ps = db.prepareStatement(
"INSERT INTO booking (screening_id, seat, customer) VALUES (?, ?, ?)")) {
ps.setInt(1, screening);
ps.setString(2, seat);
ps.setString(3, customer);
ps.executeUpdate();
}
}
static List<Booking> bookingsFor(Connection db, int screening) throws Exception {
List<Booking> result = new ArrayList<>();
try (PreparedStatement ps = db.prepareStatement(
"SELECT seat, customer FROM booking WHERE screening_id = ? ORDER BY seat")) {
ps.setInt(1, screening);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) result.add(new Booking(rs.getString(1), rs.getString(2)));
}
}
return result;
}
public static void main(String[] args) throws Exception {
// try-with-resources stops and removes the container at the end
try (PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17")) {
postgres.start(); // pulls the image if needed, then waits until Postgres accepts connections
System.out.println("Container running: " + postgres.isRunning());
try (Connection db = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
try (Statement st = db.createStatement()) {
st.execute("""
CREATE TABLE booking (
id BIGSERIAL PRIMARY KEY,
screening_id INT NOT NULL,
seat TEXT NOT NULL,
customer TEXT NOT NULL,
UNIQUE (screening_id, seat))""");
ResultSet rs = st.executeQuery("SHOW server_version_num");
rs.next();
System.out.println("PostgreSQL major version: " + rs.getInt(1) / 10_000);
}
book(db, 7, "F12", "Meera");
book(db, 7, "F11", "Jonas");
book(db, 8, "A1", "Leila");
List<Booking> found = bookingsFor(db, 7);
System.out.println("Bookings for screening 7: " + found);
System.out.println("Sorted by seat and filtered: "
+ (found.equals(List.of(new Booking("F11", "Jonas"), new Booking("F12", "Meera"))) ? "PASS" : "FAIL"));
}
}
}
}
Container running: true
PostgreSQL major version: 17
Bookings for screening 7: [Booking[seat=F11, customer=Jonas], Booking[seat=F12, customer=Meera]]
Sorted by seat and filtered: PASS
There is no JDBC URL, user or password in the code. getJdbcUrl() contains the random mapped port, and the defaults for database, user and password are all test. On a laptop with the image already cached, the whole program takes about two seconds.
4.2 Example 2: The Bug a Mock Can't Catch
Ten customers press "Book F12" at the same moment. The repository relies on the database's UNIQUE constraint, and a second method uses PostgreSQL's ON CONFLICT syntax.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.testcontainers.postgresql.PostgreSQLContainer;
public class DoubleBookingIT {
static PostgreSQLContainer postgres;
static Connection connect() throws SQLException {
return DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
// Version 1: plain insert, relies on the UNIQUE constraint to stop double bookings
static String book(String seat, String customer) {
try (Connection db = connect();
PreparedStatement ps = db.prepareStatement(
"INSERT INTO booking (screening_id, seat, customer) VALUES (7, ?, ?)")) {
ps.setString(1, seat);
ps.setString(2, customer);
ps.executeUpdate();
return "booked";
} catch (SQLException e) {
return "rejected, SQLState " + e.getSQLState(); // 23505 = unique_violation in PostgreSQL
}
}
// Version 2: PostgreSQL-specific upsert syntax that a mock or H2 would not check for you
static boolean tryBook(Connection db, String seat, String customer) throws SQLException {
try (PreparedStatement ps = db.prepareStatement("""
INSERT INTO booking (screening_id, seat, customer) VALUES (7, ?, ?)
ON CONFLICT (screening_id, seat) DO NOTHING
RETURNING id""")) {
ps.setString(1, seat);
ps.setString(2, customer);
try (ResultSet rs = ps.executeQuery()) {
return rs.next(); // a row comes back only if the insert happened
}
}
}
public static void main(String[] args) throws Exception {
try (PostgreSQLContainer pg = new PostgreSQLContainer("postgres:17")) {
pg.start();
postgres = pg;
try (Connection db = connect()) {
db.createStatement().execute("""
CREATE TABLE booking (id BIGSERIAL PRIMARY KEY, screening_id INT NOT NULL,
seat TEXT NOT NULL, customer TEXT NOT NULL, UNIQUE (screening_id, seat))""");
// Ten customers press "Book F12" at the same moment, each on its own connection
CountDownLatch go = new CountDownLatch(1);
List<Future<String>> results = new ArrayList<>();
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 10; i++) {
String customer = "customer-" + i;
results.add(pool.submit(() -> { go.await(); return book("F12", customer); }));
}
go.countDown();
}
long booked = 0, rejected = 0;
for (Future<String> f : results) {
if (f.get().equals("booked")) booked++;
else if (f.get().equals("rejected, SQLState 23505")) rejected++;
}
System.out.println("Race for F12: booked=" + booked + ", rejected with 23505=" + rejected);
System.out.println("Meera books G1: " + tryBook(db, "G1", "Meera"));
System.out.println("Jonas books G1: " + tryBook(db, "G1", "Jonas"));
ResultSet rs = db.createStatement().executeQuery("SELECT customer FROM booking WHERE seat = 'G1'");
rs.next();
System.out.println("G1 belongs to: " + rs.getString(1));
}
}
}
}
Race for F12: booked=1, rejected with 23505=9
Meera books G1: true
Jonas books G1: false
G1 belongs to: Meera
Exactly one booking wins, and the other nine get SQLState 23505, PostgreSQL's code for a unique violation. A mocked repository would have accepted all ten. The ON CONFLICT ... RETURNING query is checked by the real parser, so a typo or an unsupported clause fails here, not in production.
4.3 Example 3: A Generic Container Shared Across Test Classes
Not every product has a ready-made module. GenericContainer runs any image. Here Redis backs a rate limiter that allows three password-reset emails per hour, and one container is shared by two test classes.
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
public class RateLimiterIT {
// One container shared by every test class in the run (the "singleton container" pattern)
static final GenericContainer<?> REDIS = new GenericContainer<>("redis:8.2")
.withExposedPorts(6379) // port inside the container
.waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1));
// A minimal Redis client: sends one command, returns the first reply line
static String redis(String... command) throws Exception {
try (Socket socket = new Socket(REDIS.getHost(), REDIS.getMappedPort(6379))) { // random port on the host
StringBuilder resp = new StringBuilder("*" + command.length + "\r\n");
for (String part : command) resp.append("$").append(part.length()).append("\r\n").append(part).append("\r\n");
OutputStream out = socket.getOutputStream();
out.write(resp.toString().getBytes(StandardCharsets.UTF_8));
out.flush();
return new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)).readLine();
}
}
// Code under test: allow at most 3 password-reset emails per user per hour
static boolean allowResetEmail(String user) throws Exception {
String key = "reset-limit:" + user;
long count = Long.parseLong(redis("INCR", key).substring(1)); // integer replies look like ":3"
if (count == 1) redis("EXPIRE", key, "3600"); // start the one-hour window
return count <= 3;
}
static void limiterTestClass() throws Exception {
List<Boolean> answers = new ArrayList<>();
for (int i = 0; i < 5; i++) answers.add(allowResetEmail("dev@example.com"));
System.out.println(" five requests: " + answers);
System.out.println(" 4th and 5th blocked: " + (!answers.get(3) && !answers.get(4) ? "PASS" : "FAIL"));
}
static void otherUserTestClass() throws Exception {
System.out.println(" first request for another user allowed: "
+ (allowResetEmail("ops@example.com") ? "PASS" : "FAIL"));
}
public static void main(String[] args) throws Exception {
REDIS.start(); // started once; Ryuk removes it when the JVM exits
System.out.println("PING -> " + redis("PING"));
System.out.println("Host port differs from 6379: " + (REDIS.getMappedPort(6379) != 6379));
System.out.println("LimiterTest:");
limiterTestClass();
redis("FLUSHALL"); // reset shared state between test classes
System.out.println("OtherUserTest:");
otherUserTestClass();
}
}
PING -> +PONG
Host port differs from 6379: true
LimiterTest:
five requests: [true, true, true, false, false]
4th and 5th blocked: PASS
OtherUserTest:
first request for another user allowed: PASS
Three details matter. The test never assumes port 6379 on the host; it asks for the mapped port. The wait strategy waits for Redis's own "Ready to accept connections" log line. And because both test classes share one container, the program clears the data between them with FLUSHALL; without that step, the second class would start with the first class's counters.
5. Testcontainers vs Other Options
| Option | Same engine as production? | Setup per developer | Isolation between runs | Speed |
|---|---|---|---|---|
| Mocks | No database at all | None | Full | Fastest |
| H2 / in-memory database | No, a different engine | None | Full | Very fast |
| Shared test database server | Yes | Network access, credentials | Poor: runs affect each other | Fast |
| Docker Compose, started by hand | Yes | Must remember to start it | Depends on cleanup | Fast after startup |
| Testcontainers | Yes, the exact version | Only Docker | Full: a fresh container per run | Seconds per container |
Testcontainers doesn't replace unit tests or contract tests. Unit tests check logic; contract tests check the agreement between services; Testcontainers checks your service with its real infrastructure.
6. Common Mistakes and Pitfalls
1. Starting a new container for every test method. That costs seconds per test. Start one container per test class, or one per JVM with the singleton pattern from Example 3, and clean the data between tests instead.
2. Hard-coding ports. localhost:5432 works on your laptop and fails in CI, where another job may use that port. Always use getMappedPort() or getJdbcUrl().
3. Using latest tags. postgres:latest changes under you, and a test may break on a Monday morning for no code reason. Pin the version you run in production, such as postgres:17.
4. Forgetting about shared state. A shared container is fast, but leftover rows from one test can make another test pass or fail. Truncate tables, flush caches or use unique test data.
5. Turning on reuse in CI. withReuse(true) keeps containers alive between runs, which is handy locally. It only works when testcontainers.reuse.enable=true is set in ~/.testcontainers.properties, and reused containers are not removed by Ryuk, so on CI agents they pile up.
6. Disabling Ryuk without a reason. Some locked-down CI systems need TESTCONTAINERS_RYUK_DISABLED=true. Without Ryuk, a crashed build leaves containers running, so add a cleanup step.
7. Best Practices
- Use the same image and version as production, ideally taken from one shared constant.
- Prefer ready-made modules, such as PostgreSQL, Kafka or MongoDB, which know the right wait strategy.
- Start containers once per class or JVM, not once per test.
- Keep tests independent by cleaning data, not by restarting containers.
- Apply your real schema migrations, with Flyway or Liquibase, so tests check the same schema as production.
- Run these tests in CI on agents that have Docker, and split them from unit tests (for example with Maven Failsafe and an
ITsuffix) so fast feedback stays fast.
8. Summary
- Integration testing with Testcontainers runs real infrastructure in throwaway Docker containers from your Java tests.
- It catches bugs that mocks and in-memory databases hide: SQL dialect, constraints, concurrency.
- Testcontainers maps random ports, waits until containers are ready, and cleans up through Ryuk.
- Use modules where they exist and
GenericContainerfor anything else. - Share containers across tests, but clean data between them.
- Pin image versions to match production.
Pick the one repository or message listener that has caused the most production bugs and write a single test for it with a real container. Integration testing with Testcontainers usually pays for itself the first time it catches a bug that every mock missed.
Integration Testing with Testcontainers: Interview Questions
Common interview questions on Integration Testing with Testcontainers, from basic to scenario-based, with short answers.
Basic questions
Q. How do you use Testcontainers with JUnit 5?
Answer: Add testcontainers-junit-jupiter, annotate the class with @Testcontainers and the container field with @Container. A static field is started once for the class; an instance field is restarted for every test method.
@Testcontainers
class SeatRepositoryTest {
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
}
Q. How do you connect a Spring Boot application to a container in tests?
Answer: Since Spring Boot 3.1, annotate the container with @ServiceConnection, and Spring Boot sets the data source URL, user and password automatically. In older versions, use @DynamicPropertySource to register spring.datasource.url and the other properties from the container's getters.
Q. Can Testcontainers run without Docker Desktop?
Answer: Yes. It needs a Docker-compatible API, which can come from Docker Engine on Linux, Colima, Podman, Rancher Desktop or a remote host set through DOCKER_HOST. Testcontainers Cloud can also run containers remotely for machines where local Docker isn't allowed.
Intermediate questions
Q. What changed in Testcontainers 2.0?
Answer: Module artifacts now start with testcontainers-, for example testcontainers-postgresql. Container classes moved into module-specific packages, such as org.testcontainers.postgresql.PostgreSQLContainer, and the old classes are deprecated. The core library no longer depends on JUnit 4.
Q. What is the difference between @Testcontainers with a static field and the singleton container pattern?
Answer: A static @Container field lives for one test class and is stopped after it. The singleton pattern starts the container in a static initializer of a shared base class and never stops it explicitly, so all test classes in the JVM reuse it and Ryuk removes it at the end. The singleton pattern is faster for large suites but needs careful data cleanup.
Q. How do you test code that publishes to Kafka?
Answer: Start a Kafka container with the Kafka module, point the producer at getBootstrapServers(), call the code under test and consume from the topic with a real consumer, waiting with a timeout. Assert on the received message. This checks serialization, topic names and headers, which a mocked KafkaTemplate would not.
Q. How do you load a schema or test data into a database container?
Answer: Run your real migrations (Flyway or Liquibase) against the container at startup, which also tests the migrations themselves. For small fixtures, the JDBC modules support withInitScript("schema.sql"), and you can also run SQL directly through JDBC in a setup method.
Q. How can tests run in parallel without interfering with each other?
Answer: Each container gets its own random host port, so separate containers never clash. Problems come from sharing one container: give each test its own data, such as unique ids or a separate schema, or run tests that share a container sequentially.
Q. What does a wait strategy do, and when do you write your own?
Answer: It decides when start() may return. Built-in strategies wait for a listening port, a log message, an HTTP status or a Docker health check. Write your own, or choose a different built-in one, when an image reports its port open before it can actually serve requests.
new GenericContainer<>("my-search:3.1")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/ready").forStatusCode(200));
Tricky and scenario questions
Q. Scenario: Integration tests pass locally but fail on CI with "Could not find a valid Docker environment". What do you check?
Answer: The CI agent has no reachable Docker engine. Check that Docker is installed and running on the agent, that the build user can access the socket, and, for builds that themselves run inside a container, that the Docker socket is mounted or DOCKER_HOST is set. Also check whether the agent blocks the Ryuk container.
Q. Scenario: The test suite now takes 12 minutes because of containers. How do you speed it up?
Answer: Share containers per JVM with the singleton pattern instead of starting them per class, and clean data instead of restarting. Use withReuse(true) locally, keep images cached on CI agents, and run unit tests in a separate, faster stage. Use lighter images where that doesn't change behaviour, such as an Alpine variant of the same version.
Q. Scenario: A test fails randomly with "connection refused" right after the container starts. What is wrong?
Answer: The wait strategy says "ready" too early, often because a custom or generic strategy only checks that the port is open. Wait for the real readiness signal: a specific log line, an HTTP readiness endpoint or the image's health check. Also make sure the test uses the mapped port, not the container port.
Q. Should you use Testcontainers for end-to-end tests of the whole system?
Answer: You can start several services and their dependencies on one Docker network, but such tests become slow and fragile like any end-to-end suite. Use Testcontainers mainly to test one service with its own infrastructure, and rely on contract tests for the agreements between services.