Testing APIs with Docker and Docker Compose
A look into how I like to setup test infrastructure using docker and docker-compose for APIs
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 beused 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 bythe 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.