Integration testing is an important part of the Software Testing Life cycle as it helps identify system-level issues such as a broken database schema, incorrect migration scripts, etc.

Quoting the testcontainers site:

Testcontainers is a Java 8 library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

This plays nicely with the JUnit library and allows you to spin up Docker containers that can mimic your production environment, run your tests, and tear down the instances after execution.

Consider an example – the H2 database provides an in-memory database for running self-contained tests. This may not always be a good option as there will always be some compatibility issues between your production database, say Postgres.

Using Testcontainers you can easily swap the H2 with a Postgres container that behaves similarly to your production database, execute the tests and the container will be removed after the execution.

Testcontainers support multiple languages and frameworks including Java, Go, Python, .net, etc. The full list is here.

The prerequisites are :

  • Docker
  • A supported testing framework like Junit/Spock

Recently, I used TestContainers for running integration tests that involved DynamoDB.

And a very basic example project using TestContainers can be found here.

Testcontainers with JUnit5

Let’s build an integration test that involves DynamoDB using Gradle and JUnit5.

Add the dependencies for Testcontainers, JUnit5, AWS DynamoDB and logging in build.gradle

dependencies {
    def junitJupiterVersion = '5.6.2'
    implementation platform('com.amazonaws:aws-java-sdk-bom:1.11.847')
    implementation 'com.amazonaws:aws-java-sdk-dynamodb'
    implementation 'ch.qos.logback:logback-classic:1.2.3'
    testCompile "org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion"
    testCompile "org.junit.jupiter:junit-jupiter-params:$junitJupiterVersion"
    testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitJupiterVersion"
    testCompile "org.testcontainers:testcontainers:1.14.3"
    testCompile "org.testcontainers:junit-jupiter:1.14.3"
}

Creating a container is easy. Just specify the image name in the constructor as follows:

GenericContainer dynamoDb = new GenericContainer("amazon/dynamodb-local:1.13.2");

The dynamodb image exposes the port 8000. So we should expose that port in our configuration to make use of it. The code becomes:

GenericContainer dynamoDb = new GenericContainer("amazon/dynamodb-local:1.13.2")
            .withExposedPorts(8000);

You can change the default command of the container if you’d like. If you need to run the dynamodb in the in-memory mode with a shared DB across regions, you should change the code as below:

GenericContainer dynamoDb = new GenericContainer("amazon/dynamodb-local:1.13.2")
            .withCommand("-jar DynamoDBLocal.jar -inMemory -sharedDb")
            .withExposedPorts(8000);

You can set environment variables using withEnv, if you need:

GenericContainer dynamoDb = new GenericContainer("amazon/dynamodb-local:1.13.2")
            .withCommand("-jar DynamoDBLocal.jar -inMemory -sharedDb")
            .withEnv("MAGIC_NUMBER", "999")
            .withExposedPorts(8000);

Integrating with JUnit5

To integrate with JUnit5,

  • Annotate your test class with @Testcontainers – This annotation finds all fields that are annotated with @Container and calls their container lifecycle methods
  • Create a container instance and annotate it with @Container

The bare minimum code looks as below:

package com.jobinbasani.service;

import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
public class DynamodbIntegrationTest {
    @Container
    static final GenericContainer dynamoDb = new GenericContainer("amazon/dynamodb-local:1.13.2")
            .withCommand("-jar DynamoDBLocal.jar -inMemory -sharedDb")
            .withExposedPorts(8000);
}

Few things to note here:

  • If you annotate an instance field with @Container, the container will be started and stopped for every test method.
  • If you need to start the container before the first test and stop only after the last method, use a static field instead.
  • The exposed port number(8000 in this example) is from the perspective of the container. From the host’s perspective, TestContainers actually exposes this on a random free port.

Managing Container Ports

Testcontainers exposes the container ports on random ports available on the host machine. This is to avoid port collisions that may arise with locally running software. This means, you will have to ask Testcontainers for the actual mapped port at runtime. This can be done using the getMappedPort method, which takes the original (container) port as an argument:

 Integer dynamodbport = dynamoDb.getMappedPort(8000);

If the container exposes only one port, you can also use the getFirstMappedPort method for convenience:

Integer dynamodbport = dynamoDb.getFirstMappedPort();

If you prefer to use a fixed port, that’s possible too, but its not a recommended approach.

@Container
static final GenericContainer dynamoDb = new FixedHostPortGenericContainer("amazon/dynamodb-local:1.13.2")
            .withFixedExposedPort(9000, 8000)
            .withCommand("-jar DynamoDBLocal.jar -inMemory -sharedDb");

This will expose the container port 8000 in host port 9000, but you should make sure that the port 9000 is not used by some other application, or else the test will fail.

With a @BeforeAll annotated method, we can calculate the endpoint URL and build an AmazonDynamoDb client object as below:

@BeforeAll
public static void init() {
        var endpointUrl = String.format("http://localhost:%d", dynamoDb.getFirstMappedPort());
        client = AmazonDynamoDBClientBuilder.standard()
                .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endpointUrl, "us-west-2"))
                .build();
    }

Source Code

The full code sample can be accessed here.

References