DevOps

Docker for Java Developers: Test on Docker

This article is part of our Academy Course titled Docker Tutorial for Java Developers.

In this course, we provide a series of tutorials so that you can develop your own Docker based applications. We cover a wide range of topics, from Docker over command line, to development, testing, deployment and continuous integration. With our straightforward tutorials, you will be able to get your own projects up and running in minimum time. Check it out here!

1. Introduction

If we think about the areas of software engineering where the impact of the Docker and container-based virtualization is most noticeable, testing and test automation is certainly one of those. As the software systems become more and more complex, so do the software stacks they are built upon, with many moving parts involved.

In this section of the tutorial we are going to learn a couple of the frameworks which enormously simplify the integration, component and end-to-end testing of JVM-based applications by leveraging Docker and the tooling around it.

2. Before getting started

In the second part of this tutorial we have briefly mentioned that Java applications deployed as Docker containers should use at least Java 8 update 131 or later but we have not had chance to elaborate on importance of this fact just yet.

To understand the issue, let us focus on how JVM deal with two most important resources for Java applications: memory (heap) and CPU. For the purpose of the illustration, we are going to have Docker installed on a dedicated virtual machine which has 2 CPU cores and 4GB of memory allocated. Keeping this configuration in mind, let us take a look on that from JVM perspective:

1
$ docker run --rm openjdk:8-jdk-alpine java -XshowSettings:vm -XX:+UseParallelGC -XX:+PrintFlagsFinal –version

There are going to be a lot of information printed out in the console, but there most interesting pieces for us are ParallelGCThreads and VM settings.

01
02
03
04
05
06
07
08
09
10
11
12
...
uintx ParallelGCThreads = 2
...
VM settings:
    Max. Heap Size (Estimated): 992.00M
    Ergonomics Machine Class: server
    Using VM: OpenJDK 64-Bit Server VM
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (IcedTea 3.4.0) (Alpine 8.131.11-r2)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)

On the machines with less than (or equal to) 8 cores, the ParallelGCThreads is equal to number of cores, while the default maximum limit for heap is roughly 25% of available physical memory. Cool, the numbers which JVM reports make sense so far.

Let us push it a bit further and use Docker resource management capabilities to restrict container CPU and memory usage to 1 core and 256Mb respectively.

1
$ docker run --rm --cpuset-cpus=0 --memory=256m openjdk:8-jdk-alpine java -XshowSettings:vm -XX:+UseParallelGC -XX:+PrintFlagsFinal -version

The picture is a bit different this time:

1
2
3
4
5
6
7
8
...
uintx ParallelGCThreads = 1
...
VM settings:
    Max. Heap Size (Estimated): 992.00M
    Ergonomics Machine Class: client
    Using VM: OpenJDK 64-Bit Server VM

JVM was able to adjust the ParallelGCThreads accordingly but memory constraints seem to be ignored completely. That is right, to make JVM aware of containerized environment we need to unlock experimental JVM options: -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap.

1
$ docker run --rm --cpuset-cpus=0 --memory=256m openjdk:8-jdk-alpine java -XshowSettings:vm -XX:+UseParallelGC -XX:+PrintFlagsFinal  -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap –version

The results are looking much better now:

1
2
3
4
5
6
7
8
...
uintx ParallelGCThreads = 1
...
VM settings:
    Max. Heap Size (Estimated): 112.00M
    Ergonomics Machine Class: client
    Using VM: OpenJDK 64-Bit Server VM

The curious reader may be wondering why the maximum heap size is quite over the expected 25% of the available physical memory allocated to the container. The answer is that Docker also allocates memory for swap (which, if not specified, is equal to desired memory limit) so the true value JVM sees is 256Mb + 256Mb = 512Mb.

But even here we could make improvements. Usually, when we are running Java applications inside Docker containers, there is only place for a single JVM process in there, so why don’t we just give it all the available memory container has? That is actually very simple to do by adding -XX:MaxRAMFraction=1 command line option.

1
$ docker run --rm --cpuset-cpus=0 --memory=256m openjdk:8-jdk-alpine java -XshowSettings:vm -XX:+UseParallelGC -XX:+PrintFlagsFinal  -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:MaxRAMFraction=1  -version

And let us check what JVM reports:

