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.
Contents
Dependency
Add the following to your pom.xmlbuild.gradle
<!-- 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 @Testcontainersstatic @Container@DynamicPropertySource
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();
}
}
staticKafka Container
The class below shows the implementation. Key points are highlighted in the inline comments.
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 GenericContainerwithExposedPortswithEnvwaitingFor
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
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.
// 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() {}
}
// 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 */ }
}
testcontainers.reuse.enable=true~/.testcontainers.propertiesDocker Compose
ComposeContainerdocker-compose.yml
# 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"
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@DynamicPropertySource
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@ServiceConnection