Spring BootTestContainers
Spring Boot

TestContainers

Testcontainers is a Java library that spins up real Docker containers during integration tests. Instead of mocking databases, message brokers, or caches, tests run against actual instances of PostgreSQL, MySQL, Redis, Kafka, and others. Containers start before the test and are destroyed after — giving tests realistic behaviour without requiring a pre-installed external service.

Setup and Dependencies

Testcontainers requires Docker to be running on the machine or CI environment. The BOM manages compatible versions of all module containers. Each technology (PostgreSQL, Kafka, Redis, etc.) has its own module that wraps the official Docker image and exposes a typed Java API.
XML
<!-- pom.xml: -->
<properties>
    <testcontainers.version>1.19.8</testcontainers.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>${testcontainers.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>

    <!-- Core Testcontainers library: -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- JUnit 5 integration (@Testcontainers, @Container): -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Database modules: -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>mysql</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Messaging modules: -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>kafka</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Cache / other modules: -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>redis</artifactId>
        <scope>test</scope>
    </dependency>

</dependencies>

PostgreSQL Container with @DataJpaTest

The most common use case is replacing the in-memory H2 database with a real PostgreSQL container. @DynamicPropertySource injects the container's randomly assigned host and port into the Spring context before the application context starts — no hardcoded ports, no conflicts between parallel test runs.
Java
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {

    // @Container on a static field → one container shared across all tests
    // in this class (started once, stopped after the last test).
    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    // Inject the container's dynamic URL/credentials into Spring properties:
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",      postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private UserRepository userRepository;

    @Test
    void save_persistsUser() {
        User user = User.builder()
            .email("alice@example.com")
            .name("Alice")
            .build();

        User saved = userRepository.save(user);

        assertThat(saved.getId()).isNotNull();
        assertThat(saved.getEmail()).isEqualTo("alice@example.com");
    }

    @Test
    void findByEmail_returnsUser() {
        userRepository.save(User.builder()
            .email("bob@example.com").name("Bob").build());

        Optional<User> found = userRepository.findByEmail("bob@example.com");

        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Bob");
    }

    @Test
    void findByEmail_notFound_returnsEmpty() {
        Optional<User> found =
            userRepository.findByEmail("nobody@example.com");
        assertThat(found).isEmpty();
    }
}

Kafka Container

The Kafka module starts a real Kafka broker in Docker. Tests can produce and consume messages through the actual Kafka protocol, verifying serialisation, topic routing, consumer group behaviour, and exactly-once semantics — none of which can be reliably mocked.
Java
@SpringBootTest
@Testcontainers
class OrderEventPublisherTest {

    @Container
    static KafkaContainer kafka =
        new KafkaContainer(
            DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers",
            kafka::getBootstrapServers);
    }

    @Autowired
    private OrderEventPublisher publisher;

    @Autowired
    private KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;

    @Test
    void publishOrderPlaced_eventConsumedByListener()
            throws InterruptedException {

        // Arrange — use a CountDownLatch to wait for async consumption:
        CountDownLatch latch = new CountDownLatch(1);
        List<OrderPlacedEvent> received = new ArrayList<>();

        // Create a test consumer that records events:
        try (KafkaConsumer<String, String> consumer =
                buildTestConsumer(kafka.getBootstrapServers())) {

            consumer.subscribe(List.of("order.placed"));

            // Act:
            publisher.publish(Order.builder()
                .id(1L)
                .userId(42L)
                .totalAmount(new BigDecimal("99.99"))
                .build());

            // Poll until the event arrives (max 10s):
            long deadline = System.currentTimeMillis() + 10_000;
            while (received.isEmpty() &&
                   System.currentTimeMillis() < deadline) {
                consumer.poll(Duration.ofMillis(200))
                    .forEach(r -> received.add(
                        parseEvent(r.value())));
            }
        }

        // Assert:
        assertThat(received).hasSize(1);
        assertThat(received.get(0).getUserId()).isEqualTo(42L);
        assertThat(received.get(0).getAmount())
            .isEqualByComparingTo("99.99");
    }

    private KafkaConsumer<String, String> buildTestConsumer(
            String brokers) {
        Properties props = new Properties();
        props.put(BOOTSTRAP_SERVERS_CONFIG, brokers);
        props.put(GROUP_ID_CONFIG, "test-consumer-group");
        props.put(AUTO_OFFSET_RESET_CONFIG, "earliest");
        props.put(KEY_DESERIALIZER_CLASS_CONFIG,
            StringDeserializer.class);
        props.put(VALUE_DESERIALIZER_CLASS_CONFIG,
            StringDeserializer.class);
        return new KafkaConsumer<>(props);
    }
}

