Integration testing for Spring Boot with Testcontainers

Today, no modern application runs on its own, and it naturally shares responsibilities with other applications. Doing this simplifies the development of individual services and allows better utilization and scaling of the components of larger systems. However, testing applications involving many interconnected services requires much more effort. It can be difficult to set up all necessary services: databases or message brokers an app can depend on locally and replicate this setup for every developer and CI pipeline. 

Containers are one of the most natural solutions that come to mind here. Although wrapping those services in containers eliminates a lot of tedious and local environment-specific setup activities, manually managing the setup can still be difficult.

Why Testcontainers?

That’s when Testcontainers shines. It handles the whole container creation, its lifecycle management, and even configuring the services for the needs of your particular integration tests for you with a programmatic API. Testcontainers is a standard solution for multiple programming languages when it comes to testing with containers. And it has first-class support for Spring applications. It is even included as the default testing dependency on the well-known start.spring.io and is recommended as a library for immediate adoption by the Technology Radar.

Testing Spring Boot applications naturally means we initialize application context with all the beans hierarchy created and ready and all required integrations properties configured. Integration tests with Testcontainers take it to the next level, meaning we will run the tests against the actual versions of databases and other dependencies our application needs to work with executing the actual code paths without relying on mocked objects to cut the corners of functionality. So let’s see how we can move from a locally-working Spring Boot application to integration tests with dockerized services or databases managed with Testcontainers.

Configuring your project

The basic setup of Testcontainers requires only the testcontainers dependency itself, and you’re good to go.

Here is how you can configure a maven project:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers</artifactId>
  <version>1.17.3</version>
</dependency>

Or to a Gradle project:

testImplementation 'org.testcontainers:testcontainers:1.17.3'

It will work immediately, and you’ll be able to manage the containers right away, using the GenericContainer abstraction to represent a service our app will use for the tests.

@Test
  void test() throws IOException, InterruptedException {
    try (var container = new GenericContainer<>("nginx").withExposedPorts(80)) {
      container.start();
      var client = HttpClient.newHttpClient();
      var uri = "http://" + container.getHost() + ":" + container.getFirstMappedPort();
 
      var request = HttpRequest.newBuilder(URI.create(uri)).GET().build();
      var response = client.send(request, HttpResponse.BodyHandlers.ofString());
 
      Assertions.assertTrue(response.body().contains("Thank you for using nginx."));
    }
  }

And it works for anything that runs in a Docker container. Here we’re creating and starting a simple Nginx web server and exposing its default 80 port. The library will pick a random local port and forward the desired port there for us automatically, so you don’t need to bother looking for an available port. Then we’re starting a container and making a GET request.

Note a few cool things about the implementation: implementing the AutoClosable interface allows a container to be automatically closed when leaving the try block. Moreover, Testcontainers will stop and remove the containers it manages after your tests even if they fail or crash, providing you with a clean slate for the next run of the tests.
Another notable detail that makes Testcontainers so friendly for developers – is that all configuration is available through the fluent API, in our case, a call to publish a container port 80: .withExposedPorts(80). Programmatic configuration makes your IDE help with exposing what options are available and allows you to fine-tune what your service dependencies config needs to look like for the particular tests. 

By the way, exposing a container port is also done in a smart way, it doesn’t automatically map to the same fixed port on the host side, which would lead to conflicts when several containers try to expose same value ports. Instead it maps the port to a random available high value port on the host side, which you can also obtain programmatically via the getFirstMappedPort() method.

Testcontainers modules

Nevertheless, even running complex technologies like Kafka or ElasticSearch is quite simple. Testcontainers also provides additional modules that simplify things even more, starting with test framework integrations and ending with a dozen of well-known optimized service and database containers. Each module is available as a separate dependency and may be added to the project when needed. We can simplify the test case noted above by using JUnit Jupiter integration and an Nginx-specific container. It requires adding the mentioned Testcontainers modules to the Maven/Gradle setup. Here is how you can add it to the Maven build:

<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>
 
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>nginx</artifactId>
  <scope>test</scope>
</dependency>

And as soon as the modules are added, we can simplify the above mentioned test as follows:

@Testcontainers
final class NginxTest {
 
