One of the aims of The Twelve-Factor App is to reduce the difference between development and production and Testcontainers are a great way to start the exact external services needed for Spring Boot integration tests.

However, the existing JUnit 5 Spring Boot integration for Testcontainers comes with a number of downsides. We’ll briefly discuss these and a way to solve them using a ContextCustomizerFactory.

Working sources for the examples below can be found at mpkorstanje/blog-spring-boot-test-containers.

The Downsides

The Spring documentation suggests using Testcontainers’ in combination with @DynamicPropertySource.

@Slf4j
@SpringBootTest
@Testcontainers
class DemoApplicationTest {

    static final DockerImageName image = DockerImageName
            .parse("postgres")
            .withTag("14.1");

    @Container
    static final PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>(image);

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgresContainer::getJdbcUrl);
        registry.add("spring.datasource.username", postgresContainer::getUsername);
        registry.add("spring.datasource.password", postgresContainer::getPassword);
    }

    @Test
    void contextLoads() {
        log.info("Hello world");
    }

}

Many applications have more than one integration test and when multiple integration test classes are involved this setup has several downsides:

  1. The Testcontainers JUnit 5 extension will start a new test container for each integration test class. Because containers can be relatively slow to start, this can slow down tests significantly.

  2. The @DynamicPropertySource will be different for each class. As a result a new application context will be started for each test class. Application context are also slow to start.

  3. If you use @DynamicPropertySource in a base class, because the Testcontainers extension will start a new test container for each test class the use of @DirtiesContext is required, resulting in a restart of the application context. This is also slow.

This can be observed by duplicating the test class and observing the log when running the tests:

[main] 🐳 [postgres:14.1]                       : Container postgres:14.1 started in PT1.683591943S
[main] c.l.demo.DemoApplicationTest             : Started DemoApplicationTest in 2.502 seconds (JVM running for 6.454)
[main] c.l.demo.DemoApplicationTest             : Hello world
...
[main] 🐳 [postgres:14.1]                       : Container postgres:14.1 started in PT1.446961288S
[main] c.l.demo.DemoApplication2Test            : Started DemoApplication2Test in 0.36 seconds (JVM running for 8.923)
[main] c.l.demo.DemoApplication2Test            : Hello world

Yet there is no need to start the test container or application context twice. Integration tests can often be executed using the same application context and when sufficiently unique identifiers are used often against the same database. So ideally we would create a test container instance for each application context.

.withReuse is not a solution

Testcontainers does provide the option not to reuse a container with .withReuse(true):

	@Container
	static final PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>(image)
			.withReuse(true);

This will not terminate the container after the JVM exits. This results in the reuse of the same container for every test using that image, including those from other projects. To prevent tests from interfering with each other this requires cleaning the database before every test. This is less than ideal as we can no longer leverage database migration tools such as Flyway to create the database schema when starting the application context.

A solution

We can create a test container instance for each application context instance by using a ContextCustomizerFactory:

public class EnablePostgresTestContainerContextCustomizerFactory implements ContextCustomizerFactory {

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Inherited
    public @interface EnabledPostgresTestContainer {
    }

    @Override
    public ContextCustomizer createContextCustomizer(Class<?> testClass,
            List<ContextConfigurationAttributes> configAttributes) {
        if (!(AnnotatedElementUtils.hasAnnotation(testClass, EnabledPostgresTestContainer.class))) {
            return null;
        }
        return new PostgresTestContainerContextCustomizer();
    }

    @EqualsAndHashCode // See ContextCustomizer java doc
    private static class PostgresTestContainerContextCustomizer implements ContextCustomizer {

        private static final DockerImageName image = DockerImageName
                .parse("postgres")
                .withTag("14.1");

        @Override
        public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
            var postgresContainer = new PostgreSQLContainer<>(image);
            postgresContainer.start();
            var properties = Map.<String, Object>of(
                    "spring.datasource.url", postgresContainer.getJdbcUrl(),
                    "spring.datasource.username", postgresContainer.getUsername(),
                    "spring.datasource.password", postgresContainer.getPassword(),
                    // Prevent any in memory db from replacing the data source
                    // See @AutoConfigureTestDatabase
                    "spring.test.database.replace", "NONE"
            );
            var propertySource = new MapPropertySource("PostgresContainer Test Properties", properties);
            context.getEnvironment().getPropertySources().addFirst(propertySource);
        }

    }

}

When a test class is annotated with @EnabledPostgresTestContainer the EnablePostgresContextCustomizerFactory will produce a PostgresTestContainerContextCustomizer. This ContextCustomizer will start a test container and add a property source with the database properties.

This setup is not too different from the implementation of @DynamicPropertySource. Because we no longer use the Testcontainers JUnit 5 extension the container is not shutdown after a test class has finished.

Before use, the customizer must be enabled by adding to META-INF/spring.factories;

org.springframework.test.context.ContextCustomizerFactory=\
  com.logarithmicwhale.demo.EnablePostgresTestContainerContextCustomizerFactory

applying the annotation to the test;

@Slf4j
@SpringBootTest
@EnabledPostgresTestContainer
class DemoApplicationTest {

    @Test
    void contextLoads() {
        log.info("Hello world");
    }

}

then duplicating and running the tests we can now observe in the logs:

[main] 🐳 [postgres:14.1]                       : Container postgres:14.1 started in PT1.638159581S
[main] c.l.demo.DemoApplicationTest             : Started DemoApplicationTest in 4.899 seconds (JVM running for 5.983)
[main] c.l.demo.DemoApplicationTest             : Hello world
[main] c.l.demo.DemoApplication2Test            : Hello world

So by using a ContextCustomizerFactory we achieved the following:

  1. Only one application context is started and reused between test classes.
  2. Only one test container is started and reused between test classes.

This should significantly speed up integration tests for any application using Testcontainers.