01
02
03
04
05
06
07
08
09
10
11
12
...
uintx ParallelGCThreads = 1
...
VM settings:
    Max. Heap Size (Estimated): 228.00M
    Ergonomics Machine Class: client
    Using VM: OpenJDK 64-Bit Server VM
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (IcedTea 3.4.0) (Alpine 8.131.11-r2)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)

It looks just perfect. Please be aware of this experimental JVM options and use them appropriately, so you could adjust the resources your Java applications should be using.

3. Storing data in RAM

It is rarely the case that the application has no dependency on some kind of data store (sometimes even more than one). And, unsurprisingly, most of such stores persist the data on some kind of durable storage. This is great indeed, but quite often the presence of such data stores makes the testing a challenge, specifically when summoning the isolation of the test cases on the data level.

You may have already guessed, Docker could enormously reduce the efforts required to create reproducible test scenarios. But still, the data stores serve as the major contributor to overall test execution time.

As we are going to see in a moment, Docker has something in the sleeve to help us out: tmpfs. It effectively gives the container a way to manage the data without writing it anywhere permanently, keeping it in the host machine’s memory (or swap, if memory is running low).

There are multiple options available which allow to attach tmpfs-backed volumes to your container, but the recommended one these days is to use --mount command line argument (or tmpfs section of the specification in case of docker-compose).

1
2
3
4
5
6
7
docker run --rm -d \
  --name mysql \
  --mount type=tmpfs,destination=/var/lib/mysql \
  -e MYSQL_ROOT_PASSWORD='p$ssw0rd' \
  -e MYSQL_DATABASE=my_app_db \
  -e MYSQL_ROOT_HOST=% \
  mysql:8.0.2

The speed gains for test executions could be tremendous, but you should not use this feature in production (and very likely, in any other environments), ever, because when the container stops, the tmpfs mount disappears. Even if the container is committed, the tmpfs mount is not saved.

4. The Scenario

Armed with these tricks and tips, we could discuss the test scenario we are going to implement. Essentially, it would be great to run end-to-end test for one of the applications we have developed in the previous parts of the tutorial. The eventual goal of the test scenario would be to verify that REST(ful) APIs for tasks management is available and returning the expected results.

From the deployment stack perspective, it means we have to have the instance of MySQL and the instance of the application (let us pick the Spring Boot based one we have developed before but it does not really matter much). This application exposes the REST(ful) APIs and should be able to communicate with MySQL instance. Surely, we would like those instances to live as Docker containers.

The test scenario is going to call the task managements REST(ful) APIs to get the list of all available tasks, using terrific REST-assured framework for test scaffolding. Also, all our test scenarios are going to be based on JUnit framework.

5. Testcontainers

The first framework we are going to look at is TestContainers, a Java library that supports JUnit tests and provides lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

Let us see how we could project the test scenario we have outlined before into the running JUnit test using TestContainers to our benefits. At the end, it looks pretty straightforward.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package com.javacodegeeks;
import static io.restassured.RestAssured.when;
import static org.hamcrest.CoreMatchers.equalTo;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.wait.HostPortWaitStrategy;
import io.restassured.RestAssured;
public class SpringBootAppIntegrationTest {
    @ClassRule
    public static Network network = Network.newNetwork();
    
    @ClassRule
    public static GenericContainer<?> mysql = new
        GenericContainer<>("mysql:8.0.2")
            .withEnv("MYSQL_ROOT_PASSWORD", "p$ssw0rd")
            .withEnv("MYSQL_DATABASE", "my_app_db")
            .withEnv("MYSQL_ROOT_HOST", "%")
            .withExposedPorts(3306)
            .withNetwork(network)
            .withNetworkAliases("mysql")
            .waitingFor(new HostPortWaitStrategy());
    
    @ClassRule
    public static GenericContainer<?> javaApp =
        new GenericContainer<>("jcg/spring-boot-webapp:latest")
            .withEnv("DB_HOST", "mysql")
            .withExposedPorts(19900)
            .withStartupAttempts(3)
            .withNetwork(network)
            .waitingFor(new HostPortWaitStrategy());
    
    @BeforeClass
    public static void setUp() {
        RestAssured.baseURI = "http://" + javaApp.getContainerIpAddress();
        RestAssured.port = javaApp.getMappedPort(19900);
    }
    
    @Test
    public void getAllTasks() {
        when()
            .get("/tasks")
            .then()
            .statusCode(200)
            .body(equalTo("[]"));
    }
}

