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:
1<dependencies>
2 <!-- Spring Boot test -->
3 <dependency>
4 <groupId>org.springframework.boot</groupId>
5 <artifactId>spring-boot-starter-test</artifactId>
6 <scope>test</scope>
7 </dependency>
8
9 <!-- Testcontainers core -->
10 <dependency>
11 <groupId>org.testcontainers</groupId>
12 <artifactId>testcontainers</artifactId>
13 <scope>test</scope>
14 </dependency>
15
16 <!-- Add container modules you need -->
17 <dependency>
18 <groupId>org.testcontainers</groupId>
19 <artifactId>postgresql</artifactId>
20 <scope>test</scope>
21 </dependency>
22 <dependency>
23 <groupId>org.testcontainers</groupId>
24 <artifactId>kafka</artifactId>
25 <scope>test</scope>
26 </dependency>
27</dependencies>Gradle (Groovy):
1testImplementation 'org.springframework.boot:spring-boot-starter-test'
2testImplementation 'org.testcontainers:testcontainers'
3testImplementation 'org.testcontainers:postgresql'
4testImplementation '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):
1package com.example;
2
3import org.junit.jupiter.api.Test;
4import org.springframework.boot.test.context.SpringBootTest;
5import org.springframework.test.context.DynamicPropertyRegistry;
6import org.springframework.test.context.DynamicPropertySource;
7import org.testcontainers.containers.PostgreSQLContainer;
8import org.testcontainers.junit.jupiter.Container;
9import org.testcontainers.junit.jupiter.Testcontainers;
10
11import org.springframework.beans.factory.annotation.Autowired;
12import org.springframework.boot.test.web.client.TestRestTemplate;
13import static org.assertj.core.api.Assertions.assertThat;
14
15@Testcontainers
16@SpringBootTest
17public class PostgresIntegrationTest {
18
19 @Container
20 public static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine")
21 .withDatabaseName("testdb")
22 .withUsername("test")
23 .withPassword("test");
24
25 @DynamicPropertySource
26 static void configureProperties(DynamicPropertyRegistry registry) {
27 registry.add("spring.datasource.url", postgres::getJdbcUrl);
28 registry.add("spring.datasource.username", postgres::getUsername);
29 registry.add("spring.datasource.password", postgres::getPassword);
30 }
31
32 @Autowired
33 private UserRepository userRepository;
34
35 @Test
36 void repositorySavesAndLoads() {
37 User u = new User();
38 u.setName("Alice");
39 userRepository.save(u);
40
41 assertThat(userRepository.findByName("Alice")).isNotEmpty();
42 }
43}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:
1import org.testcontainers.containers.KafkaContainer;
2import org.testcontainers.utility.DockerImageName;
3
4@Testcontainers
5@SpringBootTest
6public class KafkaIntegrationTest {
7
8 @Container
9 public static KafkaContainer kafka = new KafkaContainer(
10 DockerImageName.parse("confluentinc/cp-kafka:7.4.0")
11 );
12
13 @DynamicPropertySource
14 static void configureKafka(DynamicPropertyRegistry registry) {
15 registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
16 }
17
18 @Autowired
19 private KafkaTemplate<String, String> template;
20
21 @Autowired
22 private ConsumerService consumer;
23
24 @Test
25 void sendAndReceive() {
26 template.send("my-topic", "key", "hello");
27 // Assert that consumer processed or use consumer polling
28 }
29}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):
1version: '3.8'
2services:
3 db:
4 image: postgres:14-alpine
5 environment:
6 POSTGRES_DB: testdb
7 POSTGRES_USER: test
8 POSTGRES_PASSWORD: test
9 ports:
10 - "5432"
11 redis:
12 image: redis:6-alpine
13 ports:
14 - "6379"Test:
1import org.testcontainers.containers.DockerComposeContainer;
2import org.testcontainers.containers.wait.strategy.Wait;
3
4@Container
5public static DockerComposeContainer<?> compose =
6 new DockerComposeContainer<>(new File("src/test/resources/docker-compose.yml"))
7 .withExposedService("db_1", 5432, Wait.forListeningPort())
8 .withExposedService("redis_1", 6379, Wait.forListeningPort());
9
10@DynamicPropertySource
11static void dynamicProperties(DynamicPropertyRegistry registry) {
12 String jdbcUrl = "jdbc:postgresql://" + compose.getServiceHost("db_1", 5432)
13 + ":" + compose.getServicePort("db_1", 5432) + "/testdb";
14 registry.add("spring.datasource.url", () -> jdbcUrl);
15 registry.add("spring.redis.host", () -> compose.getServiceHost("redis_1", 6379));
16 registry.add("spring.redis.port", () -> compose.getServicePort("redis_1", 6379).toString());
17}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 test runs: enable Testcontainers reuse (fast) with ~/.testcontainers.properties: testcontainers.reuse.enable=true And set environment variable TESTCONTAINERS_RYUK_DISABLED=true to disable Ryuk. Security implications; recommended only in trusted environments.
- Use @DynamicPropertySource for injecting runtime container values into Spring config; avoid hardcoding ports.
- Use lighter images (alpine-based) and pinned versions to speed startup and improve reproducibility.
- Use wait strategies for services that take longer to start: withStartupTimeout and Wait.forLogMessage or Wait.forHttp.
Performance, stability, and resource management
- Startup time: container startup is the major cost. Strategies:
- Start shared container per test class or suite (static @Container).
- Reuse containers across runs in local dev (but be careful in CI).
- Use cached images; CI should cache Docker layers or use pre-cached runner images.
- Parallel tests: avoid starting many containers simultaneously; it can exhaust resources. Configure test concurrency accordingly.
- Memory/CPU: allocate adequate resources to Docker daemon and runners. Containers inherit host constraints.
- Image size and architecture: use small images and ensure correct CPU architecture (x86_64 vs arm64). Use proper images or set platform.
CI/CD integration (GitHub Actions / GitLab)
GitHub Actions (simple workflow snippet):
1name: Java CI
2
3on: [push, pull_request]
4
5jobs:
6 test:
7 runs-on: ubuntu-latest
8 services:
9 docker:
10 image: docker:20.10.16
11 steps:
12 - uses: actions/checkout@v4
13 - name: Set up JDK 17
14 uses: actions/setup-java@v4
15 with:
16 distribution: temurin
17 java-version: '17'
18 - name: Start Docker
19 uses: docker/setup-buildx-action@v2
20 - name: Build and test
21 run: ./mvnw -DskipTests=false test -BNotes for CI:
- On GitHub-hosted runners Docker is available; ensure no privileged mode required unless using DinD.
- On GitLab CI, to use Docker-in-Docker, you may need privileged: true and proper services (docker:dind).
- Make sure CI runners have adequate resources (memory/CPU) and that images are compatible with runner architecture.
- If using Testcontainers reuse in CI, be cautious — ephemeral containers are preferable for test repeatability.
Troubleshooting common errors
- No Docker environment found / IllegalStateException: Docker not available:
- Ensure Docker daemon running and accessible from test process (correct user permissions).
- On CI, enable Docker-in-Docker or use runners with Docker.
- Ryuk errors / Cannot connect to / permission denied:
- Ryuk manages containers; if Ryuk fails or is blocked by firewall/selinux, disable or fix Docker config. Alternatively set TESTCONTAINERS_RYUK_DISABLED=true (not recommended for general use).
- Port already in use / Bind errors:
- Avoid binding fixed host ports; use container-provided ports (getJdbcUrl, getMappedPort).
- Tests flaky due to startup time:
- Increase startup timeout with withStartupTimeout() and use explicit Wait strategies.
- Image/architecture mismatch on Apple Silicon:
- Use multi-arch images or explicit platform in image name (e.g., DockerImageName.parse("postgres:14").withArchitecture("amd64")) — but be careful and prefer multi-arch official images.
Best practices and anti-patterns
Best practices:
- Use @DynamicPropertySource to inject container values.
- Pin container image versions for reproducibility.
- Share containers when safe; prefer per-class static containers over per-method where appropriate.
- Use integration tests for behavior that cannot be reliably simulated by unit tests.
- Clean DB state between tests (truncate tables or use transactions with rollback depending on test style).
- Capture container logs on failure to aid debugging.
- Limit test scope: keep expensive container tests focused and small; reserve heavy end-to-end tests to a separate pipeline stage.
Anti-patterns:
- Starting huge stacks for every test method.
- Relying on fixed host ports in tests.
- Disabling Ryuk and leaving containers running forever in shared environments.
- Using Testcontainers as a substitute for proper unit tests — integration tests should complement, not replace, unit tests.
Advanced features
- Custom wait strategies: implement or compose Wait strategies for better stability.
- Networks: Network.newNetwork() to connect containers in the same Docker network.
- Init scripts: configure container initialization (SQL files for DB containers via withInitScript()).
- Custom images: extend GenericContainer for tailored startup commands and volume mounts.
- LocalStackContainer: simulate AWS services (S3, SQS, DynamoDB).
- Testcontainers JDBC module: allows JVM-level DataSource that starts containers automatically (useful in some setups).
- Remote Docker / Docker over TCP: configure Testcontainers to use DOCKER_HOST.
Future trends and alternatives
- Testcontainers will likely continue to be a standard for JVM-based integration testing due to its balance of realism and test automation convenience.
- Alternatives:
- Embedded-mode libraries (embedded Kafka, embedded Redis) — faster but provide less fidelity.
- Service virtualization and dedicated test environments (Kubernetes-based test clusters).
- Contract testing (Pact) for API interactions.
- For large microservices landscapes, moving tests to ephemeral Kubernetes clusters (e.g., KinD, kind + Helm) may provide better integration parity with production, but with higher complexity and cost.
Complete, realistic example (Spring Boot + REST + Postgres)
- application-test.yml (optional):
1spring:
2 datasource:
3 driver-class-name: org.postgresql.Driver
4 hikari:
5 maximum-pool-size: 5
6 jpa:
7 hibernate:
8 ddl-auto: validate
9 properties:
10 hibernate:
11 show_sql: false- Test class:
1@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
2@Testcontainers
3public class UserApiIntegrationTest {
4
5 @Container
6 private static final PostgreSQLContainer<?> postgres =
7 new PostgreSQLContainer<>("postgres:14-alpine")
8 .withDatabaseName("app_test")
9 .withUsername("user")
10 .withPassword("pass");
11
12 @DynamicPropertySource
13 static void configure(DynamicPropertyRegistry registry) {
14 registry.add("spring.datasource.url", postgres::getJdbcUrl);
15 registry.add("spring.datasource.username", postgres::getUsername);
16 registry.add("spring.datasource.password", postgres::getPassword);
17 }
18
19 @Autowired
20 private TestRestTemplate restTemplate;
21
22 @Test
23 void createUser_thenReadBack() {
24 UserDto create = new UserDto(null, "[email protected]", "Bob");
25 ResponseEntity<UserDto> createResponse =
26 restTemplate.postForEntity("/api/users", create, UserDto.class);
27 assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
28 UserDto saved = createResponse.getBody();
29 assertThat(saved.getId()).isNotNull();
30
31 ResponseEntity<UserDto> getResponse = restTemplate.getForEntity("/api/users/" + saved.getId(), UserDto.class);
32 assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
33 assertThat(getResponse.getBody().getEmail()).isEqualTo("[email protected]");
34 }
35}This pattern combines: containerized DB, Spring Boot test server on a random port, and HTTP verification (black-box style) against a realistic backing DB.
Conclusion
Testcontainers enables robust, maintainable integration testing for Spring Boot applications by providing real containerized services, high configurability, and straightforward JUnit integration. Use it to raise confidence in database migrations, message processing, and external integrations. Balance realism and speed: keep unit tests plentiful and use Testcontainers selectively for scenarios that truly need a real service. In CI, ensure Docker access, resource sizing, and image caching to keep feedback fast and reliable.
If you want, I can:
- Provide a fully working sample Git repository with Maven/Gradle, Dockerfiles, and test classes.
- Create a concise checklist for migrating existing integration tests to Testcontainers.
- Provide CI workflow templates (GitHub Actions or GitLab CI) tailored to your environment. Which would you prefer?