00:00

Integration Testing with Testcontainers: Real Databases in Your Java Tests

Last updated:

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

How Testcontainers runs a container for a test1: the test calls start on a container object. 2: the Testcontainers library calls the Docker engine through its API; Docker first starts the Ryuk helper container. 3: Docker pulls the image if it is missing and runs the postgres:17 container, mapping port 5432 to a random host port. 4: Testcontainers waits until the container is ready. 5: the test connects over JDBC to the host and mapped port. 6: Ryuk removes the containers when the JVM exits. Test JVM your test code Testcontainers library start(), wait, getMappedPort() Docker engine reached via the Docker socket postgres:17 container 5432 inside → random host port Ryuk cleanup helper 1. start() 2. Docker API 3. pull if missing, run starts first 4. wait until ready 5. JDBC to host:mapped port 6. removes containers when the JVM exits
Testcontainers asks Docker to run the image, waits until it is ready, hands the test a random host port, and Ryuk cleans up whatever is left.
  1. The test calls start() on a container object.
  2. 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.
  3. Docker pulls the image if it isn't cached locally, creates the container and maps its ports to random free ports on your machine.
  4. The wait strategy runs. start() returns only when the container is really ready, not just running.
  5. Your test connects using getHost() and getMappedPort(), or helpers such as getJdbcUrl().
  6. Cleanup: stop() or close() removes the container. If the JVM dies first, Ryuk notices the lost connection and removes it anyway.
What happens internally: a running Postgres process is not yet a ready database. The official image starts PostgreSQL once to run its setup scripts, shuts it down, and starts it again for real. So 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.

SeatRepositoryIT.java
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"));
            }
        }
    }
}
Output
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.

DoubleBookingIT.java
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));
            }
        }
    }
}
Output
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.

RateLimiterIT.java
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();
    }
}
Output
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

OptionSame engine as production?Setup per developerIsolation between runsSpeed
MocksNo database at allNoneFullFastest
H2 / in-memory databaseNo, a different engineNoneFullVery fast
Shared test database serverYesNetwork access, credentialsPoor: runs affect each otherFast
Docker Compose, started by handYesMust remember to start itDepends on cleanupFast after startup
TestcontainersYes, the exact versionOnly DockerFull: a fresh container per runSeconds 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 IT suffix) 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 GenericContainer for 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.