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.
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.
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-18.104.22.168-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
# 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 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"
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.