Post Thumbnail

How to Configure Testcontainers in Spring Boot 3.x, 2.x and Reactive

1. Overview

Testcontainers provides disposable Docker containers for databases, message queues, Redis, and so much more. It enables us to run fully integrated SpringBoot tests without mocking the Database, Redis, and even RabbitMQ interactions.

In this tutorial, we will learn how to set up Testcontainers in a Spring Boot application. This approach will work for Spring Boot version 3.x, 2.x and even reactive Spring Boot applications.

2. Project Setup and Dependency Installation

For this article, we will work with a reactive Spring Boot application that manages a Pet resource. It uses PostgreSQL database and runs a database schema on start up.

The demo application includes two integration tests for the endpoints:

Listing 2.1 PetControllerIntegrationTest.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Test
void givenValidRequestBody_whenCreate_thenReturn2XX() {

    PetRequest request = new PetRequest();
    request.setName("Aja Ode " + insecure().randomAlphabetic(5).toUpperCase());
    request.setColour("orange");

    webTestClient.post().uri(Routes.Pets.PETS) 
            .bodyValue(request) 
            .exchange() 
            .expectStatus().isOk()
            .expectBody() 
            .jsonPath("$.data.name").isEqualTo(request.getName())
            .jsonPath("$.data.colour").isEqualTo(request.getColour());
}

@Test
void givenExistingPets_whenGetAll_thenReturnAllPets() {

    Pet pet = new Pet();
    pet.setName("Blimey the Goat " + insecure().randomAlphabetic(5).toUpperCase());
    pet.setColour("red");

    StepVerifier.create(petRepository.save(pet)) 
            .assertNext(Assertions::assertNotNull) 
            .verifyComplete();

    webTestClient.get().uri(Routes.Pets.PETS) 
            .exchange() 
            .expectStatus().isOk()
            .expectBody() 
            .jsonPath("$..name").value(Matchers.hasItem(pet.getName()));

}

For these tests to run successfully, we need a running PostgreSQL database server that’s accessible to the application.

Using the Postgres on my local machine means the tests will not be able to run successfully in a CI/CD pipeline. Moreover, we do not want to mix test data with real application data.

Therefore, we will add Testcontainers as part of the test setup, so it can run anywhere, independently without any mocking.

We will use the testcontainers BOM (Bill Of Material) to manage the various versions of the dependencies:

Listing 2.2 pom.xml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<dependencyManagement>
	<dependencies>
		<dependency>
			<groupId>org.testcontainers</groupId>
			<artifactId>testcontainers-bom</artifactId>
			<version>1.20.4</version>
			<type>pom</type>
			<scope>import</scope>
		</dependency>
	</dependencies>
</dependencyManagement>

After that, we can add the following dependencies:

Listing 2.3 pom.xml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<dependencies>
<!--    other dependencies omitted for brevity -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

The PostgreSQL driver in the test scope is for Testcontainers to be able to run the init schema as we will soon learn.

3. Testcontainers Configuration

The number one requirement for Testcontainers is a running Docker environment. So, ensure you have Docker on your local machine, and it is running.

The test classes, for the demo application, have been organised such that, a class named BaseSpringBootTest houses the annotations and main configurations. Every other test classes will extend this one.

We will create a static instance of the PostgreSQLContainer in the BaseSpringBootTest class.

Listing 3.1 BaseSpringBootTest.java

1
2
3
4
5
6
7
static PostgreSQLContainer<?> postgresContainer =
        new PostgreSQLContainer<>("postgres:latest")
                .withDatabaseName("testcontainers_demo")
                .withInitScript("init.sql")
                .withUsername("test")
                .withExposedPorts(5432)
                .withPassword("test");

The PostgreSQLContainer instance above, is set to use the username and password test and run on port 5432 - the default.

The string "postgres:latest" passed to the constructor is the specific Docker image we want to use. With this, we can control the specific version of the PostgreSQL database we want to use for the application.

Testcontainers support running an init script when initializing a database container.

In the above listing, with the method .withInitScript("init.sql"), the PostgreSQLContainer will run all SQL statements in a file named init.sql. The file should be in the src/test/resources directory.

Now, we need to start the container and replace the Spring database connection properties, with that of the PostgreSQLContainer.

We can achieve this in Spring Boot via a @DynamicPropertySource annotated method. This method enables us to replace application properties during start up.

Listing 3.2 BaseSpringBootTest.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@DynamicPropertySource
static void registerDynamicProperties(DynamicPropertyRegistry registry) {

    postgresContainer.start();

    registry.add("spring.r2dbc.url", () -> String.format(
            "r2dbc:postgresql://%s:%d/%s",
            postgresContainer.getHost(),
            postgresContainer.getMappedPort(5432),
            postgresContainer.getDatabaseName()
    ));
    registry.add("spring.r2dbc.username", postgresContainer::getUsername);
    registry.add("spring.r2dbc.password", postgresContainer::getPassword);

    Runtime.getRuntime().addShutdownHook(new Thread(postgresContainer::stop));
}

In the registerDynamicProperties method, we first invoke the start() method of the PostgreSQLContainer. This will cause the program flow to wait for a complete start before proceeding with other steps.

Once the container has started, we then add the Spring datasource properties to the DynamicPropertyRegistry with values from the recently started container.

All database interactions will now be using the PostgreSQL running in the Docker container we started.

Finally, we registered a shutdown hook to stop the container once the application run is complete. In this case, once the test run is completed.

Noticed how we used spring.r2dbc.url and not spring.datasource.url. This is because the demo application is reactive and uses the r2dbc library for database interactions.

We can achieve the same for a non-reactive Spring Boot application:

