Integration Testing with Testcontainers: Practical Spring Boot Guide ====================================================================
This guide is a deep dive into using Testcontainers for integration testing Spring Boot applications. It covers the why and how, theory and best practices, concrete code examples (PostgreSQL, Kafka, Docker Compose), CI considerations, debugging, and future directions.
Table of contents
- Why use Testcontainers?
- Theory and testing strategy
- Key Testcontainers concepts
- Getting started: dependencies and environment
- Example: Spring Boot + PostgreSQL (complete)
- Example: Spring Boot + Kafka
- Docker Compose with Testcontainers
- Common patterns and recipes
- Performance, stability, and resource management
- CI/CD integration (GitHub Actions / GitLab)
- Troubleshooting common errors
- Best practices and anti-patterns
- Future trends and alternatives
- Summary
Why use Testcontainers?
- Realism: Run actual database, message broker, or cloud-mock images in containers instead of in-memory or heavily mocked components.
- Isolation and reproducibility: Tests run against disposable containers with known images and configurations.
- Parity with production: Close environment parity reduces "works on my machine" and surfaces compatibility issues early.
- Flexibility: Start different services (DB, Kafka, Redis, S3, etc.) with arbitrary versions and configs.
Theory and testing strategy
Integration testing sits between unit tests and end-to-end/system tests. Goals:
- Validate interactions between components (e.g., Spring Data repositories and real DB).
- Test external integrations with realistic behavior (transactions, networking, broker semantics).
- Provide fast feedback while maintaining reasonable fidelity.
Test strategy using Testcontainers:
- Unit tests: lightweight, pure JVM, no containers. Fast.
- Integration tests (Testcontainers): run a small set of tests that spin up only what you need (DB, message broker).
- End-to-end tests: full system in environment (maybe Kubernetes), longer-running.
Key Testcontainers concepts
- Container modules: Core module plus preconfigured containers (PostgreSQLContainer, KafkaContainer, RabbitMQContainer, LocalStackContainer, etc.).
- GenericContainer: use for custom images or services not included.
- DockerComposeContainer: spin up multiple services defined in docker-compose.yml.
- @Testcontainers / @Container (JUnit 5): manage lifecycle from JUnit.
- DynamicPropertySource: dynamically inject container properties into Spring context at test startup.
- Networks: Docker networks for multi-container interactions.
- Wait strategies: Wait.forListeningPort(), Wait.forLogMessage(), Wait.forHttp(), withStartupTimeout().
- Reuse/cleanup: Ryuk is used to manage and cleanup containers; reuse mode exists for speed but has trade-offs.
- Per-test vs shared containers: static @Container => class-level shared container; non-static => per-test.
Getting started: dependencies and environment
Maven (pom.xml) essentials:
```xml
org.springframework.boot spring-boot-starter-test test
org.testcontainers testcontainers test
org.testcontainers postgresql test
org.testcontainers kafka test
```
Gradle (Groovy):
``groovy testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.testcontainers:testcontainers' testImplementation 'org.testcontainers:postgresql' testImplementation 'org.testcontainers:kafka' ``
Environment:
- Docker Engine accessible from the build agent (local dev machine or CI).
- For mac/arm64 (Apple Silicon), prefer multi-arch images or set the platform accordingly.
- Allow Testcontainers to start/stop containers (Docker socket access).
Example: Spring Boot + PostgreSQL (complete)
This is a typical pattern: start PostgreSQL in a container and wire its JDBC URL into Spring Boot tests via @DynamicPropertySource.
Test dependencies: testcontainers, postgresql module (see above).
Sample repository layout (tests):
- src/test/java/.../PostgresIntegrationTest.java
- src/test/resources/application-test.yml (optional)
Entity / repository setup: assume standard Spring Data JPA with datasource auto-configured.
Test class (JUnit 5 + Spring Boot):
```java package com.example;
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.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.client.TestRestTemplate; import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers @SpringBootTest public class PostgresIntegrationTest {
@Container public static PostgreSQLContainer postgres = new PostgreSQLContainer ("postgres:14-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 UserRepository userRepository;
@Test void repositorySavesAndLoads() { User u = new User(); u.setName("Alice"); userRepository.save(u);
assertThat(userRepository.findByName("Alice")).isNotEmpty(); } } ```
Notes:
- DynamicPropertySource binds container values before the Spring context is initialized.
- Use static container field to share the container across test methods in the class (and to start it once).
- If you need per-test isolation, make the container non-static (it will be restarted per test method).
Test with Spring Boot Web layer:
- Use @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) and TestRestTemplate or WebTestClient.
- Use @LocalServerPort to access the port or rely on TestRestTemplate autowiring.
DB schema and migrations:
- Use Flyway/Liquibase (Spring Boot auto-run migrations) — they execute against the container DB when Spring starts.
- Alternatively, run SQL scripts via TestExecutionListeners or @Sql.
Example: Spring Boot + Kafka
Start a Kafka broker for event-driven application testing.
Testcontainers Kafka example:
```java import org.testcontainers.containers.KafkaContainer; import org.testcontainers.utility.DockerImageName;
@Testcontainers @SpringBootTest public class KafkaIntegrationTest {
@Container public static KafkaContainer kafka = new KafkaContainer( DockerImageName.parse("confluentinc/cp-kafka:7.4.0") );
@DynamicPropertySource static void configureKafka(DynamicPropertyRegistry registry) { registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers); }
@Autowired private KafkaTemplate template;
@Autowired private ConsumerService consumer;
@Test void sendAndReceive() { template.send("my-topic", "key", "hello"); // Assert that consumer processed or use consumer polling } } ```
Notes:
- KafkaContainer exposes getBootstrapServers() which you inject into Spring properties to make your KafkaProducer/Consumer connect to the container.
- You may need to wait for topics to exist; create topics programmatically or configure client auto-creation.
Docker Compose with Testcontainers
Testcontainers can run a docker-compose file and expose services to tests. Useful when you have multiple services to start together.
docker-compose.yml (src/test/resources/docker-compose.yml):
```yaml version: '3.8' services: db: image: postgres:14-alpine environment: POSTGRESDB: testdb POSTGRESUSER: test POSTGRES_PASSWORD: test ports:
- "5432"
redis: image: redis:6-alpine ports:
- "6379"
```
Test:
```java import org.testcontainers.containers.DockerComposeContainer; import org.testcontainers.containers.wait.strategy.Wait;
@Container public static DockerComposeContainer compose = new DockerComposeContainer (new File("src/test/resources/docker-compose.yml")) .withExposedService("db1", 5432, Wait.forListeningPort()) .withExposedService("redis1", 6379, Wait.forListeningPort());
@DynamicPropertySource static void dynamicProperties(DynamicPropertyRegistry registry) { String jdbcUrl = "jdbc:postgresql://" + compose.getServiceHost("db_1", 5432)
- ":" + compose.getServicePort("db_1", 5432) + "/testdb";
registry.add("spring.datasource.url", () -> jdbcUrl); registry.add("spring.redis.host", () -> compose.getServiceHost("redis1", 6379)); registry.add("spring.redis.port", () -> compose.getServicePort("redis1", 6379).toString()); } ```
Notes:
- Compose service names may be suffixed by indices (db_1). Use compose.getServiceHost/Port to query.
- DockerComposeContainer is convenient for multi-service stacks; for tighter control use separate Testcontainers per service and share a Network.
Common patterns and recipes
- Shared container for entire test suite: use static container(s) in a dedicated @TestConfiguration or base test class. Careful with state — ensure each test cleans up DB state.
- Per-test isolation: non-static container fields or manual cleanup. More costly but safe for stateful tests.
- Reusing containers across ...