TestContainers comes with a predefined set of specialized containers (for MySQL, PostgreSQL, Oracle XE and others) but you always have a choice to use your own (like we have done in the snippet above). The Docker Compose specifications are also supported out of the box.

In case you are using Windows as a development platform, please be warned that TestContainers is not regularly tested on Windows. If you want to try it out nonetheless, the recommendation at the moment is to use alpha release.


 

6. Arquillian

Next one in the list is Arquillian, an innovative and highly extensible testing platform for the JVM that enables developers to easily create automated integration, functional and acceptance tests for Java middleware. Arquillian is indeed more a testing platform than framework, where Docker support is just one of many options available.

Let us take a look on how the same test case we have seen in the previous section may look like when translated to Arquillian Cube, an Arquillian extension that can be used to manager Docker containers from Arquillian.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class SpringBootAppIntegrationTest {
    @ClassRule
    public static NetworkDslRule network = new NetworkDslRule("my-app-network");
    
    @ClassRule
    public static ContainerDslRule mysql =
        new ContainerDslRule("mysql:8.0.2", "mysql")
            .withEnvironment("MYSQL_ROOT_PASSWORD", "p$ssw0rd")
            .withEnvironment("MYSQL_DATABASE", "my_app_db")
            .withEnvironment("MYSQL_ROOT_HOST", "%")
            .withExposedPorts(3306)
            .withNetworkMode("my-app-network")
            .withAwaitStrategy(AwaitBuilder.logAwait("/usr/sbin/mysqld: ready for connections"));
    
    @ClassRule
    public static ContainerDslRule javaApp =
        new ContainerDslRule("jcg/spring-boot-webapp:latest", "spring-boot-webapp*")
            .withEnvironment("DB_HOST", "mysql")
            .withPortBinding(19900)
            .withNetworkMode("my-app-network")
            .withLink("mysql", "mysql")
            .withAwaitStrategy(AwaitBuilder.logAwait("Started AppStarter"));
    @BeforeClass
    public static void setUp() {
        RestAssured.baseURI = "http://" + javaApp.getIpAddress();
        RestAssured.port = javaApp.getBindPort(19900);
    }
    
    @Test
    public void getAllTasks() {
        when()
            .get("/tasks")
            .then()
            .statusCode(200)
            .body(equalTo("[]"));
    }
}

There are a couple of differences from TestContainers but, by and large, the test case should be looking familiar already. The key feature which seems to be unavailable in the Arquillian Cube at the moment (at least, when using Container Objects DSL) is support for network aliasing so we had to fall back to legacy container linking in order to connect our Spring Boot application with MySQL backend.

It is worth to mention that Arquillian Cube has quite a few alternative ways to manage Docker containers from the test scenarios, including Docker Compose specifications support. On a side note, the pace of development around Arquillian is just incredible, it has all the chances to be one stop shop testing platform for modern Java applications.

7. Overcast

Overcast is a Java library from XebiaLabs intended to be used to test against hosts in the cloud. Overcast was one of the first test frameworks to offer the extensive Docker support while aiming for more ambitious goals to abstract away the host management in the test scenarios. It has a lot of very interesting features but sadly seems to be not actively maintained anymore, with last release dated 2015. Nonetheless, it is worth looking as it has some unique values.

Overcast uses configuration driven approach to describe the hosts under the test, stemming from the fact that it is not oriented only on containers or/and Docker.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
mysql {
    name="mysql"
    dockerImage="mysql:8.0.2"
    remove=true
    removeVolume=true
    env=["MYSQL_ROOT_PASSWORD=p$ssw0rd", "MYSQL_DATABASE=my_app_db", "MYSQL_ROOT_HOST=%"]
}
spring-boot-webapp {
    dockerImage="jcg/spring-boot-webapp:latest"
    exposeAllPorts=true
    remove=true
    removeVolume=true
    env=["DB_HOST=mysql"]
    links=["mysql:mysql"]
}

