Once you’ve handled Testcontainers with PostgreSQL alone, it’s common to wonder how to write tests when Kafka and Redis enter the mix. In this article, I’ll walk through how to write integration tests that spin up all three middlewares simultaneously, using @ServiceConnection introduced in Spring Boot 3.1+.
The basic setup with PostgreSQL alone is covered in How to write integration tests with Spring Boot and Testcontainers, so this article will be easier to follow as a sequel to that one.
Why write integration tests with multiple containers
A DB-only test won’t catch interplay issues between middlewares, such as missing Kafka topic configurations or Redis serializer mismatches. You could substitute with embedded-kafka or embedded-redis, but their behavior is subtly different from the real thing, and you often end up tripping on it in CI anyway.
If you spin up the real middlewares with Testcontainers, you can write integration tests with a configuration close to production. The startup cost becomes a concern in exchange, so I’ll cover container reuse settings and the context cache later on.
Adding dependencies
Add the testcontainers-bom and Spring Boot’s test modules to build.gradle.
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.springframework.kafka:spring-kafka-test'
testImplementation platform('org.testcontainers:testcontainers-bom:1.20.4')
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
testImplementation 'org.testcontainers:kafka'
testImplementation 'org.awaitility:awaitility'
}
PostgreSQL and Kafka have dedicated modules, but Redis is thin enough that we’ll just spin it up with GenericContainer. Locally, you’ll need either Docker Desktop, Colima, or rootless Docker.
What @ServiceConnection takes off your hands
Before Spring Boot 3.1, you had to hand-write spring.datasource.url and the like with @DynamicPropertySource. This was quietly tedious — you had to remember the property names for each container.
When you attach @ServiceConnection, Spring Boot looks at the type of PostgreSQLContainer or KafkaContainer and auto-registers a ConnectionDetails. You no longer have to write spring.datasource.url or spring.kafka.bootstrap-servers.
That said, supported containers are limited, and when you spin up Redis with GenericContainer, you have to assemble RedisConnectionDetails yourself. We’ll look at a concrete example later.
A base class that shares three containers
Spinning up containers from scratch in every test class takes time, so it’s standard practice to share them via static fields.
@SpringBootTest
@Testcontainers
public abstract class AbstractIntegrationTest {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"));
@Container
@ServiceConnection
static final KafkaContainer KAFKA =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
@Container
static final GenericContainer<?> REDIS =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@TestConfiguration
static class RedisConfig {
@Bean
RedisConnectionDetails redisConnectionDetails() {
return new RedisConnectionDetails() {
@Override
public Standalone getStandalone() {
return Standalone.of(REDIS.getHost(), REDIS.getMappedPort(6379));
}
};
}
}
}
Since they’re declared static, JUnit 5’s lifecycle reuses the same containers across test classes. Just by writing one RedisConnectionDetails Bean for Redis, you no longer need to configure spring.data.redis.* properties — the same feel as PostgreSQL or Kafka.
Testing Kafka send/receive
Asynchronous tests are easy to write when combined with Awaitility.
class OrderEventIntegrationTest extends AbstractIntegrationTest {
@Autowired KafkaTemplate<String, String> kafkaTemplate;
@Autowired OrderEventListener listener;
@Test
void shouldConsumeOrderEvent() {
kafkaTemplate.send("orders", "order-1", "{\"id\":\"order-1\"}");
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() -> assertThat(listener.received()).contains("order-1"));
}
}
For test topics, declaring a single NewTopic Bean in @TestConfiguration lets them be created automatically at startup, which is convenient. If you get stuck on rebalancing, making group.id unique per test stabilizes things. This is also touched on in How to implement Producer/Consumer with Spring Boot × Kafka.
Testing Redis cache behavior
class UserCacheIntegrationTest extends AbstractIntegrationTest {
@Autowired StringRedisTemplate redisTemplate;
@Autowired UserService userService;
@Test
void shouldCacheUserOnSecondLookup() {
userService.findById("u-1");
userService.findById("u-1");
assertThat(redisTemplate.opsForValue().get("user:u-1")).isNotNull();
}
}
Without writing spring.data.redis.host and so on, Lettuce automatically connects with just the RedisConnectionDetails Bean from earlier. For Redis integration in general, see How to use Redis from Spring Boot.
Speeding up local execution with container reuse
Once you start writing and running tests, startup time becomes a concern. For local-only use, the reuse setting can give a big speedup.
First, add one line to ~/.testcontainers.properties.
testcontainers.reuse.enable=true
Then, on the container declaration side, attach withReuse(true).
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true);
If the container image, environment variables, and port settings are identical, the container stays alive even after the JVM exits and gets reused on the next startup. Conversely, changing the SQL loaded with withInitScript regenerates it as a different container, so when changing schemas, sweep up old containers with docker rm to avoid confusion.
What to change in CI
In CI, you should basically turn reuse off. The environment is torn down per job anyway, so leaving the reuse flag on just leaves behind useless garbage. What works instead is Spring’s ApplicationContext cache — if @SpringBootTest settings across test classes are identical, the same context is shared.
- Don’t overuse
@MockBean(it fragments the context) - Don’t split test classes excessively
- If you enable JUnit 5 parallel execution, attach
@Execution(SAME_THREAD)to container-dependent tests
Just following these will shrink CI execution time substantially. GitHub Actions’ Docker daemon works out of the box, so no special configuration is usually needed. If you use rootless Docker, just don’t forget to specify DOCKER_HOST.
When external HTTP APIs are involved, you’ll need a separate mock, but I’ve summarized that in How to mock external APIs with Spring Boot and WireMock, so combining the two gives you a fairly complete integration test foundation.
Summary
Thanks to @ServiceConnection, even when you spin up PostgreSQL, Kafka, and Redis at the same time, the test-side code stays very simple. Only Redis requires writing a single ConnectionDetails Bean — everything else auto-connects based on type, and being freed from hand-writing spring.datasource.url and friends is a big deal.
If you operate with withReuse(true) locally and lean on ApplicationContext caching in CI, even multi-container setups can be run at realistic speeds. Start by creating one base class.