Leveraging containers for automated testing

Based on our experience working with Amach clients, one area that invariably throws up issues is setting up test environments for Software Under Test (SUT) for both functional and integration testing. Getting a SUT and its dependencies such as databases, queues and other services setup can be problematic, time consuming and error prone.

Aside from this, ensuring that these external dependencies are in a known state at the beginning of each test run can be difficult to manage especially if these dependencies are shared.

Enter containers!

More specifically enter Docker and Testcontainers.



Docker & TestContainers

Docker is a tool designed to make it easier to create, deploy, and run applications by using containers. Containers allow a developer to package up an application with all of the parts it needs, such as libraries and other dependencies, and deploy it as one package.

Testcontainers is a Java 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. It's basically an API on top of Docker allowing you to programatically make calls to Docker to run containers.

Using Docker you can build your SUT into an image and have this deployed into a Docker environment along with all its dependencies such as an Oracle database, Kafka etc using TestContainers from JUnit tests.

The benefits of this approach are:

  • Full environments including your SUT to be created and destroyed after each test run
  • Testing focused on the tests (not everything else)
  • Delivers the enormous benefit of not having to maintain long lived environments
  • Starts with dependencies in a known state
  • No contamination between test runs
  • No need to maintain or clean up data after testing has completed
  • Allows for services to be tested early and often as part of a CI/CD pipeline




In the above picture the microservice is the service under test (SUT). It requires an Oracle DB and a Kafka instance. Junit starts up the SUT within the Docker environment via TestContainers along with its dependencies Oracle DB and a Kafka instance that the SUT requires to operate.

There is also a Selenium Webdriver that is controlled by Junit in order to run the tests.


Example

In the following example we will build an image using a simple microservice. We have used Spring Boot along with Java to help create the sample application. No previous knowledge of Spring Boot is needed to follow the example.

This sample is available to view/clone here


Setup

For this example you will need the following installed:


Building the Image

To build our service into an image we will use Fabric8 which is a plugin for Maven https://dmp.fabric8.io/ . We will use the simple dockerfile build approach for simplicity but there are many options for building as you can see in the fabric8 documentation.

Here is the main Maven setup in the pom.xml

     <docker.name>${project.artifactId}</docker.name>

     <dependency>
        <groupId>io.fabric8</groupId>
        <artifactId>docker-maven-plugin</artifactId>
        <version>0.33.0</version>
     </dependency>

     <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>io.fabric8</groupId>
                    <artifactId>docker-maven-plugin</artifactId>
                 </plugin>
            </plugins>
        </pluginManagement>
     </build>
                    

We will also include a Dockerfile for the building of our images. This is placed at the root of our project where Fabric8 expects it to be by default. If you are not familiar with Dockerfiles you can read more here

     FROM java:8-jdk-alpine
     COPY ./target/test-containers-example-0.0.1-SNAPSHOT.jar /usr/app/
     WORKDIR /usr/app
     EXPOSE 8080
     ENTRYPOINT ["java", "-jar", "test-containers-example-0.0.1-SNAPSHOT.jar"]
                        

For our simple example we have hard-coded the name of our jar but there are ways and means of making this much more dynamic.

Finally we can build our image. We will run our maven commands on the command line.

First we build our jar

mvn clean package

Then we build our images with our fabric maven goal

mvn docker:build

This takes the jar file and using the Dockerfile an image is built.

Remember if any changes are made to our service we will have to rebuild it and the images in order to see the changes in our test.

We can check our image was created with command

docker images

Which gives the following output:


More information on the this command can be found here


Using the Image

Now that the image has been built we can now use it in our tests with the following code:


     @ClassRule
     public static GenericContainer genericContainer = new GenericContainer("test-containers-example:latest")
       .withExposedPorts(8080)
       .waitingFor(Wait.forHttp("/employees"));
                        

When Junit starts the test, it first starts the class rule. This starts our image in a container within our Docker environment and maps port 8080 to a random port on the host machine. The code here also waits for the service to start up by checking for a 200 status code response from the /employees endpoint.

We can construct the the URL to use in our tests by getting the IP address and port in the following way:


     String address = String.format("http://%s:%s", genericContainer.getContainerIpAddress(), genericContainer.getFirstMappedPort());
                        

With this we can now make requests to our service within the container. Using RestAssured we can make a request in the following way:


     given().spec(requestSpecification)
       .when().get("/employees")
       .then().statusCode(SC_OK).extract().jsonPath();
                        

As this is a GET request you can also call the service via a browser using the IP address and mapped port. Note the mapped port will be a random port on the host but will map to port 8080 in the container. This would allow you to deploy multiple containers, all using the same port and all addressable via the random port assigned on the host.

Although this example is very simple you should begin to get an idea of the possibilities that Docker and TestContainers give you. Docker hub (https://hub.docker.com/) has a huge amount of pre-built images that could be used to support your testing. TestContainers also has a number of purpose built modules for some of the more popular frameworks that can also be used.


Other Useful Features

It is possible to have services running in Docker talk to services running on the host machine. Details on how to set this up can see here

I have found this useful when running Junit rules for applications such as Wiremock to replicate a real service http://wiremock.org/docs/junit-rule/.

TestContainers has a lot of other useful features that can be used to make our testing quicker and easier.

Here is a overview of some other features


Conclusion

We saw above how Docker and TestContainers have huge potential from a testing point of view. In the above example we just looked at using these technologies to simply run a service as an image in a Docker container and then run tests against the service.

We also talked about how most types of dependency a service may require to run can be deployed using Docker images available on Docker Hub, using the pre-built modules from TestContainers or even building your own custom image.

All this leads to

  • Stability - Tests are much more stable with dependencies always in a known state.
  • Isolation - Tests can be isolated from one another ensuring no cross contamination.
  • Repeatability - Tests are repeatable as dependencies are in the same known state each test run.
  • Speed - The setup and running of tests is much quicker
  • Cost - Overall cost of testing is reduced both for setting up, running and maintaining tests. Also not having to maintain a long lived environment for testing can be a big money saver.
  • Feedback Loop - Quicker feedback loops for Developers so bugs are found and fixed faster. This also contributes to cost reduction.