Testcontainers – How to use them in your Spring Boot Integration Tests


Probably, the most annoying thing about integration tests is the need for testing infrastructure.
If we want to test our Spring Data Repositories, we need a database. We could just use the H2 in-memory database provided by Spring Boot but the problem with this approach is, that H2 is probably not the database we use at runtime.
This means, our integration tests don’t tell us if our code works as expected when it runs in the production environment. The solution to this problem seems to be obvious – we could use the real database in our integration tests. We could create a separate database on our local server and connect our tests against it.
This would work on our dev machines, but would also introduce other problems. What if our application not only uses a database, but also a message bus, a key-value store, and a remote 3rd party Service? As the developers, we would need to install all those services and maintain them. And the problems won’t even stop on our local machines. How would we handle this problem on our CI Server where the tests run, before they get deployed? Thankfully there is one answer to all of these challenges - Testcontainers!

What is Testcontainers?

Testcontainers is a Java library that provides functionality to handle a docker container. You can start any container by using the GenericContainer with any docker image, one of the specialized containers (e.g PostgreSqlContainer) provided by a module, or by programmatically creating your own image on the fly.
To bind the container to the lifecycle of your JUnit tests, you can use the provided integration. Testcontainers also ensures that the application inside the container is started and ready to use, by providing a WaitStrategy.

The Example

In this blog post, we will upgrade the code from “How to test the Data Layer of your Spring Boot Application with @DataJpaTest” to use a real PostgreSQL Database.
I already changed the database used in production from H2 to PostgreSQL.

To use Testcontainers in our tests, we first need to add some dependencies to our project.

pom.xml
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>1.17.3</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- other dependencies -->
    <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>
</dependencies>
  • Testcontainers provides a Bill of Material (BOM) to manage the versions for our project’s dependencies.
  • The dependency for Testcontainers itself - thanks to the BOM, we don’t need to provide a version.
  • The JUnit Jupiter Integration which provides a JUnit extension to bind docker containers to tests.
  • One of the ready-to-use modules. This one contains a PostgreSQL Container.

How to use Testcontainers?

With all the preparation done, we can now start to implement our first @DataJpaTest, which will use a real PostgreSQL Database.

RestaurantTest.java
@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ContextConfiguration(initializers = RestaurantTest.DataSourceInitializer.class)
class RestaurantTest {

    @Container
    private static final PostgreSQLContainer<?> database = new PostgreSQLContainer<>("postgres:12.9-alpine");

    // ...

    public static class DataSourceInitializer 
        implements ApplicationContextInitializer<ConfigurableApplicationContext> {

        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
                    applicationContext,
                    "spring.datasource.url=" + database.getJdbcUrl(),
                    "spring.datasource.username=" + database.getUsername(),
                    "spring.datasource.password=" + database.getPassword()
            );
        }
    }
}
  • The @Testcontainers annotation is the JUnit Jupiter Extension that binds the lifecycle of a docker container to the one of the tests. It will start each container marked with @Container at the beginning of your test and will stop them at the end of the test. If the container is an instance field, the container will be started and stopped for each test. If it is a static field, the container will be started before the first test of the class and stopped after the last one.
  • Normally, Spring Boot will start an in-memory database for @DataJpaTest-Tests. We use AutoConfigureTestDatabase to explicitly deactivate this default behavior. This way, the tests will run against our PostgreSQL Database.
  • To make some dynamic configurations at the runtime of our tests, we use @ContextConfiguration.
  • The @Container annotation is a marker to tell @Testcontainers which container it should manage.
  • This is the Initializer used in the @ContextConfiguration. We use it to configure the connection to the PostgreSQL database running in the container.
  • This is an example of the dynamic configuration of the data source URL. We assign the dynamic JDBC URL of the database to Spring’s spring.datasource.url property.

The Test

In the last part, we created the boilerplate code needed to run our tests against a real PostgreSQL Database. Now it’s time to create a test!

