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!
Table Of Contents
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.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.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.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) -> Duration.standardSeconds( 30 ) ) .shutdownStrategy(ShutdownStrategy.GRACEFUL) .build(); @BeforeClass public static void setUp() { final DockerPort port = docker.containers().container( "java-app" ).port( 19900 ); 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.