"How to set up Testcontainers with Spring Boot" has already been described hundreds times. I am not going to write the same things that have already been said but rather discuss the pros and cons of the existing solutions and present one that works for me and I believe works for majority of projects.
Specifically, I am looking for a solution that meets following criteria:
- as little overhead as possible
- containers are started only once for all tests
- containers are started in parallel
- no requirement for test inheritance
- declarative usage
Dynamic Property Source
@DynamicPropertySource
annotation was introduced in Spring Framework 5.2.5. While it is not bound to Testcontainers integration, its main purpose was to simplify and reduce boilerplate from the Testcontainers and Spring Boot setup.
Typically it looks like this:
@Testcontainers
@SpringBootTest
public class AppTests {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:15.1"));
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3"));
@Test
void firstTest() {
// ...
}
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
The Testcontainers annotations: @Testcontainers
& @Container
from org.testcontainers:junit-jupiter
handle the container lifecycle (start & stop), @DynamicPropertySource
pulls the properties from running containers and adds them to Spring configuration.
Such setup works especially well for small applications with a single integration test class. If there are more integration test classes that use Testcontainers, the only way to avoid duplication is to move the container setup to a parent class and make all test classes extend the parent test class. It is not neccessarily a bad thing, but such parent base test classes tend to grow with time, become bloated and difficult to read. Ideally, I try to stay away from such.
Another issue is the container lifecycle - containers are started and stopped for each test class. If this is not your intentional behavior, you are likely dealing with extra seconds or even minutes of overhead.
Lets look how it matches the criteria:
- as little overhead as possible
- 🛑 containers are started only once for all tests
- 🛑 containers are started in parallel
- 🛑 no requirement for test inheritance
- ✅ declarative usage
Run containers only once for all tests
To run containers only once for all tests we must control the container lifecycle manually - meaning we do not, and should not rely on @Testcontainers
annotations.
@SpringBootTest
public class AppTests {
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:15.1"));
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3"));
static {
postgres.start();
kafka.start();
}
@Test
void firstTest() {
}
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
// no changes here
}
}
Starting containers in the static block ensures that it happens before Spring Boot starts the application context.
Start containers in parallel
Once we move to controlling lifecycle manually, starting containers in parallel is trivial. We basically replace the static block from previous snippet with:
import org.testcontainers.lifecycle.Startables;
// ...
static {
Startables.deepStart(postgres, kafka).join();
}
Test inheritance ..?
Unfortunately, we are stuck with the test inheritance, because @DynamicPropertySource
annotation is searched only on the actual test class, or any parent class in the hierarchy.
Lets take a look at the alternative approach - a custom ApplicationContextInitializer
.
Custom Application Context Initializer
Custom ApplicationContextInitializer
is the way we used to set up Testcontainers before @DynamicPropertySource
was introduced. It is similar, but with more ceremony. It does have though some benefits.
The initial setup looks very similar:
@SpringBootTest
@Testcontainers
@ContextConfiguration(initializers = AppTests.TestcontainersInitializer.class)
public class AppTests {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:15.1"));
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3"));
@Test
void firstTest() {
}
static class TestcontainersInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext ctx) {
TestPropertyValues.of(
"spring.kafka.bootstrap-servers=" + kafka.getBootstrapServers(),
"spring.datasource.url=" + postgres.getJdbcUrl(),
"spring.datasource.username=" + postgres.getUsername(),
"spring.datasource.password=" + postgres.getPassword()
).applyTo(ctx.getEnvironment());
}
}
}
It suffers from the same "overhead" issues, and which can be fixed in exactly the same way:
@SpringBootTest
@ContextConfiguration(initializers = AppTests.TestcontainersInitializer.class)
public class AppTests {
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:15.1"));
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3"));
static {
Startables.deepStart(postgres, kafka).join();
}
@Test
void firstTest() {
}
static class TestcontainersInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
// no changes here
}
}
Since we are not using anymore @Testcontainers
, TestcontainersInitializer
can be moved to a top level class:
class TestcontainersInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:15.1"));
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3"));
static {
Startables.deepStart(postgres, kafka).join();
}
@Override
public void initialize(ConfigurableApplicationContext ctx) {
TestPropertyValues.of(
"spring.kafka.bootstrap-servers=" + kafka.getBootstrapServers(),
"spring.datasource.url=" + postgres.getJdbcUrl(),
"spring.datasource.username=" + postgres.getUsername(),
"spring.datasource.password=" + postgres.getPassword()
).applyTo(ctx.getEnvironment());
}
}
The benefit of having the initializer as a separate class is that if there are more integration test classes, Testcontainers setup can be included through annotations composition instead of inheritance.
@SpringBootTest
@ContextConfiguration(initializers = TestcontainersInitializer.class)
public class AppTests {
// ...
}
Another advantate of using initializer, is the ability to have multiple Testcontainers configurations and compose them for different tests setup (thanks @Siva for a hint!)
@EnableTestcontainers
To make the annotation setup more self-explanatory and easier to read, we can create a new annotation @EnableTestcontainers
and meta-annotate it with previously used @ContextConfiguration
:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ContextConfiguration(initializers = TestcontainersInitializer.class)
public @interface EnableTestcontainers {
}
This way, the test class looks like extremely clean:
@SpringBootTest
@EnableTestcontainers
public class AppTests {
// ...
}
This solution ticks all the boxes:
- ✅ as little overhead as possible
- ✅ containers are started only once for all tests
- ✅ containers are started in parallel
- ✅ no requirement for test inheritance
- ✅ declarative usage
Alternative approaches
While there is no official support for Testcontainers in Spring Boot, there is an unofficial one developed by Playtika: testcontainers-spring-boot, that unquestionably reduces the boilerplate to minimum, as long as the list of supported services matches your needs.