Listing 3.3 BaseSpringBootTest.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@DynamicPropertySource
static void registerDynamicProperties(DynamicPropertyRegistry registry) {

    postgresContainer.start();

    registry.add("spring.datasource.url", postgresContainer::getJdbcUrl);
    registry.add("spring.datasource.username", postgresContainer::getUsername);
    registry.add("spring.datasource.password", postgresContainer::getPassword);

    Runtime.getRuntime().addShutdownHook(new Thread(postgresContainer::stop));
}

Testcontainers, by default, use random ports. For example, the default PostgreSQL port is 5432. PostgreSQLContainer will internally run on 5432 but map it to a random port for accessibility.

In order for us to get the mapped port for 5432, we can use the function postgresContainer.getMappedPort(5432).

The complete BaseSpringBootTest.java is like this:

Listing 3.4 BaseSpringBootTest.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@SpringBootTest
@ContextConfiguration(classes = WebTestClientConfiguration.class)
public class BaseSpringBootTest {


    static PostgreSQLContainer<?> postgresContainer =
            new PostgreSQLContainer<>("postgres:latest")
                    .withDatabaseName("testcontainers_demo")
                    .withInitScript("init.sql")
                    .withUsername("test")
                    .withExposedPorts(5432)
                    .withPassword("test");

    @DynamicPropertySource
    static void registerDynamicProperties(DynamicPropertyRegistry registry) {

        postgresContainer.start();

        registry.add("spring.r2dbc.url", () -> String.format(
                "r2dbc:postgresql://%s:%d/%s",
                postgresContainer.getHost(),
                postgresContainer.getMappedPort(5432),
                postgresContainer.getDatabaseName()
        ));
        registry.add("spring.r2dbc.username", postgresContainer::getUsername);
        registry.add("spring.r2dbc.password", postgresContainer::getPassword);

        Runtime.getRuntime().addShutdownHook(new Thread(postgresContainer::stop));
    }

}

At this point, we can now run our tests, without mocking the database or relying on a local database instance.

Whenever we start the test run. Testcontainers will pull the required Docker image from Docker Hub, and create a Docker container for the application to use. When the test run completes, Testcontainers will dispose of the Docker container automatically.

4. Generic Containers

Testcontainers provide containers for other resources like RabbitMQ, MySQL, CassandraSQL, Local Stack and so much more. We simply need to add the required modules to our applications as needed.

Moreover, Testcontainers has a generic container that we can use to start up a container with any Docker image.

For example, we can create an instance of a Generic container for Redis:

Listing 4.1 BaseSpringBootTest.java

1
2
static GenericContainer redisContainer = new GenericContainer<>("redis:latest")
        .withExposedPorts(6379);

Using the same technique as above, we can then start the container in a DynamicPropertySource method and override the application’s Redis connection properties:

Listing 4.1 BaseSpringBootTest.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@DynamicPropertySource
static void registerDynamicProperties(DynamicPropertyRegistry registry) {

    postgresContainer.start();
    redisContainer.start();

    registry.add("spring.r2dbc.url", () -> String.format(
            "r2dbc:postgresql://%s:%d/%s",
            postgresContainer.getHost(),
            postgresContainer.getMappedPort(5432),
            postgresContainer.getDatabaseName()
    ));
    registry.add("spring.r2dbc.username", postgresContainer::getUsername);
    registry.add("spring.r2dbc.password", postgresContainer::getPassword);

    registry.add("spring.data.redis.host", redisContainer::getHost);
    registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379));

    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
        postgresContainer.stop();
        redisContainer.stop();
    }));
}

Following this configuration (on lines 5, 16, 17, and 21), all Redis interactions within the Spring Boot test will be using the Redis instance provided by the Testcontainers.

5. Tips and Tricks

5.1 Connecting to a Running TestContainer Instance

Sometimes, we may want to connect to the temporary Docker containers before they’re disposed. The trick is to add a breakpoint somewhere in the test logic and run the test in debug mode.

Once the execution is paused, search the console output for a string like jdbc:.

You will see a line like Container is started (JDBC URL: jdbc:postgresql://localhost:53321/testcontainers_demo?loggerLevel=OFF). With this host and port, we can connect to the running database using the default username and password test.

5.2 Using a Private Docker Repository

Can I use private Docker repositories? Sure. Especially to circumvent the rate limit on Docker Hub. To achieve this, we need to do the following:

  1. Create a testcontainers.properties in src/test/resources
  2. Add hub.image.name.prefix = private.docker-repo.io/ where docker.io should be the URL of your private Docker repo or mirror site
  3. Remember to authenticate to the private Docker repo by executing docker login private.docker-repo.io -u username -p $DOCKER_PASS

Every time Testcontainers needs to pull an image, it will use the configured private Docker repo as opposed to the default Docker Hub.

5.3 Running the Test in a CI/CD Runner

Just like your local machine, your CI/CD environment also must have a valid Docker environment for the tests to run. You can confirm this by running docker --version in your pipeline step.

Different CI/CD providers have different approaches to availing a Docker environment within the pipeline. GitLab CI/CD will call this feature Docker-in-Docker.

Do ensure to consult your CI/CD provider’s documentation on achieving same.

The demo project uses CircleCI and enabling Docker in the pipeline is as simple as adding a setup_remote_docker step.

6. Conclusion

Testcontainers is revolutionary. It eliminates the complexities around running an integrated test for a Spring Boot application.

The complete source code is available on GitHub

Consult the Testcontainers official documentation for more info.

Happy Coding!

You can watch a video version of this article below:

Seun Matt

Results-driven Engineer, dedicated to building elite teams that consistently achieve business objectives and drive profitability. With over 9 years of experience, spanning different facets of the FinTech space; including digital lending, consumer payment, collections and payment gateway using Java/Spring Boot technologies, PHP and Ruby on Rails