Introduction
Testcontainers is a Java library that provides lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container. It enables true integration testing by allowing tests to interact with real services in isolated containers.
Maven Dependencies
<!-- Testcontainers BOM --> <dependencyManagement> <dependencies> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers-bom</artifactId> <version>1.19.3</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <!-- Core Testcontainers --> <dependencies> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers</artifactId> <scope>test</scope> </dependency> <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> <dependency> <groupId>org.testcontainers</groupId> <artifactId>mongodb</artifactId> <scope>test</scope> </dependency> <!-- Kafka --> <dependency> <groupId>org.testcontainers</groupId> <artifactId>kafka</artifactId> <scope>test</scope> </dependency> <!-- Selenium --> <dependency> <groupId>org.testcontainers</groupId> <artifactId>selenium</artifactId> <scope>test</scope> </dependency> <!-- Spring Boot Support --> <dependency> <groupId>org.testcontainers</groupId> <artifactId>spring-boot</artifactId> <scope>test</scope> </dependency> </dependencies>
Basic Testcontainers Setup
Simple Container Test
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
public class BasicContainerTest {
@Container
private static final GenericContainer<?> redisContainer =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@Test
void testRedisConnection() {
String redisHost = redisContainer.getHost();
Integer redisPort = redisContainer.getFirstMappedPort();
// Use the actual container host and port
String connectionString = String.format("redis://%s:%d", redisHost, redisPort);
System.out.println("Redis connection: " + connectionString);
// Test your Redis client implementation
assertThat(redisContainer.isRunning()).isTrue();
}
@Test
void testMultipleContainers() {
GenericContainer<?> nginxContainer = new GenericContainer<>("nginx:alpine")
.withExposedPorts(80);
nginxContainer.start();
try {
assertThat(nginxContainer.isRunning()).isTrue();
} finally {
nginxContainer.stop();
}
}
}
Database Integration Testing
PostgreSQL Integration Tests
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@SpringBootTest
public class PostgresIntegrationTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@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 DataSource dataSource;
@Test
void testDatabaseConnection() throws Exception {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT 1")) {
assertThat(rs.next()).isTrue();
assertThat(rs.getInt(1)).isEqualTo(1);
}
}
@Test
void testDatabaseIsPostgreSQL() throws Exception {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT version()")) {
assertThat(rs.next()).isTrue();
String version = rs.getString(1);
assertThat(version).contains("PostgreSQL");
}
}
}
// JPA Repository Testing
@DataJpaTest
@Testcontainers
class UserRepositoryTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine");
@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 TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndFindUser() {
User user = User.builder()
.username("testuser")
.email("[email protected]")
.build();
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull();
assertThat(userRepository.findByUsername("testuser"))
.isPresent()
.get()
.extracting(User::getEmail)
.isEqualTo("[email protected]");
}
}
MySQL Integration Tests
@Testcontainers
@SpringBootTest
public class MySQLIntegrationTest {
@Container
private static final MySQLContainer<?> mysql =
new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withInitScript("init-mysql.sql");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private DataSource dataSource;
@Test
void testMySQLSpecificFeatures() throws Exception {
// Test MySQL-specific functionality
}
}
// init-mysql.sql
CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
MongoDB Integration Tests
import org.springframework.data.mongodb.core.MongoTemplate;
import org.testcontainers.containers.MongoDBContainer;
@Testcontainers
@SpringBootTest
public class MongoDBIntegrationTest {
@Container
private static final MongoDBContainer mongo =
new MongoDBContainer("mongo:6.0")
.withExposedPorts(27017);
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl);
}
@Autowired
private MongoTemplate mongoTemplate;
@Test
void testMongoDBConnection() {
// Verify connection works
assertThat(mongoTemplate.getDb().getName()).isNotNull();
}
@Test
void shouldSaveAndRetrieveDocument() {
UserDocument user = UserDocument.builder()
.username("mongouser")
.email("[email protected]")
.build();
UserDocument saved = mongoTemplate.save(user);
assertThat(saved.getId()).isNotNull();
assertThat(mongoTemplate.findById(saved.getId(), UserDocument.class))
.isNotNull()
.extracting(UserDocument::getUsername)
.isEqualTo("mongouser");
}
}
Kafka Integration Testing
Kafka Testcontainers Setup
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
@Testcontainers
public class KafkaIntegrationTest {
@Container
private static final KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
private String getBootstrapServers() {
return kafka.getBootstrapServers();
}
@Test
void testKafkaProduceConsume() {
String topicName = "test-topic";
// Producer configuration
Properties producerProps = new Properties();
producerProps.put("bootstrap.servers", getBootstrapServers());
producerProps.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
producerProps.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// Consumer configuration
Properties consumerProps = new Properties();
consumerProps.put("bootstrap.servers", getBootstrapServers());
consumerProps.put("group.id", "test-group");
consumerProps.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
consumerProps.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
try (KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps);
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps)) {
// Produce message
String testMessage = "Hello Kafka!";
producer.send(new ProducerRecord<>(topicName, "key1", testMessage));
producer.flush();
// Consume message
consumer.subscribe(Collections.singletonList(topicName));
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(10));
assertThat(records).isNotEmpty();
assertThat(records.iterator().next().value()).isEqualTo(testMessage);
}
}
}
// Spring Kafka Testing
@SpringBootTest
@Testcontainers
class SpringKafkaTest {
@Container
private static final KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Autowired
private KafkaListenerEndpointRegistry registry;
@Test
void testSpringKafkaIntegration() throws Exception {
String topic = "spring-test-topic";
String message = "Test message";
kafkaTemplate.send(topic, message);
kafkaTemplate.flush();
// Wait for message to be processed
Thread.sleep(2000);
// Verify message was processed by your @KafkaListener
}
}
Complex Multi-Container Setups
Docker Compose Integration
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.DockerComposeContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.File;
@Testcontainers
public class DockerComposeTest {
@Container
private static final DockerComposeContainer<?> compose =
new DockerComposeContainer<>(new File("src/test/resources/docker-compose-test.yml"))
.withExposedService("postgres_1", 5432)
.withExposedService("redis_1", 6379)
.withLocalCompose(true);
@Test
void testMultipleServices() {
String postgresHost = compose.getServiceHost("postgres_1", 5432);
Integer postgresPort = compose.getServicePort("postgres_1", 5432);
String redisHost = compose.getServiceHost("redis_1", 6379);
Integer redisPort = compose.getServicePort("redis_1", 6379);
// Test connections to both services
assertThat(postgresHost).isNotNull();
assertThat(redisHost).isNotNull();
}
}
// docker-compose-test.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- "5432"
redis:
image: redis:7-alpine
ports:
- "6379"
Custom Container Networks
@Testcontainers
public class NetworkTest {
private static final Network network = Network.newNetwork();
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withNetwork(network)
.withNetworkAliases("postgres");
@Container
private static final GenericContainer<?> app =
new GenericContainer<>("my-app:latest")
.withNetwork(network)
.dependsOn(postgres)
.withEnv("DATABASE_URL", "jdbc:postgresql://postgres:5432/testdb")
.withExposedPorts(8080);
@Test
void testAppDatabaseConnection() {
String appUrl = "http://" + app.getHost() + ":" + app.getFirstMappedPort();
// Test your application's HTTP endpoints
// The app can connect to postgres using "postgres:5432" hostname
}
}
Spring Boot Testcontainers Support
@TestConfiguration with Testcontainers
@SpringBootTest
@Testcontainers
public class SpringBootIntegrationTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine");
@TestConfiguration
static class TestConfig {
@Bean
@ServiceConnection // Spring Boot 3.1+ auto-configuration
public PostgreSQLContainer<?> postgreSQLContainer() {
return postgres;
}
}
@Autowired
private DataSource dataSource;
@Test
void testSpringDataJPA() {
// Your repository tests with real database
}
}
// Application properties for tests
@TestConfiguration
public class TestContainersConfiguration {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgreSQLContainer() {
return new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
}
@Bean
@ServiceConnection
public MongoDBContainer mongoDBContainer() {
return new MongoDBContainer("mongo:6.0");
}
}
Database Migration Testing
@Testcontainers
@SpringBootTest
public class FlywayMigrationTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("migration_test")
.withUsername("test")
.withPassword("test");
@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.flyway.locations", () -> "classpath:db/migration");
}
@Autowired
private DataSource dataSource;
@Test
void testMigrationsAppliedSuccessfully() throws Exception {
// Flyway migrations run automatically on startup
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")) {
List<String> tables = new ArrayList<>();
while (rs.next()) {
tables.add(rs.getString(1));
}
assertThat(tables).contains("users", "orders", "products");
}
}
@Test
void testDataAfterMigration() {
// Test that initial data migrations worked correctly
}
}
Advanced Testcontainers Patterns
Reusable Containers
@Testcontainers
public class ReusableContainerTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true); // Enable container reuse
@Test
void testOne() {
// Uses the same container instance
assertThat(postgres.isRunning()).isTrue();
}
@Test
void testTwo() {
// Reuses the same container
assertThat(postgres.isRunning()).isTrue();
}
}
// .testcontainers.properties for global reuse
// Create this file in user home directory
testcontainers.reuse.enable=true
Container Lifecycle Management
public class ManualContainerManagementTest {
private PostgreSQLContainer<?> postgres;
@BeforeEach
void setUp() {
postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb");
postgres.start();
// Configure your application with container details
System.setProperty("spring.datasource.url", postgres.getJdbcUrl());
System.setProperty("spring.datasource.username", postgres.getUsername());
System.setProperty("spring.datasource.password", postgres.getPassword());
}
@AfterEach
void tearDown() {
if (postgres != null) {
postgres.stop();
}
}
@Test
void testWithManualManagement() {
assertThat(postgres.isRunning()).isTrue();
// Your test logic here
}
}
Custom Container Images
public class CustomContainerTest {
@Container
private static final GenericContainer<?> customApp =
new GenericContainer<>(
DockerImageName.parse("my-registry/my-custom-app:latest")
.asCompatibleSubstituteFor("nginx")
)
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/health").forStatusCode(200));
@Test
void testCustomApplication() {
String baseUrl = "http://" + customApp.getHost() + ":" + customApp.getFirstMappedPort();
// Test your custom application
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.getForEntity(baseUrl + "/health", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
// Building custom images programmatically
public class BuiltImageTest {
@Container
private static final GenericContainer<?> container =
new GenericContainer<>(
new ImageFromDockerfile()
.withDockerfileFromBuilder(builder ->
builder
.from("openjdk:17-jre-slim")
.copy("app.jar", "/app.jar")
.entryPoint("java", "-jar", "/app.jar")
.build()
)
.withFileFromFile("app.jar", new File("build/libs/my-app.jar"))
)
.withExposedPorts(8080);
}
Performance Optimization
Parallel Test Execution
@Testcontainers
@Execution(ExecutionMode.CONCURRENT) // Run tests in parallel
public class ParallelContainerTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withReuse(true); // Important for parallel execution
@Test
void testOne() throws InterruptedException {
Thread.sleep(1000); // Simulate long test
assertThat(postgres.isRunning()).isTrue();
}
@Test
void testTwo() throws InterruptedException {
Thread.sleep(1000); // Simulate long test
assertThat(postgres.isRunning()).isTrue();
}
@Test
void testThree() throws InterruptedException {
Thread.sleep(1000); // Simulate long test
assertThat(postgres.isRunning()).isTrue();
}
}
Container Caching Strategies
public class ContainerCachingTest {
private static final PostgreSQLContainer<?> SHARED_POSTGRES =
new PostgreSQLContainer<>("postgres:15-alpine")
.withReuse(true);
static {
SHARED_POSTGRES.start();
}
@Test
void testUsingSharedContainer() {
// All tests use the same container instance
String jdbcUrl = SHARED_POSTGRES.getJdbcUrl();
// Test logic...
}
}
Testing Best Practices
Test Base Classes
public abstract class AbstractIntegrationTest {
protected static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
static {
postgres.start();
// One-time setup for all tests
configureSystemProperties();
}
private static void configureSystemProperties() {
System.setProperty("spring.datasource.url", postgres.getJdbcUrl());
System.setProperty("spring.datasource.username", postgres.getUsername());
System.setProperty("spring.datasource.password", postgres.getPassword());
}
@BeforeEach
void setUp() {
// Clean database before each test
cleanupDatabase();
}
private void cleanupDatabase() {
// Implement database cleanup logic
}
}
// Concrete test class
public class UserServiceIT extends AbstractIntegrationTest {
@Test
void testUserOperations() {
// Test with clean database
}
}
Configuration Utilities
public final class TestContainerUtils {
private TestContainerUtils() {
// utility class
}
public static PostgreSQLContainer<?> createPostgreSQLContainer() {
return new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true);
}
public static MongoDBContainer createMongoDBContainer() {
return new MongoDBContainer("mongo:6.0")
.withReuse(true);
}
public static KafkaContainer createKafkaContainer() {
return new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"))
.withReuse(true);
}
public static void configureSpringProperties(
DynamicPropertyRegistry registry,
PostgreSQLContainer<?> postgres
) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
Common Issues and Solutions
Debugging Testcontainers
@Testcontainers
public class DebuggingTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("postgres")));
@Test
void testWithLogs() {
// Container logs will be visible in test output
assertThat(postgres.isRunning()).isTrue();
}
}
// Custom wait strategy
public class CustomWaitStrategyTest {
@Container
private static final GenericContainer<?> app =
new GenericContainer<>("my-app:latest")
.withExposedPorts(8080)
.waitingFor(Wait.forLogMessage(".*Application started.*", 1)
.withStartupTimeout(Duration.ofMinutes(2)));
@Test
void testAppStartup() {
assertThat(app.isRunning()).isTrue();
}
}
Conclusion
Testcontainers provides powerful integration testing capabilities by leveraging Docker containers. Key benefits include:
- Real Environment Testing: Test against actual databases and services
- Isolation: Each test gets a clean environment
- Reproducibility: Consistent environments across all machines
- Flexibility: Support for various databases, message brokers, and custom services
Best Practices:
- Use reusable containers when possible to improve performance
- Implement proper cleanup between tests
- Use appropriate wait strategies for service readiness
- Leverage Spring Boot's Testcontainers support for seamless integration
- Monitor container logs for debugging
When to Use Testcontainers:
- Database integration tests
- Microservice integration tests
- Message broker testing
- Any external service dependencies
Testcontainers bridges the gap between unit tests and full end-to-end tests, providing fast, reliable integration testing that closely mirrors production environments.