Testing with Real Dependencies (Testcontainers)

Run repository and integration tests against a real PostgreSQL or Redis in a throwaway Docker container with Testcontainers, started and removed from the test.

Syntax@Testcontainers class RepoIT { @Container static PostgreSQLContainer db = new PostgreSQLContainer("postgres:17-alpine"); }

A repository test exists to prove that your SQL works. Mocking the database cannot do that, and an in-memory database such as H2 only partly can: it differs from PostgreSQL or MySQL in data types, functions, locking, ON CONFLICT handling and error codes. Testcontainers starts the real database in Docker from your test, gives you its connection details, and removes the container when the tests finish.

How it works

Testcontainers is a Java library that talks to a Docker-compatible engine on the machine: Docker Desktop, Docker Engine on Linux, or an alternative such as Podman or Colima. Your CI runners need one as well. A container is mapped to a random free host port, so tests never clash with a database already running locally. A small helper container (Ryuk) removes leftover containers even if the test JVM is killed.

With the JUnit extension, annotate the class with @Testcontainers and the container field with @Container. A static field starts once for the whole class; an instance field starts a new container for every test, which is rarely what you want.

Keeping it fast

  • Start a container once and share it. A static field in a base class, started in a static initialiser, lives for the whole test run.
  • Isolate tests by data, not by container: truncate tables in @BeforeEach, or run each test in a transaction that is rolled back.
  • Pin the image tag to the major version you run in production, such as postgres:17-alpine. latest changes under you.
  • Build the schema with the same migrations production uses, for example by running Flyway against the container once it has started, rather than a hand-kept test schema that drifts. The tests then also prove the migrations work.

Unit or integration?

These tests take seconds rather than milliseconds, mostly for the first container start. Name them *IT and run them with Maven Failsafe in verify, keeping ./mvnw test quick. In Spring Boot, the @ServiceConnection annotation on a container field sets the data source properties for you, so no URL has to be copied by hand.

The examples use Testcontainers 2.0, the line Spring Boot 4 manages. It renamed the artifacts to testcontainers-* (for example testcontainers-postgresql) and moved module classes into their own packages, such as org.testcontainers.postgresql.PostgreSQLContainer, which is no longer generic. Older guides still show PostgreSQLContainer<?> from org.testcontainers.containers; that class is deprecated.

Example

Example · java
import static org.assertj.core.api.Assertions.assertThat;

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.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@Testcontainers
class JdbcCustomerRepositoryIT {

    @Container
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine")
            .withInitScript("db/schema.sql"); // a small script from the test class path

    JdbcCustomerRepository repository;

    @BeforeEach
    void setUp() throws SQLException {
        try (Connection c = connect(); Statement s = c.createStatement()) {
            s.execute("TRUNCATE customers RESTART IDENTITY"); // each test starts empty
        }
        repository = new JdbcCustomerRepository(this::connect);
    }

    Connection connect() throws SQLException {
        return DriverManager.getConnection(
                postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
    }

    @Test
    void savesAndFindsACustomerByEmail() {
        repository.save(new NewCustomer("Ada", "[email protected]"));

        assertThat(repository.findByEmail("[email protected]"))
                .hasValueSatisfying(c -> assertThat(c.name()).isEqualTo("Ada"));
    }

    @Test
    void emailLookupIsCaseInsensitive() {
        repository.save(new NewCustomer("Ada", "[email protected]"));

        assertThat(repository.findByEmail("[email protected]")).isPresent();
    }
}

// The code under test: plain JDBC against the customers table from db/schema.sql
record NewCustomer(String name, String email) {}

record Customer(long id, String name, String email) {}

interface ConnectionFactory {
    Connection get() throws SQLException;
}

class JdbcCustomerRepository {
    private final ConnectionFactory connections;

    JdbcCustomerRepository(ConnectionFactory connections) {
        this.connections = connections;
    }

    void save(NewCustomer customer) {
        try (Connection c = connections.get();
             PreparedStatement ps = c.prepareStatement("INSERT INTO customers (name, email) VALUES (?, ?)")) {
            ps.setString(1, customer.name());
            ps.setString(2, customer.email());
            ps.executeUpdate();
        } catch (SQLException e) {
            throw new IllegalStateException("could not save customer", e);
        }
    }

