Using Testcontainers in a Spring Boot Application
Discover how Testcontainers improves developer experience and enables robust integration testing in Spring Boot applications by automating Docker container management.
Testcontainers is a Java library that enables the execution of Docker containers programmatically. In this article, we’ll explore two main use cases for integrating Testcontainers into a Spring Boot application:
- Improving Developer Experience (DevEx): Automatically starting services like databases or caches when launching the application locally.
- Writing Integration Tests: Testing interactions between your application and external services (databases, message brokers, search engines, etc.).
Use Case 1: Enhancing Developer Experience
The Challenge
When developing a Spring Boot application that depends on external services like PostgreSQL or Redis, developers typically need to:
- Manually install and configure these services on their machines
- Ensure everyone on the team uses the same versions
- Deal with inconsistent environments that can lead to “works on my machine” bugs
The Solution: Automated Service Provisioning
Testcontainers can automatically start required services when you launch your application in development mode. This eliminates manual setup and ensures consistency across your team.
Implementation
Create a configuration class with the @Configuration annotation and restrict it to the local profile:
package com.example.demo.config;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.GenericContainer;
@TestConfiguration(proxyBeanMethods = false)
@Profile("local")
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
@Bean
@ServiceConnection(name = "redis")
GenericContainer<?> redisContainer() {
return new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
}
}
The @ServiceConnection annotation automatically configures Spring Boot properties (like database URL, username, password) based on the running container, eliminating manual configuration.
Activating the Configuration
To enable this configuration when running your application locally, add the local profile:
package com.example.demo;
import com.example.demo.config.TestcontainersConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication
.from(DemoApplication::main)
.with(TestcontainersConfiguration.class)
.run(args);
}
}
Alternatively, set the profile in your IDE or via command line:
./mvnw spring-boot:run -Dspring-boot.run.profiles=local
Benefits
- Automation: No need to manually install PostgreSQL or Redis on each developer’s machine
- Consistency: Everyone uses the same service versions defined in code
- Reproducibility: Bugs are easier to reproduce since environments are identical
Use Case 2: Writing Integration Tests
The Challenge
Unit tests are great for testing individual components, but you also need integration tests to verify that your application correctly interacts with real external services. These tests should:
- Use actual databases, message brokers, and other services
- Run in isolation without affecting other tests
- Be reproducible in CI/CD pipelines
- Clean up resources automatically
The Solution: Ephemeral Test Containers
Testcontainers allows you to programmatically start Docker containers for your tests, creating isolated, reproducible test environments.
Example Scenario
Let’s test a Spring Boot REST API that:
- Receives data via HTTP POST
- Stores it in PostgreSQL
- Publishes a message to Apache Pulsar
- Indexes the data in Elasticsearch
Required Dependencies
Add these dependencies to your pom.xml:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>pulsar</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>elasticsearch</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
Approach 1: Manual Container Configuration
This approach gives you fine-grained control over each container’s configuration.
package com.example.demo;
import org.junit.jupiter.api.Test;
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.containers.PulsarContainer;
import org.testcontainers.elasticsearch.ElasticsearchContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest
@Testcontainers
class DemoApplicationTests {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Container
static PulsarContainer pulsar = new PulsarContainer("apachepulsar/pulsar:3.1.1");
@Container
static ElasticsearchContainer elasticsearch = new ElasticsearchContainer(
"docker.elastic.co/elasticsearch/elasticsearch:8.11.0"
)
.withEnv("discovery.type", "single-node")
.withEnv("xpack.security.enabled", "false");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// Configure Spring Boot to use the test containers
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("pulsar.service-url", pulsar::getPulsarBrokerUrl);
registry.add("spring.elasticsearch.uris",
() -> "http://" + elasticsearch.getHttpHostAddress());
}
@Test
void contextLoads() {
// Your integration tests here
}
}
Key Points:
@Testcontainers: Enables automatic container lifecycle management@Container: Marks containers to be started before tests and stopped after@DynamicPropertySource: Dynamically injects container URLs into Spring properties- Each container can be individually configured (ports, environment variables, versions, etc.)
Approach 2: Docker Compose Integration
For complex setups with multiple services, using Docker Compose is often simpler and more maintainable.
Create a compose.yml file:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- "5432:5432"
pulsar:
image: apachepulsar/pulsar:3.1.1
command: bin/pulsar standalone
ports:
- "6650:6650"
- "8080:8080"
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
environment:
- discovery.type=single-node
- xpack.security.enabled=false
ports:
- "9200:9200"
Use it in your tests:
package com.example.demo;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.ComposeContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.File;
@SpringBootTest
@Testcontainers
class DemoApplicationComposeTests {
@Container
static ComposeContainer environment = new ComposeContainer(
new File("src/test/resources/compose.yml")
)
.withExposedService("postgres", 5432, Wait.forListeningPort())
.withExposedService("pulsar", 6650, Wait.forListeningPort())
.withExposedService("elasticsearch", 9200, Wait.forHttp("/"));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
String postgresHost = environment.getServiceHost("postgres", 5432);
Integer postgresPort = environment.getServicePort("postgres", 5432);
registry.add("spring.datasource.url",
() -> String.format("jdbc:postgresql://%s:%d/testdb", postgresHost, postgresPort));
registry.add("spring.datasource.username", () -> "test");
registry.add("spring.datasource.password", () -> "test");
String pulsarHost = environment.getServiceHost("pulsar", 6650);
Integer pulsarPort = environment.getServicePort("pulsar", 6650);
registry.add("pulsar.service-url",
() -> String.format("pulsar://%s:%d", pulsarHost, pulsarPort));
String elasticHost = environment.getServiceHost("elasticsearch", 9200);
Integer elasticPort = environment.getServicePort("elasticsearch", 9200);
registry.add("spring.elasticsearch.uris",
() -> String.format("http://%s:%d", elasticHost, elasticPort));
}
@Test
void contextLoads() {
// Your integration tests here
}
}
Advantages of Docker Compose Approach:
- Single Configuration: All services defined in one file
- Orchestration: Testcontainers manages startup order and dependencies
- Reusability: Same compose file can be used for local development
- Wait Strategies: Built-in support for waiting until services are ready
Comparison: Manual vs Docker Compose
| Aspect | Manual Configuration | Docker Compose |
|---|---|---|
| Setup Complexity | More code, individual containers | Single YAML file |
| Control | Fine-grained per container | Declarative, service-level |
| Reusability | Test-specific | Can share with dev environment |
| Orchestration | Manual coordination | Automatic via Docker Compose |
| Best For | Simple scenarios, specific configs | Complex multi-service setups |
Best Practices
1. Use Specific Image Versions
Always pin specific versions to ensure reproducibility:
// Good
new PostgreSQLContainer<>("postgres:16-alpine")
// Bad (version can change)
new PostgreSQLContainer<>("postgres:latest")
2. Reuse Containers Across Tests
Starting containers is expensive. Reuse them when possible:
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true);
Enable reuse in ~/.testcontainers.properties:
testcontainers.reuse.enable=true
3. Use Wait Strategies
Ensure containers are fully ready before running tests:
new PostgreSQLContainer<>("postgres:16-alpine")
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*", 2));
4. Clean Up Test Data
Use @BeforeEach and @AfterEach to ensure clean state between tests:
@BeforeEach
void setUp() {
// Clean database or prepare test data
jdbcTemplate.execute("TRUNCATE TABLE users CASCADE");
}
5. Configure Resource Limits
Prevent containers from consuming too many resources:
new PostgreSQLContainer<>("postgres:16-alpine")
.withCreateContainerCmdModifier(cmd ->
cmd.getHostConfig()
.withMemory(512 * 1024 * 1024L) // 512MB
.withCpuCount(1L)
);
Running Tests in CI/CD
Testcontainers works seamlessly in CI/CD pipelines. Most CI environments (GitHub Actions, GitLab CI, Jenkins) support Docker-in-Docker or Docker socket mounting.
GitHub Actions Example:
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: "17"
distribution: "temurin"
- name: Run integration tests
run: ./mvnw verify
No special Docker configuration is needed—Testcontainers automatically detects the Docker environment.
Common Pitfalls and Solutions
1. Port Conflicts
Problem: Container ports conflict with services running on the host.
Solution: Let Testcontainers assign random ports:
// Don't specify host port
postgres.getMappedPort(5432); // Returns random available port
2. Slow Test Startup
Problem: Starting containers for every test class is slow.
Solution: Use @SpringBootTest with webEnvironment = NONE when you don’t need the web server, and enable container reuse.
3. Resource Cleanup Issues
Problem: Containers not being cleaned up properly.
Solution: Use Ryuk (enabled by default) or configure:
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withLabel("testcontainers", "true");
Advanced Features
Custom Container Images
Create custom containers with pre-loaded data:
GenericContainer<?> customDb = new GenericContainer<>(
DockerImageName.parse("myregistry/custom-postgres:1.0")
)
.withExposedPorts(5432)
.withCopyFileToContainer(
MountableFile.forClasspathResource("init.sql"),
"/docker-entrypoint-initdb.d/init.sql"
);
Network Communication Between Containers
Allow containers to communicate:
Network network = Network.newNetwork();
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withNetwork(network)
.withNetworkAliases("postgres");
GenericContainer<?> app = new GenericContainer<>("my-app:latest")
.withNetwork(network)
.withEnv("DB_HOST", "postgres");
Module-Specific Containers
Testcontainers provides specialized modules with convenience methods:
// Kafka
KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0")
);
// MongoDB
MongoDBContainer mongo = new MongoDBContainer("mongo:7.0");
// Neo4j
Neo4jContainer<?> neo4j = new Neo4jContainer<>("neo4j:5.13");
Conclusion
Testcontainers is a game-changer for Spring Boot development, offering two powerful use cases:
-
Developer Experience: Automatically provision services locally, eliminating setup friction and ensuring environment consistency across your team.
-
Integration Testing: Write robust, isolated tests against real services without complex test infrastructure or manual setup.
By adopting Testcontainers, you can:
- Reduce “works on my machine” bugs
- Increase confidence in your integration tests
- Simplify onboarding for new developers
- Ensure consistency between development, testing, and production environments
Whether you choose the manual configuration approach for fine-grained control or the Docker Compose approach for simplicity, Testcontainers provides the flexibility to match your team’s needs.
Additional Resources
- Testcontainers Documentation
- Spring Boot Testcontainers Support
- Recorded technical session on Testcontainers with Spring Boot (in French)
Original article published in French on VaDuo Consulting Blog