# Testcontainers Spring Boot: pijnloze integratietests
> Volledige gids voor het configureren van Testcontainers met Spring Boot 3.4. PostgreSQL, Redis en Kafka in Docker-containers voor betrouwbare, reproduceerbare integratietests.
- Published: 2026-03-19
- Updated: 2026-05-01
- Author: SharpSkill
- Tags: testcontainers, spring boot, integration testing, docker, postgresql
- Reading time: 14 min
---
Integratietests vormen een grote uitdaging in de ontwikkeling van Spring Boot-applicaties. Testen tegen een echte PostgreSQL-database of Kafka-broker vereist het onderhouden van zware infrastructuur. Testcontainers lost dit probleem op door tijdens de tests Docker-containers op aanvraag te starten, wat geïsoleerde en reproduceerbare omgevingen garandeert.
> **Spring Boot 3.4 en Testcontainers**
>
> Spring Boot 3.4 bevat native Testcontainers-ondersteuning met auto-configuratie. De afhankelijkheden `spring-boot-testcontainers` vereenvoudigen de installatie drastisch en maken hergebruik van containers tussen tests mogelijk.
## De integratie tussen Testcontainers en Spring Boot begrijpen
Testcontainers biedt een Java-API om Docker-containers te starten tijdens de uitvoering van tests. In plaats van externe afhankelijkheden te mocken of gedeelde testdatabases te onderhouden, krijgt elke testrun zijn eigen geïsoleerde instantie.
De architectuur steunt op drie hoofdcomponenten: de Testcontainers-bibliotheek die Docker aanstuurt, gespecialiseerde modules voor elke technologie (PostgreSQL, Redis, Kafka) en de Spring Boot-integratie die automatisch de verbindingsparameters injecteert.
```xml
org.springframework.boot
spring-boot-testcontainers
test
org.testcontainers
postgresql
test
org.testcontainers
junit-jupiter
test
org.testcontainers
testcontainers-bom
1.20.4
pom
import
```
De Testcontainers BOM zorgt voor versieconsistentie tussen alle modules die in het project worden gebruikt.
## Basisconfiguratie met PostgreSQL
Het meest voorkomende scenario betreft testen tegen een echte PostgreSQL-database. Spring Boot 3.4 biedt twee benaderingen: de annotatie `@ServiceConnection` voor auto-configuratie of handmatige configuratie via `@DynamicPropertySource`.
```java
// UserRepositoryIntegrationTest.java
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class UserRepositoryIntegrationTest {
// Starts a PostgreSQL container before tests
@Container
@ServiceConnection
static PostgreSQLContainer> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
);
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUser() {
// Given: a user to persist
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
// When: save and retrieve
User saved = userRepository.save(user);
Optional found = userRepository.findById(saved.getId());
// Then: user is correctly persisted
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("test@example.com");
}
@Test
void shouldFindUserByEmail() {
// Given: a user in database
User user = new User();
user.setEmail("search@example.com");
user.setName("Search User");
userRepository.save(user);
// When: search by email
Optional found = userRepository.findByEmail("search@example.com");
// Then: user is found
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Search User");
}
}
```
De annotatie `@ServiceConnection` detecteert automatisch het containertype en configureert de bijbehorende Spring-properties (`spring.datasource.url`, `spring.datasource.username`, enzovoort). Deze aanpak elimineert herhalende configuratiecode.
> **Levenscyclus van de container**
>
> Met `@Container` op een statisch veld start de container eenmalig vóór alle tests in de klasse en stopt deze na de laatste test. Voor een container per test moet een niet-statisch instantieveld worden gebruikt.
## Handmatige configuratie met @DynamicPropertySource
Sommige scenario's vereisen fijnere controle over de geïnjecteerde properties. De annotatie `@DynamicPropertySource` maakt het mogelijk configuratiewaarden expliciet te definiëren.
```java
// OrderRepositoryIntegrationTest.java
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryIntegrationTest {
@Container
static PostgreSQLContainer> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
)
// Specific container configuration
.withDatabaseName("orders_test")
.withUsername("test_user")
.withPassword("test_password")
// SQL initialization script
.withInitScript("db/init-orders.sql");
// Manual injection of dynamic properties
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// JDBC URL generated dynamically with mapped port
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
// Additional properties if needed
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired
private OrderRepository orderRepository;
@Autowired
private EntityManager entityManager;
@Test
void shouldPersistOrderWithItems() {
// Given: an order with items
Order order = new Order();
order.setOrderNumber("ORD-2026-001");
order.setStatus(OrderStatus.PENDING);
OrderItem item = new OrderItem();
item.setProductId(1L);
item.setQuantity(2);
item.setUnitPrice(BigDecimal.valueOf(29.99));
order.addItem(item);
// When: save the order
Order saved = orderRepository.save(order);
entityManager.flush();
entityManager.clear();
// Then: order and its items are persisted
Order found = orderRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getItems()).hasSize(1);
assertThat(found.getItems().get(0).getQuantity()).isEqualTo(2);
}
}
```
Het initialisatiescript `withInitScript` bereidt het schema voor of voegt referentiedata toe vóór de uitvoering van de tests.
## Volledige Spring Boot-integratietests
Om de volledige applicatie te testen met alle componenten geladen, vervangt `@SpringBootTest` de annotatie `@DataJpaTest`. Deze configuratie start de volledige Spring-context samen met de PostgreSQL-container.
```java
// UserServiceIntegrationTest.java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserServiceIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
);
@Autowired
private UserService userService;
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldCreateUserViaApi() {
// Given: a creation request
CreateUserRequest request = new CreateUserRequest(
"api@example.com",
"API User",
"securePassword123"
);
// When: call the REST API
ResponseEntity response = restTemplate.postForEntity(
"/api/users",
request,
UserResponse.class
);
// Then: user is created successfully
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().email()).isEqualTo("api@example.com");
}
@Test
void shouldRetrieveUserById() {
// Given: an existing user
UserResponse created = userService.createUser(
new CreateUserRequest("retrieve@example.com", "Retrieve User", "password")
);
// When: retrieve by ID
ResponseEntity response = restTemplate.getForEntity(
"/api/users/" + created.id(),
UserResponse.class
);
// Then: user is returned
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().name()).isEqualTo("Retrieve User");
}
}
```
De automatisch geconfigureerde `TestRestTemplate` wijst naar de server die op een willekeurige poort is gestart, waardoor poortconflicten tussen parallelle tests worden vermeden.
## Hergebruik van containers tussen tests
Het starten van een Docker-container duurt enkele seconden. Om de uitvoering van tests te versnellen, maakt Spring Boot 3.4 hergebruik van containers mogelijk via een gecentraliseerde configuratie.
```java
// TestcontainersConfiguration.java
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
// Reusable container bean across all tests
@Bean
@ServiceConnection
PostgreSQLContainer> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withReuse(true)
.withLabel("reuse.UUID", "e06d7a87-7d7d-472e-a047-7c2c6d4b5f7a");
}
@Bean
@ServiceConnection
RedisContainer redisContainer() {
return new RedisContainer(DockerImageName.parse("redis:7-alpine"))
.withReuse(true)
.withLabel("reuse.UUID", "b3c8f9d2-4a5e-4c8d-9f2a-1b3c5d7e9f0a");
}
}
```
De tests importeren deze configuratie om dezelfde containers te delen.
```java
// ProductServiceIntegrationTest.java
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class ProductServiceIntegrationTest {
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCacheProductDetails() {
// Given: a product in database
Product product = new Product();
product.setName("Cached Product");
product.setPrice(BigDecimal.valueOf(99.99));
productRepository.save(product);
// When: two successive calls
ProductDto first = productService.getProductById(product.getId());
ProductDto second = productService.getProductById(product.getId());
// Then: second call uses cache
assertThat(first).isEqualTo(second);
}
}
```
Om hergebruik van containers in te schakelen, moet de configuratie worden toegevoegd in `~/.testcontainers.properties`:
```properties
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
```
> **Data opschonen**
>
> Bij hergebruik van containers blijven de data bestaan tussen uitvoeringen. Het is raadzaam `@Sql` of `@BeforeEach` te gebruiken om tabellen vóór elke test op te schonen, of een ander schema per testklasse te configureren.
## Tests met Redis en gedistribueerde cache
Testcontainers ondersteunt Redis voor het testen van cachefunctionaliteit. De Redis-module biedt een vooraf geconfigureerde container die klaar is voor gebruik.
```java
// CacheServiceIntegrationTest.java
@SpringBootTest
@Testcontainers
class CacheServiceIntegrationTest {
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer(
DockerImageName.parse("redis:7-alpine")
);
@Autowired
private CacheService cacheService;
@Autowired
private StringRedisTemplate redisTemplate;
@Test
void shouldStoreAndRetrieveFromCache() {
// Given: a value to cache
String key = "user:123";
String value = "{\"id\":123,\"name\":\"Cached User\"}";
// When: store in cache
cacheService.put(key, value, Duration.ofMinutes(10));
// Then: value is retrievable
String cached = cacheService.get(key);
assertThat(cached).isEqualTo(value);
}
@Test
void shouldExpireAfterTtl() throws InterruptedException {
// Given: a value with short TTL
String key = "expiring:key";
cacheService.put(key, "temporary", Duration.ofSeconds(1));
// When: wait for expiration
Thread.sleep(1500);
// Then: key has expired
String cached = cacheService.get(key);
assertThat(cached).isNull();
}
@Test
void shouldIncrementCounter() {
// Given: a counter key
String counterKey = "page:views:homepage";
// When: multiple increments
Long first = redisTemplate.opsForValue().increment(counterKey);
Long second = redisTemplate.opsForValue().increment(counterKey);
Long third = redisTemplate.opsForValue().increment(counterKey);
// Then: counter increments correctly
assertThat(first).isEqualTo(1);
assertThat(second).isEqualTo(2);
assertThat(third).isEqualTo(3);
}
}
```
Spring Boot detecteert de `RedisContainer` automatisch via `@ServiceConnection` en configureert `spring.data.redis.host` en `spring.data.redis.port`.
## Tests met Kafka en asynchrone messaging
Event-driven applicaties vereisen tests met een echte Kafka-broker. Testcontainers biedt een Kafka-module die een single-node cluster start die geschikt is voor tests.
```java
// OrderEventIntegrationTest.java
@SpringBootTest
@Testcontainers
@EmbeddedKafka(partitions = 1, topics = {"order-events"})
class OrderEventIntegrationTest {
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
@Autowired
private KafkaTemplate kafkaTemplate;
@Autowired
private OrderEventConsumer orderEventConsumer;
@Test
void shouldPublishAndConsumeOrderEvent() throws Exception {
// Given: an order event
OrderEvent event = new OrderEvent(
"ORD-2026-100",
OrderEventType.CREATED,
LocalDateTime.now()
);
// When: publish to Kafka
kafkaTemplate.send("order-events", event.orderId(), event).get();
// Then: event is consumed (with timeout)
await()
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> {
assertThat(orderEventConsumer.getReceivedEvents())
.hasSize(1)
.first()
.extracting(OrderEvent::orderId)
.isEqualTo("ORD-2026-100");
});
}
}
```
```java
// OrderEventConsumer.java
@Component
public class OrderEventConsumer {
private final List receivedEvents = new CopyOnWriteArrayList<>();
@KafkaListener(topics = "order-events", groupId = "test-group")
public void consume(OrderEvent event) {
receivedEvents.add(event);
}
public List getReceivedEvents() {
return List.copyOf(receivedEvents);
}
public void clear() {
receivedEvents.clear();
}
}
```
De Awaitility-bibliotheek behandelt asynchrone asserties met timeout en vermijdt zo fragiele `Thread.sleep`-aanroepen in tests.
## Multi-container configuratie met Docker Compose
Voor complexe applicaties die meerdere onderling afhankelijke services vereisen, ondersteunt Testcontainers Docker Compose-bestanden.
```yaml
# src/test/resources/docker-compose-test.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- "5432"
redis:
image: redis:7-alpine
ports:
- "6379"
localstack:
image: localstack/localstack:3.0
environment:
SERVICES: s3,sqs
DEFAULT_REGION: eu-west-1
ports:
- "4566"
```
```java
// FullStackIntegrationTest.java
@SpringBootTest
@Testcontainers
class FullStackIntegrationTest {
@Container
static DockerComposeContainer> environment = new DockerComposeContainer<>(
new File("src/test/resources/docker-compose-test.yml")
)
.withExposedService("postgres", 5432)
.withExposedService("redis", 6379)
.withExposedService("localstack", 4566)
.waitingFor("postgres", Wait.forListeningPort())
.waitingFor("redis", Wait.forListeningPort())
.waitingFor("localstack", Wait.forLogMessage(".*Ready\\.$", 1));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// PostgreSQL configuration
String postgresHost = environment.getServiceHost("postgres", 5432);
Integer postgresPort = environment.getServicePort("postgres", 5432);
registry.add("spring.datasource.url",
() -> "jdbc:postgresql://" + postgresHost + ":" + postgresPort + "/testdb");
registry.add("spring.datasource.username", () -> "testuser");
registry.add("spring.datasource.password", () -> "testpass");
// Redis configuration
String redisHost = environment.getServiceHost("redis", 6379);
Integer redisPort = environment.getServicePort("redis", 6379);
registry.add("spring.data.redis.host", () -> redisHost);
registry.add("spring.data.redis.port", () -> redisPort);
// LocalStack S3 configuration
String localstackHost = environment.getServiceHost("localstack", 4566);
Integer localstackPort = environment.getServicePort("localstack", 4566);
registry.add("aws.s3.endpoint",
() -> "http://" + localstackHost + ":" + localstackPort);
}
@Autowired
private FileStorageService fileStorageService;
@Test
void shouldUploadFileToS3() {
// Given: a file to upload
byte[] content = "Test file content".getBytes();
String fileName = "test-file.txt";
// When: upload to S3 via LocalStack
String url = fileStorageService.upload(fileName, content);
// Then: file is accessible
assertThat(url).contains(fileName);
byte[] downloaded = fileStorageService.download(fileName);
assertThat(downloaded).isEqualTo(content);
}
}
```
## Best practices en prestatie-optimalisatie
Een efficiënte uitvoering van Testcontainers vereist enkele optimalisaties om de buildtijden te verkorten.
```java
// AbstractIntegrationTest.java
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
// Shared container across all inheriting classes
@Container
@ServiceConnection
protected static PostgreSQLContainer> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
)
.withReuse(true);
@Autowired
protected JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanDatabase() {
// Clean tables in order to respect FK constraints
jdbcTemplate.execute("TRUNCATE TABLE order_items CASCADE");
jdbcTemplate.execute("TRUNCATE TABLE orders CASCADE");
jdbcTemplate.execute("TRUNCATE TABLE users CASCADE");
}
}
```
```java
// UserIntegrationTest.java
class UserIntegrationTest extends AbstractIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldCreateUser() {
// PostgreSQL container already started via parent class
User user = new User();
user.setEmail("inherited@test.com");
user.setName("Inherited Test");
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull();
}
}
```
De abstracte klasse centraliseert de containerconfiguratie en het opschonen van de database, waardoor codeduplicatie wordt voorkomen.
```properties
# src/test/resources/application-test.properties
# Test-specific configuration
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false
# Disable Flyway/Liquibase if using ddl-auto
spring.flyway.enabled=false
# Reduced connection pool for tests
spring.datasource.hikari.maximum-pool-size=5
spring.datasource.hikari.minimum-idle=2
```
> **Lichte Docker-images**
>
> Alpine-images (`postgres:16-alpine`, `redis:7-alpine`) verdienen de voorkeur omdat ze lichter zijn en sneller starten. Voor tests is het functionele verschil ten opzichte van de volledige images verwaarloosbaar.
## Tests van databasemigraties
Testcontainers blinkt uit in het testen van Flyway- of Liquibase-migraties tegen een echte database.
```java
// FlywayMigrationTest.java
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class FlywayMigrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
);
@Autowired
private Flyway flyway;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldApplyAllMigrations() {
// Given: migrations applied at startup
// When: check state
MigrationInfoService info = flyway.info();
// Then: all migrations are applied
assertThat(info.pending()).isEmpty();
assertThat(info.applied()).isNotEmpty();
}
@Test
void shouldCreateExpectedTables() {
// Given: migrations applied
// When: query system tables
List tables = jdbcTemplate.queryForList(
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = 'public' AND table_type = 'BASE TABLE'",
String.class
);
// Then: expected tables exist
assertThat(tables).contains("users", "orders", "order_items", "products");
}
@Test
void shouldHaveCorrectColumnTypes() {
// Given: users table created
// When: verify schema
List