Maciej Walkowiak

The best way to use Testcontainers with Spring Boot

Russia has invaded Ukraine and already killed tens of thousands of civilians, with many more raped and tortured. Ukraine needs your help!

Help Ukraine Now!

"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.