Integration Tests in Java with Docker

Integration Tests should be part of a project from early beginning till the very end. There are many undeniable advantages of using continuous testing process. Ceaseless development of tests allow to avoid regression.

 

geralt / Pixabay

 

To avoid mixing business purposes of Integration Testing, let’s concentrate only at technical details of proposed solution.

What will be used during this example:

 

To assure during gradle build that Docker Images will be created from your backend, just use Docker Gradle Plugin. If want to run Integration Tests based on created docker images, then use Docker Compose Gradle Plugin. Below is a small example of simple backend application and then detailed description of integrating docker to the tests layer. Note, that such a tests should be separate step during testing process and may not be a part of standard gradle build process.

 

Backend Application

We will build example application that is a standalone JAR file with running http rest server and one health check endpoint. This application will use listed above technologies for Integration Tests.  To assume that backend application is simple, will be used also:

  • Grizzly
  • Jersey
  • Kotlin
  • Awaitility

and others.

First step is to create a simple backend.

Let’s create a fresh Gradle Project. This project should have folder and gradle.build inside of it. We would need to have a submodule for a backend application. Backend will not be a root project itself, because later there will be need to add more submodules. At this stage we should have a folder for example project and a subfolder with backend application inside of it. This example should not concentrate on architecture of backend, so we will create only few classes backend application with one rest endpoint.

Example project should contain files: 

rootProject.name = 'example-integration-test-docker'

include 'additions'
project(":additions").projectDir = file("./additions")