    @Container
    NginxContainer<?> nginx = new NginxContainer<>("nginx");
 
    @Test
    void test() throws IOException, InterruptedException, URISyntaxException {
        var client = HttpClient.newHttpClient();
        var url = nginx.getBaseUrl("http", 80);
        var request = HttpRequest.newBuilder(url.toURI()).GET().build();
        var response = client.send(request, HttpResponse.BodyHandlers.ofString());
        
        Assertions.assertTrue(response.body().contains("Thank you for using nginx."));
    }
}

Here, the @Testcontainers annotation enables JUnit5 extension that will manage the lifecycle of the containers for us automatically, it can greatly simplify individual tests by tying the lifecycle of the containers you need to the lifecycle of the tests themselves.  In this example, the container will be started and stopped for each test method inside the NginxTest class.

We’ll explore this approach in more detail in a separate blog post. 

Note, though, that the NginxContainer provides useful helpers for interacting with the web server. In addition, one can specify from where they should serve the extra content. For the web-server service example, it’s a fairly straightforward getBaseUrl("http", 80), but more complex modules can greatly reduce cognitive load by abstracting away more complex configuration, like enabling or disabling ZooKeeper for your Kafka cluster, or producing correct reactive JDBC urls on the fly. 

Abstracting away reusable container setup

So, like the JUnit5 integration module or the Nginx module, Testcontainers provides pluggable architecture where one can abstract away some common service configurations by extending a GenericContainer. For example, here’s how one can create a custom container wrapper hiding container-specific configurations for the Google Cloud Storage emulator container: (note that there’s a fully featured GCloud module available, so the code below is more for your education purposes).

public final class GcsContainer extends GenericContainer<GcsContainer> {
 
    private static final String GCS_CONTAINER =
            "spine3/cloudstorage-emulator:eeaa4f1de686a8d4315d5c2fa2aa26fc9fa242d6";
    private static final int PORT = 9199;
 
    private GcsContainer(DockerImageName image) {
        super(image);
    }
 
    /**
     * Creates a new instance of the GCS emulator container.
     *
     * <p>The connection port is dynamically exposed to the host and can be obtained using
     * {@link #getFirstMappedPort()}.
     */
    public static GcsContainer newInstance() {
        return new GcsContainer(DockerImageName.parse(GCS_CONTAINER))
                .waitingFor(Wait.forLogMessage(".*Listening at.*", 1))
                .withExposedPorts(PORT);
    }
}

Spring Boot application configuration for Testcontainers

While the Spring Boot application can be tested as any other Java application, Spring makes it extra easy to inject and configure the components under test. Testcontainers provide integration with everything Docker from configuration to clean up. And the only thing left is to ensure your Spring app knows where to find all those dockerized services created during the tests.

Dynamic properties-based config

Luckily, Spring Boot provides a neat mechanism to configure the application during testing overriding all static config from before: `DynamicPropertySource`. DynamicPropertySource marked methods are executed in the beginning of the initialization and you can use it to pass the configuration from the dynamically managed containerized services to the app:

@SpringBootTest
@Testcontainers
public class MySQLTest {
 
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8");
 
    @DynamicPropertySource
    static void registerMySQLProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }
}

In this example, we create a MySQL container with Testcontainers and configure the relevant spring.datasource properties, so the app properly connects to the actual database during the tests. 

The only thing to mention is that the dynamic property source method must follow the contract, have a single DynamicPropertyRegistry argument, be annotated with a @DynamicPropertySource annotation, and be static.

Wiring it all together

To make a Spring Boot application easily testable with real databases or other services your code depends on, you can use Testcontainers. 

There are two essential things to consider: container configuration, lifecycle, and clean-up, which are nicely abstracted by the Testcontainers core libraries and the modules for specific technologies. And dynamically configuring the app under test to use the dockerized dependencies, the simplest way for which is using the @DynamicPropertySource methods.

All in all, Spring Boot and testcontainers are a very flexible combination, and if you haven’t looked at it before, perhaps you should consider writing more integration tests now it’s so easy to get started. 

If you want to try this combination, you can visit this GitHub repository for a step-by-step workshop on integrating Testcontainers into a Spring Boot application.