With such configuration in place, stored in the overcast.conf, we could refer to containers by their names from the test scenarios.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class SpringBootAppIntegrationTest {
    private static CloudHost mysql = CloudHostFactory.getCloudHost("mysql");
    private static CloudHost javaApp = CloudHostFactory.getCloudHost("spring-boot-webapp");
    
    @BeforeClass
    public static void setUp() {
        mysql.setup();
        javaApp.setup();
        
        RestAssured.baseURI = "http://" + javaApp.getHostName();
        RestAssured.port = javaApp.getPort(19900);
        
        await()
            .atMost(20, TimeUnit.SECONDS)
            .ignoreExceptions()
            .pollInterval(1, TimeUnit.SECONDS)
            .untilAsserted(() ->
                when()
                    .get("/application/health")
                    .then()
                    .statusCode(200));
    }
    
    @AfterClass
    public static void tearDown() {
        javaApp.teardown();
        mysql.teardown();
    }
    
    @Test
    public void getAllTasks() {
        when()
            .get("/tasks")
            .then()
            .statusCode(200)
            .body(equalTo("[]"));
    }
}

It looks pretty straightforward except the health check part. Overcast does not provide the means to verify that containers are up and ready to serve the requests, so we on-boarded another great library, Awaitility, to compensate that.

There is a lot of potential and benefits which Overcast could provide, hopefully we would see it back to active development one day. As for this example, in order to use the latest Overcast features we had to build it from the sources.

8. Docker Compose Rule

Last but not least, let us discuss the Docker Compose Rule, a library from Palantir Technologies for executing JUnit tests that interact with Docker Compose-managed containers. Under the hood it uses the docker-compose command line tool and, as of now, the Windows platform is not supported.

As you may guess, we have to start off from the docker-compose.yml specification so it could be read by Docker Compose Rule later on. Here is an example of one.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
version: '2.1'
services:
  mysql:
    image: mysql:8.0.2
    environment:
      - MYSQL_ROOT_PASSWORD=p$ssw0rd
      - MYSQL_DATABASE=my_app_db
      - MYSQL_ROOT_HOST=%
    expose:
      - 3306
    healthcheck:
      test: ["CMD-SHELL", "ss -ltn src :3306 | grep 3306"]
      interval: 10s
      timeout: 5s
      retries: 3
    tmpfs:
      - /var/lib/mysql
    networks:
      - my-app-network
  java-app:
    image: jcg/spring-boot-webapp:latest
    environment:
      - DB_HOST=mysql
    ports:
      - 19900
    depends_on:
      mysql:
        condition: service_healthy
    networks:
      - my-app-network
networks:
    my-app-network:
       driver: bridge

With that, we just need to feed this specification to Docker Compose Rule, like in the code snippet below. Please also notice that we have augmented the scenario with a health check to make sure that our Spring Boot application has fully started.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class SpringBootAppIntegrationTest {
    @ClassRule
    public static DockerComposeRule docker = DockerComposeRule.builder()
        .file("src/test/resources/docker-compose.yml")
        .waitingForService("java-app",
            HealthChecks.toRespond2xxOverHttp(19900, (port) ->
                port.inFormat("http://$HOST:$EXTERNAL_PORT/application/health")),
            Duration.standardSeconds(30)
        )
        .shutdownStrategy(ShutdownStrategy.GRACEFUL)
        .build();
    
    @BeforeClass
    public static void setUp() {
        final DockerPort port = docker.containers().container("java-app").port(19900);
        RestAssured.baseURI = port.inFormat("http://$HOST");
        RestAssured.port = port.getExternalPort();
    }
    
    @Test
    public void getAllTasks() {
        when()
            .get("/tasks")
            .then()
            .statusCode(200)
            .body(equalTo("[]"));
    }
}

This is probably the simplest way to integrate Docker  into your JUnit test scenarios. And, coincidentally, the same Docker Compose specifications may be shared and reused for other purposes.

9. Conclusions

It this section of the tutorial we have talked about how Docker disrupted and literally revolutionized some of the most complicated testing strategies we use in the real projects. As we have seen by familiarizing with different Java testing frameworks, the support of Docker is outstanding but choosing the right tool for your project could be the challenge, depending on the operating systems and Docker installations you rely upon.

However, with a great power comes great responsibility. Docker is not a silver bullet, please always keep in mind the test pyramid close by and try to find the balance which works for you the best.

10. What’s next

In the next section of the tutorial we are going to talk about quite a few typical deployment orchestrations where Docker  containers (and containers in general) are first class citizens.

The complete project sources are available for download.

Andrey Redko

Andriy is a well-grounded software developer with more then 12 years of practical experience using Java/EE, C#/.NET, C++, Groovy, Ruby, functional programming (Scala), databases (MySQL, PostgreSQL, Oracle) and NoSQL solutions (MongoDB, Redis).
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Inline Feedbacks
View all comments
Back to top button