include "backend-application"
settings.gradle
buildscript {
    ext.kotlin_version = '1.2.30'

    repositories {
        jcenter()
        mavenCentral()
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

plugins {
    id "idea"
    id "eclipse"
}

group 'example-project-backend-with-integration-tests'
version '1.0-SNAPSHOT'

def kotlinProjects = [
        "backend-application"
]

def notJavaProjects = [
        "additions"
]

subprojects {
    apply plugin: 'idea'
    apply plugin: 'eclipse'

    ext.appVersion = this.version

    ext.kotlin_version = '1.1.2-2'
    ext.kotlinApiVersion = '1.1'
    ext.kotlinLoggingVersion = '1.4.3'
    ext.log_version = '2.7'
    ext.jersey_version = '2.25'
    ext.grizzlyVersion = '2.3.29'
    ext.jackson_version = '2.8.6'
    ext.awaitilityVersion = '1.7.0'

    if(!notJavaProjects.contains(name)) {
        configureJava(project)
    }

    if(kotlinProjects.contains(name)) {
        configureKotlin(project)
    }
}

task wrapper(type: Wrapper) {
    gradleVersion = '3.4.1'
}

def applyApplication(proj, applicationName, applicationTitle) {
    proj.ext.applicationMainClassName = applicationName
    proj.ext.applicationImplementationTitle = applicationTitle
    proj.apply from: "$rootDir/additions/gradle-files/application.gradle"
}

def configureJava(proj) {
    proj.apply plugin: 'java'
    proj.version = proj.ext.appVersion
    proj.sourceCompatibility = 1.8
    proj.targetCompatibility = 1.8
    proj.compileJava.options.encoding = 'UTF-8'
    proj.compileTestJava.options.encoding = 'UTF-8'
    proj.repositories {
        mavenCentral()
        mavenLocal()
        jcenter()
        maven {
            url "http://dl.bintray.com/microutils/kotlin-logging"
        }
    }
    proj.test {
        testLogging {
            exceptionFormat = 'full'
            events "started", "passed", "skipped", "failed", "standardError"
        }
    }
}

def configureKotlin(proj) {
    proj.apply plugin: 'kotlin'
    proj.compileKotlin {
        kotlinOptions {
            jvmTarget = "1.8"
            apiVersion = proj.ext.kotlinApiVersion
            languageVersion = proj.ext.kotlinApiVersion
        }
    }

    proj.dependencies {
        compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$proj.ext.kotlin_version"
        compile "org.jetbrains.kotlin:kotlin-reflect:$proj.ext.kotlin_version"
    }
}
build.gradle
task flatJar(type: Jar) {

    outputs.dir("$buildDir/libs")

    manifest {
        attributes(
                'Implementation-Title': applicationImplementationTitle,
                'Implementation-Version': version,
                'Main-Class': mainClassName
        )
    }
    classifier = 'all'
    from {
        configurations.runtime.collect { it.isDirectory() ? it : zipTree(it) }
    } {
        exclude "META-INF/*.SF"
        exclude "META-INF/*.DSA"
        exclude "META-INF/*.RSA"
    }
    with jar
}

dependencies {

    // server deps
    compile "org.glassfish.jersey.containers:jersey-container-servlet:$jersey_version"
    compile "org.glassfish.jersey.media:jersey-media-json-jackson:$jersey_version"
    compile "org.glassfish.jersey.containers:jersey-container-grizzly2-http:$jersey_version"
    compile "org.glassfish.grizzly:grizzly-http-servlet:$grizzlyVersion"
    compile "com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider:$jackson_version"
    compile "com.fasterxml.jackson.module:jackson-module-kotlin:$jackson_version"
    compile "com.fasterxml.jackson.module:jackson-module-parameter-names:$jackson_version"
    compile "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:$jackson_version"
    compile "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jackson_version"

    // logging
    compile "io.github.microutils:kotlin-logging:$kotlinLoggingVersion"
    compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: log_version
    compile group: 'org.apache.logging.log4j', name: 'log4j-api', version: log_version
    compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: log_version
    
}
additions/gradle-files/application.gradle

 

group 'example-project-backend-with-integration-tests'

applyApplication(this, 'com.example.backend.Main', 'backend-application')

dependencies {
    testCompile group: 'junit', name: 'junit', version: '4.+'
}
backend-application/build.gradle

 

@file:JvmName("Main")

package com.example.backend

import mu.KotlinLogging

private val log = KotlinLogging.logger { }

fun main(args: Array<String>) {
    startInNewThread()
}

fun startInNewThread() = Thread {
    start()
}.start()

fun start() {
    try {
        BackendApp().start()
        await()
    } catch (ex: Exception) {
        log.error(ex) { "" }
    }
}

private fun await() {
    while (true) {
        Thread.sleep(60000)
    }
}
Main.kt

 

package com.example.backend

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider
import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider.DEFAULT_ANNOTATIONS
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule
import mu.KotlinLogging
import org.glassfish.grizzly.http.server.HttpHandler
import org.glassfish.grizzly.http.server.HttpServer
import org.glassfish.grizzly.threadpool.ThreadPoolConfig
import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpContainerProvider
import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory
import org.glassfish.jersey.server.ResourceConfig
import org.glassfish.jersey.server.ServerProperties
import javax.ws.rs.core.UriBuilder

class BackendApp {

    private val log = KotlinLogging.logger { }

    private val address = UriBuilder.fromUri("http://0.0.0.0").port(9999).build()
    private val httpServer = prepareWebServer()

    val MAIN_OBJECT_MAPPER = ObjectMapper().apply {
        registerModule(ParameterNamesModule())
        registerModule(Jdk8Module())
        registerModule(JavaTimeModule())
        registerModule(KotlinModule())
    }

    init {
        setupWorkers()
    }

    fun start() {
        try {
            httpServer.start()
        } catch (e: Exception) {
            log.error { e }
        }
    }

    private fun prepareWebServer(): HttpServer {
        val globalRc = getGlobalResourceConfig()
        val applicationRc = getApplicationResourceConfig()
        val httpServer = GrizzlyHttpServerFactory.createHttpServer(address, globalRc, false).apply {
            serverConfiguration.apply {
                addHttpHandler(container(applicationRc), "/")
            }
        }
        return httpServer
    }

    private fun setupWorkers() {
        val threadPoolConfig = ThreadPoolConfig.defaultConfig()
                .setPoolName("backend-worker-pool")
                .setCorePoolSize(10)
                .setMaxPoolSize(20)

        val listener = httpServer.listeners.iterator().next()
        listener.transport.workerThreadPoolConfig = threadPoolConfig
    }

    private fun container(rc: ResourceConfig) = GrizzlyHttpContainerProvider()
            .createContainer(HttpHandler::class.java, rc)

    private fun getBaseResourceConfig(name: String = ""): ResourceConfig = ResourceConfig()
            .setProperties(mapOf(
                    ServerProperties.APPLICATION_NAME to "backend$name"
            ))
            .register(JacksonJaxbJsonProvider(MAIN_OBJECT_MAPPER, DEFAULT_ANNOTATIONS))

    private fun getGlobalResourceConfig() = getBaseResourceConfig("-global")

    private fun getApplicationResourceConfig(): ResourceConfig = getBaseResourceConfig("-application")
            .register(BackendApi())
}
BackendApp.kt

 

package com.example.backend

import com.sun.xml.internal.ws.client.sei.ResponseBuilder
import javax.ws.rs.Consumes
import javax.ws.rs.GET
import javax.ws.rs.Path
import javax.ws.rs.Produces
import javax.ws.rs.container.AsyncResponse
import javax.ws.rs.container.Suspended
import javax.ws.rs.core.MediaType
import javax.ws.rs.core.Response
import javax.ws.rs.core.Response.*

@Path("/")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
class BackendApi {

    @GET
    @Path("/check")
    fun get(@Suspended asyncResponse: AsyncResponse) {
        System.out.println("OK")
        asyncResponse.resume(ok().entity("OK").build())
    }

}
BackendApi.kt

 

You should after import to development environment see something like:

 

After starting the Main you should see logs:

org.glassfish.grizzly.http.server.NetworkListener start
INFO: Started listener bound to [0.0.0.0:9999]
org.glassfish.grizzly.http.server.HttpServer start
INFO: [HttpServer] Started.
Loga after starting example backend

 

After running GET in the browser you should see:

 

As described in example, server of this backend is using port 9999 and endpoint “/check” to provide response with “OK” text.

Details about used solutions for constructing backend application in similar schema can be found in related posts and articles.

 

Docker on a way to test

In different solution like Integration Tests with heavy application in JUnit or Nested Tests in JUnit probably you would start your application in the code and close it at the end of tests. Such a way for testing is also proper, however it is not vitalized in any way. In this example our goal is not only to provide virtualization of testing environment but also perform more tests like loadtesting. What are advantages of dockerized? There are many, they are in details described in other articles. Most important is: virtualization.

 

Compiling of docker images during building

In example there is already used flatJar plugin in gradle, that will provide backend as a standalone runnable JAR file with dependencies. Goal is to create Docker Image with such a JAR running on it and serving rest services on the selected port. To achieve this, there is need to use Gradle Docker Plugin that will compile docker image with selected artifact from backend module.

In main build.gradle let’s add definition of applyDocker method.

def applyDocker(proj, imageName, files, baseImage = 'none', alwaysRebuild = false) {
    proj.ext.dockerImageName = imageName
    proj.ext.dockerPackageFiles = files
    proj.ext.alwaysRebuild = alwaysRebuild
    proj.apply from: "$rootDir/additions/gradle-files/docker.gradle"
    if (baseImage == 'alpine-java') {
        proj.tasks.docker.dependsOn(":additions:alpine-java:docker")
    }
}
build.gradle

It will use /additions/gradle-files/docker.gradle and base docker image alpine-java.

buildscript {
    repositories {
        maven {
            url "https://plugins.gradle.org/m2/"
        }
    }
    dependencies {
        classpath "gradle.plugin.com.palantir.gradle.docker:gradle-docker:0.12.0"
    }
}

apply plugin: com.palantir.gradle.docker.PalantirDockerPlugin

task bootRepackage {
    outputs.dir("$buildDir/libs")
}

task dockerCleanImages() {
    group 'docker'
    doLast {
        exec {
            ignoreExitValue = true
            standardOutput = new ByteArrayOutputStream()
            errorOutput = standardOutput
            println "Removing existing images with name: $dockerImageName"
            commandLine 'docker', 'rmi', "$dockerImageName"
        }
    }
}


docker {
    name dockerImageName
    tags 'latest'
    files dockerPackageFiles, 'Dockerfile'
    dependsOn tasks.bootRepackage, tasks.dockerCleanImages
    pull false
}

task(fastDocker) {
    if (alwaysRebuild) {
        dependsOn 'docker'
    }
}
additions/gradle-files/docker.gradle

 

For details about this solution see detailed description at plugin homepage:

https://github.com/palantir/gradle-docker

 

Now our gradle and plugin is almost ready. Still there is need to add docker definitions to build images. As our backend will use some existing Docker Image and will just extend it, then we need to provide that definition.

applyDocker(this, 'example/alpine-java', ['Dockerfile', 'start.sh'])
additions/alpine-java/build.gradle

There is also need to add new module in settings.gradle

include 'additions:alpine-java'

 

Provide target Docker Images definitions:

# Base Alpine Linux based image with OpenJDK JRE only
FROM openjdk:8-jre-alpine
EXPOSE 9009

ADD start.sh /start.sh

CMD ["sh", "start.sh"]
additions/alpine-java/Dockerfile

 

In this definition is used file start.sh, so we need to provide it too.

#!/usr/bin/env sh
set -x

pid=0

# SIGTERM-handler
term_handler() {
  if [ $pid -ne 0 ]; then
    kill -SIGTERM "$pid"
    wait "$pid"
  fi
  exit 143; # 128 + 15 -- SIGTERM
}

# setup handlers
# on callback, kill the last background process, which is `tail -f /dev/null` and execute the specified handler
trap 'kill ${!}; term_handler' SIGTERM

# run application
/usr/bin/java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=9009 -Djava.security.egd=file:/dev/./urandom $JAVA_ARGS -jar $TARGET_JAR $TARGET_ARGS &
pid="$!"

# wait forever
while true
do
  tail -f /dev/null & wait ${!}
done
additions/alpine-java/start.sh

 

Finally add Dockerfile as an definition to our backend project:

FROM example/alpine-java
EXPOSE 9999
EXPOSE 9009

ADD backend-application-1.0-SNAPSHOT-all.jar backend.jar

ENV FILEBEAT_TAG_NAME=backend
ENV DASHBOARD_APP_NAME=backend
ENV TARGET_JAR=backend.jar
backend-application/Dockerfile

 

Our project is now Dockerized. Let’s build it and have Docker Images after build in local docker repository. Remember that in your system there has to be installed “Docker”.

Now able to build a project with gradle and use Docker Image from it.

gradle clean build docker -x test

 

 

Run the Integration Tests

To properly run integration tests, there is need to use Gradle Docker Compose Plugin.

Add a new module project called in example integration-tests. The build.gradle may have content based on:

buildscript {
    repositories {
        mavenCentral()
        jcenter()
    }
    dependencies {
        // Probably this can be removed
        classpath "com.avast.gradle:docker-compose-gradle-plugin:0.3.21"
    }
}

configurations {
    integrationTestCompile.extendsFrom testCompile
    integrationTestRuntime.extendsFrom testRuntime
}

sourceSets {
    integrationTest {
        compileClasspath += main.output + test.output
        runtimeClasspath += main.output + test.output
    }
}

task integrationTest(type: Test) {
    loadProperties(it)

    testClassesDir = sourceSets.integrationTest.output.classesDir
    classpath = sourceSets.integrationTest.runtimeClasspath
    testLogging {
        exceptionFormat = 'full'
        events "started", "passed", "skipped", "failed", "standardError"
    }
}

dependencies {

}
integration-tests/build.gradle

 

After executing on your local or CI:

gradle clean build docker integrationTest

 

Gradle will take care about compiling JAR, Docker and run classes in the “integrationTests” packages in your project.

 

Load Tests during normal development flow

Properly configured above example allow to use Engulf and it’s API to check endpoints against high load.

applyDocker(this, 'example/engulf', ['Dockerfile', 'engulf.jar'], "alpine-java")
additions/engulf/build.gradle

 

FROM example/alpine-java
ENV TARGET_JAR=engulf.jar
ENV TARGET_ARGS='--mode combined'
EXPOSE 4000 4025
ADD engulf.jar engulf.jar
additions/engulf/Dockerfile

 

Remember that you can always use Engulf in many instances, by providing proper docker-compose.xml. See documentation at gradle plugin webpage of how to use docker-compose files properly.

Additional descriptions will be available in separate posts. This technical description is not a complete “How To”, it’s purpose is only to show possibilities of using docker with integration testing and continuous testing. Probably you may also be interested in using such a solution to deploy and post check on environments. Same scenarios properly used may be ideal for post-deploy checking of environment integrity.

 

 

Advertisements
Integration Tests in Java with Docker

Are you using Docker?

Thank you for the vote!

Leave a Reply

Be the First to Comment!

Leave a Reply

  Subscribe  
Notify of