    Optional<Customer> findByEmail(String email) {
        try (Connection c = connections.get();
             PreparedStatement ps = c.prepareStatement(
                     "SELECT id, name, email FROM customers WHERE lower(email) = lower(?)")) {
            ps.setString(1, email);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next()
                        ? Optional.of(new Customer(rs.getLong("id"), rs.getString("name"), rs.getString("email")))
                        : Optional.empty();
            }
        } catch (SQLException e) {
            throw new IllegalStateException("could not find customer", e);
        }
    }
}

When to use it

  • Prove that a repository's upsert using ON CONFLICT behaves correctly on the same PostgreSQL major version as production.
  • Check that schema migrations apply cleanly to an empty database on every pull request.
  • Test a Redis-backed rate limiter against real Redis instead of an in-memory map that hides expiry behaviour.

More examples

Test dependencies in Maven

The BOM keeps the core library and its modules on one version. The testcontainers-postgresql module adds PostgreSQLContainer with JDBC URL helpers; the driver itself is a separate dependency.

Example · xml
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-bom</artifactId>
      <version>2.0.5</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers-junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers-postgresql</artifactId>
    <scope>test</scope>
  </dependency>
  <!-- The JDBC driver; runtime scope if the application itself uses PostgreSQL -->
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.13</version>
    <scope>test</scope>
  </dependency>
</dependencies>

One container shared by every test class

This pattern trades per-class isolation for speed: ten repository test classes pay for one container start and one migration run instead of ten. Flyway needs flyway-core and flyway-database-postgresql as test dependencies. It does not use @Testcontainers or @Container, because those would stop the container after each class.

Example · java
import org.flywaydb.core.Flyway;
import org.testcontainers.postgresql.PostgreSQLContainer;

abstract class PostgresTestBase {

    // Started once per JVM, the first time any subclass is loaded.
    // Ryuk removes it when the test run ends.
    static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17-alpine");

    static {
        POSTGRES.start();
        // The same migrations production runs, from src/main/resources/db/migration
        Flyway.configure()
                .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
                .load()
                .migrate();
    }
}

class OrderRepositoryIT extends PostgresTestBase {
    // uses POSTGRES.getJdbcUrl(), getUsername() and getPassword()
}

class InvoiceRepositoryIT extends PostgresTestBase {
    // the same running container, no second start-up
}

Any image with GenericContainer

When there is no ready-made module, GenericContainer runs any image and maps the ports you expose. Always ask for getMappedPort(6379); the container's 6379 is published on a different, random host port.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

@Testcontainers
class RateLimiterIT {

    @Container
    static GenericContainer<?> redis =
            new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine"))
                    .withExposedPorts(6379);

    @Test
    void blocksTheEleventhRequestInAMinute() {
        // Host and port come from the container: the port is random on every run
        var limiter = new RedisRateLimiter(redis.getHost(), redis.getMappedPort(6379), 10);

        for (int i = 0; i < 10; i++) {
            assertThat(limiter.tryAcquire("api-key-1")).isTrue();
        }
        assertThat(limiter.tryAcquire("api-key-1")).isFalse();
    }
}

// The class under test: a fixed-window limiter using INCR and EXPIRE,
// speaking the Redis protocol over a socket so it needs no client library
class RedisRateLimiter {
    private final String host;
    private final int port;
    private final int limitPerMinute;

    RedisRateLimiter(String host, int port, int limitPerMinute) {
        this.host = host;
        this.port = port;
        this.limitPerMinute = limitPerMinute;
    }

    boolean tryAcquire(String apiKey) {
        String key = "rate:" + apiKey;
        try (Socket socket = new Socket(host, port)) {
            OutputStream out = socket.getOutputStream();
            var in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
            out.write(command("INCR", key));
            out.write(command("EXPIRE", key, "60", "NX")); // start the window on the first request
            out.flush();
            long count = Long.parseLong(in.readLine().substring(1)); // reply ":<count>"
            in.readLine(); // EXPIRE reply
            return count <= limitPerMinute;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private static byte[] command(String... parts) {
        var sb = new StringBuilder("*").append(parts.length).append("\r\n");
        for (String part : parts) {
            sb.append('$').append(part.getBytes(StandardCharsets.UTF_8).length).append("\r\n")
              .append(part).append("\r\n");
        }
        return sb.toString().getBytes(StandardCharsets.UTF_8);
    }
}

Discussion

  • Be the first to comment on this lesson.