RestaurantTest.java
@Test
void restaurantsWithAnAverageRatingOfEight_AreTopRatedRestaurants() {

    var topRestaurant = new Restaurant("Café Java");
    topRestaurant.rate(10);

    var normalRestaurant = new Restaurant("Jakarta Restaurant");
    normalRestaurant.rate(8);
    normalRestaurant.rate(7);

    restaurantRepository.save(topRestaurant);
    restaurantRepository.save(normalRestaurant);

    List<Restaurant> topRestaurants = restaurantRepository.findTopRatedRestaurants();
    assertEquals(1, topRestaurants.size());
    assertEquals("Café Java", topRestaurants.get(0).getName());
}
  • We create two restaurants with some ratings. The first one qualifies as a “Top” restaurant while the other one does not.
  • We use the repository to persist the two restaurants.
  • We call RestaurantRepository::findTopRatedRestaurants() - the method containing the logic we want to test - and make some assertions about the response.

As you may have noticed, this code looks exactly the same as the one in my previous blog post about @DataJpaTest. It does not contain anything related to Testcontainers because the JUnit Extension and the DataSourceInitializer take care of the container and the connection between the application and the database.

Some Refactoring

In the shown example we made all configurations on a single test class. In your real-world project, you will probably have more than one. Does that mean you have to add the boilerplate code to each test class? Thankfully, no. The JUnit extensions can be used on a superclass which can be extended by a concrete test class.

DatabaseTest.java
@Testcontainers
@ContextConfiguration(initializers = DatabaseTest.DataSourceInitializer.class)
public abstract class DatabaseTest {

    @Container
    private static final PostgreSQLContainer<?> database = new PostgreSQLContainer<>("postgres:12.9-alpine");

    public static class DataSourceInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
                    applicationContext,
                    "spring.test.database.replace=none",
                    "spring.datasource.url=" + database.getJdbcUrl(),
                    "spring.datasource.username=" + database.getUsername(),
                    "spring.datasource.password=" + database.getPassword()
            );
        }
    }
}
  • We created a new base class for our tests, that contains all the configurations for Testcontainers, that were previously on our RestaurantTest test class.
  • This new property prevents all extending tests from starting an in-memory database. It is the replacement for the AutoConfigureTestDatabase annotation.

Let’s have a look at our test class:

RestaurantRefactoredTest.java
@DataJpaTest
class RestaurantRefactoredTest extends DatabaseTest {
    // test code
}
  • The only thing we need to do is to extend our new base class. Clean, isn’t it?

Some more Refactoring

Thanks to Testcontainers JDBC support, we can reduce our setup even more. By modifying the JDBC connection URL in our application.properties the complete DatabaseTest class becomes obsolete.

application-postgres.properties
spring.datasource.url=jdbc:tc:postgresql:12.9-alpine:///spring_boot_testcontainers
spring.test.database.replace=none
  • To use Testcontainers we first need to modify the protocol part of the JDBC URL. We add tc: after the jdbc:, followed by the container we want to use in our test. Because the host and port are unimportant for Testcontainers, we can skip them by using a host-less URI (///).
  • @DataJpaTest would still try to use an H2 database. By adding this property we turn this behavior off again.

The resulting test class will look like this:

RestaurantRefactoredJdbcUrlTest.java
@DataJpaTest
@ActiveProfiles("postgres")
class RestaurantRefactoredJdbcUrlTest {
    // test code
}
  • By adding the @ActiveProfile annotation, Spring will load the properties from the corresponding properties file. In this case the application-postgres.properties.
  • This test class doesn’t have to extend any class. All configuration is done by Testcontainers.

Testcontainers on CI Environments

Depending on your CI environment, you might need to do some extra configuration to use Testcontainers. Most of these CI Servers use Docker to do their work. This means we will run Docker containers (via Testcontainers) inside another Docker container(of the CI Server).
This approach is supported by Testcontainers, and it will do some configurations if it detects that it is running inside a container. However, you may need to do some configuration on your pipeline.
In a former project, which used Bitbucket Pipelines, we had to enabled docker as an available Service and disable Ryuk (a part of Testcontainers). In another project, where we used Azure Pipelines, we didn’t need any extra configuration.
If and how you need to configure your pipeline is documented at the Continous Integration part of Testcontainers documentation.

Conclusion

With the help of Testcontainers, we can test our application against the infrastructure that is used at runtime, without any setup costs. Thanks to the provided modules and their documentation, it is quite easy to use for the most common use cases. If you have special requirements that are not supported by Testcontainers modules, it gives you the flexibility to configure a container that exactly fits your needs.

Resources