Reusable Containers and Shared Base Class

Starting a new container for every test class is slow. A shared abstract base class with a static container declaration starts the container once per JVM and reuses it across all test classes. Enabling Testcontainers reuse mode (.withReuse(true)) goes further — the container survives across JVM restarts, making subsequent test runs nearly instant.
Java
// ── Abstract base class — container started once per JVM: ────────────
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class AbstractIntegrationTest {

    // static → shared across all subclasses in the same JVM run:
    @Container
    protected static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test")
            .withReuse(true);   // reuse across JVM restarts

    @Container
    protected static final KafkaContainer KAFKA =
        new KafkaContainer(
            DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
            .withReuse(true);

    @Container
    protected static final GenericContainer<?> REDIS =
        new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379)
            .withReuse(true);

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",      POSTGRES::getJdbcUrl);
        registry.add("spring.datasource.username", POSTGRES::getUsername);
        registry.add("spring.datasource.password", POSTGRES::getPassword);

        registry.add("spring.kafka.bootstrap-servers",
            KAFKA::getBootstrapServers);

        registry.add("spring.data.redis.host", REDIS::getHost);
        registry.add("spring.data.redis.port",
            () -> REDIS.getMappedPort(6379));
    }
}

// ── Subclasses inherit all containers automatically: ──────────────────
class UserRepositoryTest extends AbstractIntegrationTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void save_persistsUser() { ... }
}

class OrderServiceTest extends AbstractIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Test
    void placeOrder_publishesKafkaEvent() { ... }
}

// ── Enable reuse in ~/.testcontainers.properties: ─────────────────────
// testcontainers.reuse.enable=true
//
// With reuse enabled:
//   First run:  containers start (slow ~10s)
//   Next runs:  containers already running → skip startup (~0s)
//   Container identified by hash of its configuration.

GenericContainer for Custom Images

GenericContainer works with any Docker image that does not have a dedicated Testcontainers module. This covers custom microservices, third-party tools, mock servers, and less common infrastructure components. WireMock is a common choice for mocking external HTTP APIs in integration tests.
Java
// ── Redis with GenericContainer: ─────────────────────────────────────
@Testcontainers
@SpringBootTest
class CacheServiceTest {

    @Container
    static GenericContainer<?> redis =
        new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379)
            .waitingFor(Wait.forLogMessage(
                ".*Ready to accept connections.*\n", 1));

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port",
            () -> redis.getMappedPort(6379));
    }

    @Autowired
    private CacheService cacheService;

    @Test
    void set_andGet_returnsValue() {
        cacheService.set("key1", "value1", Duration.ofMinutes(5));
        String value = cacheService.get("key1");
        assertThat(value).isEqualTo("value1");
    }
}

// ── WireMock container for mocking external HTTP APIs: ────────────────
@Testcontainers
@SpringBootTest
class ExternalApiClientTest {

    @Container
    static GenericContainer<?> wireMock =
        new GenericContainer<>("wiremock/wiremock:3.6.0")
            .withExposedPorts(8080)
            .waitingFor(Wait.forHttp("/__admin/mappings")
                .withStartupTimeout(Duration.ofSeconds(30)));

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("external.api.url", () ->
            "http://" + wireMock.getHost() + ":" +
            wireMock.getMappedPort(8080));
    }

    @BeforeEach
    void stubExternalApi() throws Exception {
        String adminUrl = "http://" + wireMock.getHost()
            + ":" + wireMock.getMappedPort(8080)
            + "/__admin/mappings";

        // Register a stub via WireMock Admin API:
        HttpClient.newHttpClient().send(
            HttpRequest.newBuilder(URI.create(adminUrl))
                .POST(HttpRequest.BodyPublishers.ofString("""
                    {
                      "request":  { "method": "GET",
                                    "url": "/api/data" },
                      "response": { "status": 200,
                                    "body": "{\"value\":42}",
                                    "headers": {
                                      "Content-Type":
                                        "application/json" } }
                    }"""))
                .header("Content-Type", "application/json")
                .build(),
            HttpResponse.BodyHandlers.ofString());
    }

    @Autowired
    private ExternalApiClient externalApiClient;

    @Test
    void fetchData_returnsStubResponse() {
        ExternalDataResponse data = externalApiClient.getData();
        assertThat(data.getValue()).isEqualTo(42);
    }
}