Testing a Java project with TestContainers on JUnit 5

java spring boot docker container testing testcontainers


November 12, 2018

TestContainers is a helpful tool for writing integration and functional tests with Docker containers as fixtures. Starting with version 1.10.0, the Java library of TestContainers supports writing and executing tests with JUnit 5, a feature long-awaited by the community. Time to explore the functionality by example!

In this blog post, you will learn how to build an image for a Java application on-the-fly, start up a container as test fixture and stop it after the test has finished. You will also understand how to set up a Gradle build and IntelliJ to run those tests. You can find the full-fledged source code on GitHub if you want to dig deeper.

Configuring the dependencies

Before you can get started writing tests with JUnit 5 and TestContainers, you’ll have to add the relevant dependencies to your project. The example project uses the build tool Gradle. Listing 1 shows how to declare the dependencies for the latest version of JUnit 5 and TestContainers. Both libraries are available on Maven Central or JCenter.

build.gradle

repositories {
    jcenter()
}

dependencies {
    def junitJupiterVersion = '5.3.1'
    testImplementation "org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion"
    testImplementation "org.junit.jupiter:junit-jupiter-params:$junitJupiterVersion"
    testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitJupiterVersion"
    testImplementation 'org.testcontainers:junit-jupiter:1.10.1'
}

Listing 1. Declaring the dependencies for JUnit Jupiter and TestContainers

Next, you’ll also need to configure test execution in Gradle.

Configuring test execution

By default, Gradle uses JUnit 4.x as the default test framework. To switch to JUnit 5, you’ll have to explicitly call the method useJUnitPlatform() on the test task. When building the Docker image for a Java application, your test code needs to know where to find the JAR file of your application. You can provide the path by setting a system property. Listing 2 demonstrates the necessary setup for testing a Spring Boot application in a Gradle build. To ensure that the JAR file always contains the latest changes, the test task defines a task dependency on assemble.

tasks.withType(Test) {
    useJUnitPlatform()
    systemProperty 'distribution.dir', bootJar.destinationDir
    systemProperty 'archive.name', bootJar.archiveName
}

test.dependsOn assemble

Listing 2. Ensuring the proper setup for executing JUnit 5 tests with TestContainers

The build should be ready to go. Let’s have a look at the actual test implementation.

Building and using a container as test fixture

Our test class makes the following assumptions. The code under test represents a web service with endpoints for managing a To Do list. The test cases interact with those endpoints to verify the correct behavior by performing HTTP calls against them.

The TestContainers library provides a rich API for starting containers as test fixtures. The API includes functionality for building a Dockerfile, creating an image from the Dockerfile and starting a container for the image. The test code in listing 3 uses the JUnit 5-compatible annotations to achieve exactly that.

ToDoWebServiceFunctionalTest.java

@Testcontainers
public class ToDoWebServiceFunctionalTest {
    private final static File DISTRIBUTION_DIR = new File(System.getProperty("distribution.dir"));
    private final static String ARCHIVE_NAME = System.getProperty("archive.name");

    @Container
    private GenericContainer appContainer = createContainer();

    private static GenericContainer createContainer() {
        return new GenericContainer(buildImageDockerfile())
                .withExposedPorts(8080)
                .waitingFor(Wait.forHttp("/actuator/health")
                .forStatusCode(200));
    }

    private static ImageFromDockerfile buildImageDockerfile() {
        return new ImageFromDockerfile()
                .withFileFromFile(ARCHIVE_NAME, new File(DISTRIBUTION_DIR, ARCHIVE_NAME))
                .withDockerfileFromBuilder(builder -> builder
                        .from("openjdk:jre-alpine")
                        .copy(ARCHIVE_NAME, "/app/" + ARCHIVE_NAME)
                        .entryPoint("java", "-jar", "/app/" + ARCHIVE_NAME)
                        .build());
    }
}

Listing 3. Creating and starting a container as test fixture

I want to point out of a couple of interesting pieces in the listing. The Dockerfile consists of only three instructions. It uses the base image named openjdk:jre-alpine to make the resuting image as small as possible. Then it copies the JAR file and points the java command to it as entrypoint. When starting the container, test execution blocks until the Spring Boot application becomes "healthy". In this case, the application uses Actuator to expose a health status endpoint.

Using the JAR file for running the application in a container may not be the most performant option. Every single change to the source code will result in the need to rebuild the archive.

The better alternative is to define the Dockerfile in a way that creates separate layers for external dependencies, resources files and class files. That way, Docker can cache unchanged layers. You will also want to disable automatic deletion of images for TestContainers.

It’s the test cases' responsibility to verify the application’s endpoints by making a HTTP call and inspecting the response. The GenericContainer class exposes methods for retrieving the the container’s IP address and its exposed ports. With this information, you can determine the endpoint URL exposed by the container, as shown in listing 4.

ToDoWebServiceFunctionalTest.java

@Test
@DisplayName("can retrieve all items before and after inserting new ones")
void retrieveAllItems() {
    // Use endpoint URL to make HTTP calls
}

private URL buildEndpointUrl(String context) {
    StringBuilder url = new StringBuilder();
    url.append("http://");
    url.append(appContainer.getContainerIpAddress());
    url.append(":");
    url.append(appContainer.getFirstMappedPort());
    url.append(context);

    try {
        return new URL(url.toString());
    } catch (MalformedURLException e) {
        throw new RuntimeException("Invalid URL", e);
    }
}

Listing 4. Building the endpoint URL for the container

Executing the test from the build works just fine. But what about running tests in the IDE?

Executing tests from the IDE

Developers live and breathe in the IDE. Unfortunately, the integration between the IDE and a build tool is not as tight as it could. For example if you execute the same test case from the IDE, you’d run into a similar exception shown below.

java.lang.ExceptionInInitializerError
	at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
Caused by: java.lang.NullPointerException
	at java.base/java.io.File.<init>(File.java:276)
	at com.bmuschko.todo.webservice.ToDoWebServiceFunctionalTest.<clinit>(ToDoWebServiceFunctionalTest.java:23)
	... 44 more

Obviously, the system properties we set in the build file couldn’t not be resolved. Even if you’d create the system properties, you’d still have an issue when changing the source code. By default, test execution from the IDE doesn’t create the JAR file. As a result, the latest changes wouldn’t be reflected in the binary file leading to incorrect behavior.

So what can you do to run the tests from the IDE? Really, the only option you have at the moment is to delegate test execution to the build. In IntelliJ, this setting is configurable.



comments powered by Disqus