Testing APIs with Docker and Docker Compose

I recently gave a lunch and learn at work entitled "The Road to Continuous Delivery is paved with testing and containers". During the talk I shared a bit about the testing infrastructure that has proven to be quite effective for me in running unit, integration, and functional tests.

This basic set of Dockerfiles can help you deploy and test API servers built in any language but the examples will contain software packaged with Gradle and deployed as a jar. When developing these files it was important to me that the same Dockerfiles could be used to run, test, and deploy locally and within a CI/CD environment.

Project Structure

Makefile - This file orchestrates the docker commands

src/
  main/
  test/
    integration/com/example - Holds all the integration tests
    unit/com/example        - Holds all the unit tests

postman/
  api.json - This is the postman collection that also contains tests
  docker-env.json - This is the environment for the docker tests

This basic structure contains a Makefile at the project root. It has commands that get used locally and by the CI server to initiate the tests. I also like to split my unit and integration tests using a package prefix so its easy to tell Gradle which tests to run.

In addition to unit and integration tests I like to utilize Postman to run functional tests against a running API. This also has the added benefit that you end up maintaining a Postman collection that is useful for the entire team to utilize during development.

Next we need a number of Dockerfiles that orchestrate and run our test suites.

Dockerfiles

I like to try and setup the Dockerfiles in such a way where they can be composed together with minimal fuss. I like it when the files can be used for both deployment and testing without major changes. I have ended up with a structure that looks like the following.

Dockerfile - a multi-stage file that builds and runs the jar
Dockerfile.test - contains the source and the tests
Dockerfile.test.acceptance - contains the postman tests
.dockerignore - ensures we only copy the necessary files into the containers

I want to highlight what a few of these files look like with some boilerplate removed.

#Dockerfile.test
#Used for running unit and integration tests
FROM adoptopenjdk/openjdk12:jdk-12.0.1.12-slim

ENV DOCKERIZE_VERSION v0.6.1

RUN groupadd -g 999 appuser && \
    useradd --create-home -r -u 999 -g appuser appuser

USER appuser

# add dockerize to help with waiting for the database within docker-compose
RUN curl -L https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz --output /tmp/dockerize.tar.gz \
    && tar -C /home/appuser -xzvf /tmp/dockerize.tar.gz \
    && rm /tmp/dockerize.tar.gz

WORKDIR /home/appuser
COPY --chown=appuser:appuser . /home/appuser

Our functional test container just has the Postman files and newman for running the tests.

# Dockerfile.test.acceptance
FROM node:10.8.0-slim

ENV DOCKERIZE_VERSION v0.6.1

USER appuser
RUN groupadd -g 999 appuser && \
    useradd --create-home -r -u 999 -g appuser appuser

RUN npm install newman --global

USER appuser
COPY --chown=appuser:appuser /postman /home/appuser
WORKDIR /home/appuser
RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
    && tar -C /home/appuser -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
    && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz

Some eagle eyed readers might be wondering how we spin up dependencies such as databases when running integration tests? How do we start up the server for functional tests? This is where docker-compose comes in to play.

Docker Compose

Docker Compose allows developers to orchestrate a number of containers together. We can have it spin up dependencies alongside our test containers. Lets take a look at an example compose file for running integrations tests.

version: '3.3'
services:
  test-suite:
    user: appuser
    command: ["sh", "-c",
              "./dockerize -wait tcp://mysql:3306 -timeout 1m &&
              ./gradlew test --tests \unit* --no-daemon --console plain --stacktrace"]
    build:
      context: .
      dockerfile: Dockerfile.test
  redis:
    image: "redis:4.0.2"
    ports:
    - "6379:6379"
  mysql:
    image: "mysql:5.7.20"
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: myapp
      MYSQL_USER: appuser
      MYSQL_PASSWORD: password_goes_here
    command: ['--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci']
    ports:
    - "3306:3306"

Here we spin up a database, redis, and then run our tests. We utilize a command called dockerize to wait for the mysql database to be ready before running our tests. In order to run functional tests we have to spin up our entire server which will look something like the following.

version: '3.3'
services:
  functional-test:
    user: appuser
    command: ["sh", "-c",
              "./dockerize -wait tcp://app-server:8080 -timeout 5m &&
              newman run postman/api.json -e postman/docker-env.json --reporters cli"]
    build:
      context: .
      dockerfile: Dockerfile.test.acceptance
  app-server:
    # - build the artifacts
    # - wait for mysql
    # - run the app server
    user: appuser
    command: ["sh", "-c",
              "./gradlew shadowJar &&
               ./dockerize -wait tcp://mysql:3306 -timeout 1m &&
               java -jar api/build/libs/app-server.jar"]
    build:
      context: .
      dockerfile: Dockerfile.test
  redis:
    image: "redis:4.0.2"
    ports:
    - "6379:6379"
  mysql:
    image: "mysql:5.7.20"
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: myapp
      MYSQL_USER: appuser
      MYSQL_PASSWORD: password_goes_here
    command: ['--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci']
    ports:
    - "3306:3306"

Here we build our jar, wait for mysql, then wait for the app server to start. Once up and running newman will run the tests from our Postman file.

Makefile

One thing I ran into using this structure is that I wanted docker-compose to spin down once the tests were finished. Here is an example of the commands in the Makefile that make this possible.

test:
	docker-compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from test-suite --renew-anon-volumes --remove-orphan

functional-test:
	docker-compose -f docker-compose.functional.yml up --build --abort-on-container-exit --exit-code-from functional-test --renew-anon-volumes --remove-orphan

With this file in place I could spin up tests with their dependencies and then spin down when they completed or failed. The same commands allowed to me run the tests suites locally and on CI/CD servers as long as they support docker and docker-compose.