Testcontainers: Custom Images, Reusable Containers & Docker Compose

Testcontainers starts real Docker containers inside JUnit tests (a PostgreSQL database, Kafka broker, Redis cache, or any Docker image) and tears them down automatically after. This eliminates the gap between mocked dependencies and production behaviour, making integration tests genuinely trustworthy.

Share

Contents

Dependency

Add the following to your pom.xml (Maven) or build.gradle (Gradle). Spring Boot manages compatible versions automatically when you use the Spring Boot BOM.

XML
<!-- BOM — manages all Testcontainers module versions -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-bom</artifactId>
      <version>1.20.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>  <!-- @Testcontainers, @Container -->
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>kafka</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

PostgreSQL Container

Annotate the test class with @Testcontainers and declare the container as a static @Container field so it is shared across all test methods. Use @DynamicPropertySource to wire the container's JDBC URL into Spring's environment.

Java
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import static org.assertj.core.api.Assertions.assertThat;

@Testcontainers
class UserRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test")
        .withInitScript("db/schema.sql");   // runs on container start

    @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 UserRepository userRepo;

    @Test
    void savesAndFindsUser() {
        User user = userRepo.save(new User("Alice", "alice@example.com"));
        assertThat(user.id()).isNotNull();
        assertThat(userRepo.findByEmail("alice@example.com")).isPresent();
    }
}
Declare the container static so one container is started per test class rather than per test method. Starting Docker containers is slow, sharing them across methods keeps the suite fast while still using a real database.

Kafka Container

The class below shows the implementation. Key points are highlighted in the inline comments.

Java
import org.testcontainers.kafka.KafkaContainer;
import org.testcontainers.utility.DockerImageName;

@Testcontainers
@SpringBootTest
class OrderEventListenerTest {

    @Container
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("apache/kafka:3.7.0"));

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

    @Autowired KafkaTemplate<String, String> kafkaTemplate;
    @Autowired OrderEventListener listener;

    @Test
    void consumesOrderEvent() throws Exception {
        kafkaTemplate.send("orders", "o1", "{\"product\":\"Widget\",\"qty\":2}");

        // Wait up to 5 s for the listener to process
        await().atMost(5, TimeUnit.SECONDS)
               .untilAsserted(() -> assertThat(listener.processedCount()).isEqualTo(1));
    }
}

Generic & Custom Docker Images

Use GenericContainer for any Docker image that doesn't have a dedicated Testcontainers module. Chain withExposedPorts, withEnv, and waitingFor to configure readiness.

Java
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;

@Testcontainers
class RedisCacheTest {

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

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

    @Test
    void cachesValue() {
        // test with real Redis
    }
}

Build a custom image from a Dockerfile in the test resources:

Java
import org.testcontainers.images.builder.ImageFromDockerfile;

GenericContainer<?> customApp = new GenericContainer<>(
    new ImageFromDockerfile()
        .withDockerfileFromBuilder(builder -> builder
            .from("eclipse-temurin:21-jre-alpine")
            .copy("app.jar", "/app.jar")
            .cmd("java", "-jar", "/app.jar")
            .build())
        .withFileFromClasspath("app.jar", "test-app.jar"))
    .withExposedPorts(8080)
    .waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200));

Reusable Containers

Reusable containers survive across test runs by skipping the stop/remove on JVM exit. This dramatically speeds up local development: the container is only started once and reused on every subsequent test run until manually stopped.

Java
// SharedContainers.java — a central class holding reusable containers
public final class SharedContainers {

    public static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
            .withReuse(true);          // key flag — keeps the container alive between runs
        POSTGRES.start();
    }

    private SharedContainers() {}
}
Java
// Test class — uses the shared container
class ProductRepositoryTest {

    static {
        // Ensures the shared container is started before Spring wires DataSource
        SharedContainers.POSTGRES.start();
    }

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

    @Test
    void test() { /* runs against the shared container */ }
}
Reusable containers require testcontainers.reuse.enable=true in ~/.testcontainers.properties on the developer machine. CI environments should set this property too, or leave it unset to always start fresh containers in CI (recommended for isolation).

Docker Compose

ComposeContainer (Testcontainers 1.19+) starts a docker-compose.yml file and exposes each service's mapped port. This lets integration tests mirror a real multi-service topology.

YAML
# src/test/resources/compose-test.yml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    ports:
      - "5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379"
Java
import org.testcontainers.containers.ComposeContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import java.io.File;

@Testcontainers
class FullStackTest {

    @Container
    static ComposeContainer env = new ComposeContainer(
            new File("src/test/resources/compose-test.yml"))
        .withExposedService("postgres", 5432,
            Wait.forListeningPort())
        .withExposedService("redis", 6379,
            Wait.forListeningPort());

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", () ->
            "jdbc:postgresql://" + env.getServiceHost("postgres", 5432)
            + ":" + env.getServicePort("postgres", 5432) + "/testdb");
        registry.add("spring.data.redis.host", () ->
            env.getServiceHost("redis", 6379));
        registry.add("spring.data.redis.port", () ->
            env.getServicePort("redis", 6379));
    }

    @Test
    void runsAgainstFullStack() {
        // hits both real Postgres and real Redis
    }
}

Spring Boot Integration

Spring Boot 3.1+ introduces @ServiceConnection which eliminates the need for @DynamicPropertySource: Spring auto-wires the container's connection properties.

Java
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;

@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {

    @Container
    @ServiceConnection                              // no @DynamicPropertySource needed
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @Container
    @ServiceConnection
    static KafkaContainer kafka =
        new KafkaContainer(DockerImageName.parse("apache/kafka:3.7.0"));

    @Autowired OrderService orderService;

    @Test
    void placesOrderAndPublishesEvent() {
        Order order = orderService.place("Widget", 3);
        assertThat(order.id()).isNotNull();
        // Kafka event verified via consumer...
    }
}

For projects not yet on Spring Boot 3.1, use the @DynamicPropertySource approach shown in the PostgreSQL section. Both work identically: @ServiceConnection is just syntactic sugar on top.