Test Driven Development for Docker Images
Tags: docker, tdd
Published: 2020-06-08 14:13:43

Containers were one of the driving forces behind the modern DevOps culture. Where there used to be a strict barrier between developers and operations, is now a common, agreed upon standard. Developers are able to configure their runtime environment to their needs, focusing on what happens *inside* a container, IT-Ops takes care of things *outside* a container - how to run, scale and monitor them.
While the concept of process isolation has been around for much longer, the one thing which brought container technology to the masses was [Docker](https://www.docker.com/) in 2013. Gone should be the days when developers handed over *some* kind of deliverable and IT-Ops had to somehow take care of it. No more “but it works on my machine” - if it works on your machine, we will ship your machine!

> With great power comes great responsibility

Given the ability to customise the runtime environment, responsibility for providing and maintaining a proper environment shifts from IT-Ops towards developers. We’re not only responsible for our application, but also the environment to run it in. Fortunate enough, we can apply well-known methods to make this effort less tedious: automated tests and test-driven development (TDD).

## Meet Goss and DGoss

Goss is a neat little utility which helps you validate your server configuration. 
As stated on its [GitHub](https://github.com/aelsabbahy/goss) page it’s a YAML based alternative to Ruby-based [serverspec](https://serverspec.org/). (And since it’s written in Go, I assume Goss is short for Go serverspec)

So, how will Goss help us test our Docker images? With Goss itself it's possible to verify a multitude of properties on Linux systems, ranging from filesystem layouts, permissions and content over network ports, installed packages for various distributions to running services. This would allow us to e.g. verify the content of our Apache config, that our `apache2` service is actually enabled and running and last but not least, whether it’s listening on port 80.

Now when building a Docker image, we can apply these things too. DGoss is a wrapper script around Goss which allows us to target Docker containers, so let’s explore how we can write Docker tests. 

### Installation

As mentioned earlier, Goss and DGoss currently only support Linux systems. Since we want to target Linux containers, this is not a problem for us. We can get the latest release on [GitHub](https://github.com/aelsabbahy/goss/releases):

```bash
curl -L https://github.com/aelsabbahy/goss/releases/latest/download/goss-linux-amd64 -o /usr/local/bin/goss
chmod +rx /usr/local/bin/goss

curl -L https://github.com/aelsabbahy/goss/releases/latest/download/dgoss -o /usr/local/bin/dgoss
chmod +rx /usr/local/bin/dgoss
```

In case you should decide to download Goss to a location outside your `PATH`,  you’ll have to provide an additional environment variable `GOSS_PATH`, which points to the executable.

### First Glance

To get a first impression, we will create a short test for the [httpd](https://hub.docker.com/_/httpd) Docker image. If not specified otherwise via `GOSS_FILE` environment variable, the default spec file is called `goss.yaml`:

```yaml
port:
  tcp:80:
    listening: true
    ip:
    - 0.0.0.0
http:
  http://localhost:
    status: 200
    body: ["It works!"]
```
 
This exemplary test verifies that we have a server running on TCP port 80, listening on all IP addresses. Additionally, it makes sure it serves the default page which only displays *”It works!”*.

To run our tests we execute `dgoss run httpd` instead of `docker run httpd`. Dgoss will start a container based on our specified image and copy all required files for us. Assuming the image under test works like expected we should see them succeed:

```bash
INFO: Starting docker container
INFO: Container ID: 76715e17
INFO: Sleeping for 0.2
INFO: Container health
INFO: Running Tests
Port: tcp:80: listening: matches expectation: [true]
Port: tcp:80: ip: matches expectation: [["0.0.0.0"]]
HTTP: http://localhost: status: matches expectation: [200]
HTTP: http://localhost: Body: matches expectation: [It works!]


Total Duration: 0.010s
Count: 4, Failed: 0, Skipped: 0
INFO: Deleting container
```

## Test Driven Docker Development (TDDD)

Now the above example is not *that* interesting, let’s instead focus on building an image for e.g. a node backend. We will be using a Debian base image, `debian:10.4-slim` to be precise, and change the default shell to `bash` for our build.

```bash
FROM debian:10.4-slim

SHELL [ "/bin/bash", "-c" ]
```

Next to our `Dockerfile` we create a `goss.yaml` file as well as a little helper script to build and test our image, `build_and_test.sh`.

```bash
#!/usr/bin/env bash

set -e

IMAGE=s1hofmann/node-runtime
TAG=12.18.0

docker build -t $IMAGE:$TAG .
dgoss run -it $IMAGE:$TAG
```

The repository linked at the end of the post also contains a nodemon setup to automatically build and test the image on file changes.

### Non-root images

The first thing we want to add to our image is a dedicated non-root user and group to run our app, so let’s add both a [group](https://github.com/aelsabbahy/goss/blob/master/docs/manual.md#group) and [user](https://github.com/aelsabbahy/goss/blob/master/docs/manual.md#user) test to our Goss file.

```yaml
group:
  node:
    exists: true
    gid: 1000
    skip: false

user:
  node:
    exists: true
    uid: 1000
    gid: 1000
    groups:
    - node
    home: /home/node
    shell: /bin/bash
    skip: false
```

Since we did not yet apply any changes to our base image, our tests will fail:

```yaml
+ IMAGE=s1hofmann/node-runtime
+ TAG=12.18.0
+ docker build -t s1hofmann/node-runtime:12.18.0 .
Sending build context to Docker daemon  4.096kB
Step 1/2 : FROM debian:10.4-slim
 ---> 108d75da320f
Step 2/2 : SHELL [ "/bin/bash", "-c" ]
 ---> Using cache
 ---> 39a73e053958
Successfully built 39a73e053958
Successfully tagged s1hofmann/node-runtime:12.18.0
+ dgoss run -it s1hofmann/node-runtime:12.18.0
INFO: Starting docker container
INFO: Container ID: ae8fbadc
INFO: Sleeping for 0.2
INFO: Container health
INFO: Running Tests
Group: node: exists:
Expected
    <bool>: false
to equal
    <bool>: true
Group: node: gid: skipped
User: node: exists:
Expected
    <bool>: false
to equal
    <bool>: true
User: node: uid: skipped
User: node: gid: skipped
User: node: home: skipped
User: node: groups: skipped
User: node: shell: skipped


Failures/Skipped:

Group: node: exists:
Expected
    <bool>: false
to equal
    <bool>: true
Group: node: gid: skipped

User: node: exists:
Expected
    <bool>: false
to equal
    <bool>: true
User: node: uid: skipped
User: node: gid: skipped
User: node: home: skipped
User: node: groups: skipped
User: node: shell: skipped

Total Duration: 0.003s
Count: 8, Failed: 2, Skipped: 6
INFO: Deleting container
```

Once we extended our image, our tests should pass:

```bash
FROM debian:10.4-slim

SHELL [ "/bin/bash", "-c" ]

RUN groupadd --gid 1000 node && useradd --uid 1000 --gid node --shell /bin/bash --create-home node
```

```bash
+ IMAGE=s1hofmann/node-runtime
+ TAG=12.18.0
+ docker build -t s1hofmann/node-runtime:12.18.0 .
Sending build context to Docker daemon  4.096kB
Step 1/3 : FROM debian:10.4-slim
 ---> 108d75da320f
Step 2/3 : SHELL [ "/bin/bash", "-c" ]
 ---> Using cache
 ---> 39a73e053958
Step 3/3 : RUN groupadd --gid 1000 node && useradd --uid 1000 --gid node --shell /bin/bash --create-home node
 ---> Using cache
 ---> 8700babb2f3b
Successfully built 8700babb2f3b
Successfully tagged s1hofmann/node-runtime:12.18.0
+ dgoss run -it s1hofmann/node-runtime:12.18.0
INFO: Starting docker container
INFO: Container ID: 4f21c178
INFO: Sleeping for 0.2
INFO: Container health
INFO: Running Tests
Group: node: exists: matches expectation: [true]
Group: node: gid: matches expectation: [1000]
User: node: exists: matches expectation: [true]
User: node: uid: matches expectation: [1000]
User: node: gid: matches expectation: [1000]
User: node: home: matches expectation: ["/home/node"]
User: node: groups: matches expectation: [["node"]]
User: node: shell: matches expectation: ["/bin/bash"]


Total Duration: 0.003s
Count: 8, Failed: 0, Skipped: 0
INFO: Deleting container
```

With our first tests passing we still want to make sure our image actually switches to the created user. A [command](https://github.com/aelsabbahy/goss/blob/master/docs/manual.md#command) test can be used to verify this:

```yaml
command:
  container_user:
    exec: "id -un" 
    exit-status: 0
    stdout:
    - node
    skip: false
  container_group:
    exec: "id -gn" 
    exit-status: 0
    stdout:
    - node
    skip: false
  container_pwd:
    exec: "pwd" 
    exit-status: 0
    stdout:
    - /home/node
    skip: false
```

As expected, our new tests will initially fail, but once we extend our Dockerfile, they should pass again:

```yaml
FROM debian:10.4-slim

RUN groupadd --gid 1000 node && useradd --uid 1000 --gid node --shell /bin/bash --create-home node

USER node:node
WORKDIR /home/node
```

```bash
Count: 14, Failed: 0, Skipped: 0
```

### Installed Packages

Our upcoming node setup requires either of *wget* or *curl*. It is certainly not required to keep them in our image, but since curl has been really useful more than once, I like to have it pre-installed. Let’s add a [package](https://github.com/aelsabbahy/goss/blob/master/docs/manual.md#package) test for it:

```bash
package:
  curl:
    installed: true
    skip: false
```

To fix our now failing test, let’s also update our Dockerfile:

```bash
FROM debian:10.4-slim

SHELL [ "/bin/bash", "-c" ]

RUN groupadd --gid 1000 node && useradd --uid 1000 --gid node --shell /bin/bash --create-home node

RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

USER node:node
WORKDIR /home/node
```

### node

[nvm](https://github.com/nvm-sh/nvm) turned into my go-to solution to install node. When installing node using nvm, we want to make sure that

1. A `.nvm` folder is located in our home directory
2. A node version corresponding to our image tag is installed at `$HOME/.nvm/versions/node`
3. Our node version is set as default in `$HOME/.nvm/alias/default`

A possible way to verify this is a [file](https://github.com/aelsabbahy/goss/blob/master/docs/manual.md#file) test which uses [variables](https://github.com/aelsabbahy/goss/blob/master/docs/manual.md#templates):

```yaml
file:
  /{{ .Env.HOME }}/.nvm:
    exists: true
    owner: node
    group: node
    filetype: directory
  /{{ .Env.HOME }}/.nvm/versions/node/v{{ .Env.NODE_VERSION }}:
    exists: true
    owner: node
    group: node
    filetype: directory
  /{{ .Env.HOME }}/.nvm/alias/default:
    exists: true
    owner: node
    group: node
    filetype: file
    contains: [/^{{ .Env.NODE_VERSION }}$/]
```

The following additions to our Dockerfile are required to pass our latest test:

```bash
ARG NODE_VERSION=12.18.0
ENV NODE_VERSION $NODE_VERSION
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash && chmod +x $HOME/.nvm/nvm.sh && . $HOME/.nvm/nvm.sh && nvm install $NODE_VERSION && nvm alias default $NODE_VERSION && nvm use default
```

### Startup

Our image should provide an [entrypoint](https://docs.docker.com/engine/reference/builder/#entrypoint) script which either forwards all non-command parameters to node, or executes any other command so we’re able to start our container running e.g. a `bash` shell.
To make sure we're providing an entrypoint script we’re going to extend our [file](https://github.com/aelsabbahy/goss/blob/master/docs/manual.md#file) test:

```yaml
file:
  ...
  /usr/local/bin/entrypoint.sh:
    exists: true
    owner: node
    group: node
    filetype: file
```

And with our entrypoint in place, we expect a running node [process](https://github.com/aelsabbahy/goss/blob/master/docs/manual.md#process) in our container:

```yaml
process:
  node:
    running: true
```

In order to pass our last tests we have will have to copy our script and configure an entrypoint:

```bash
COPY --chown=node:node entrypoint.sh /usr/local/bin

ENTRYPOINT [ "entrypoint.sh" ]
CMD [ "node" ]
```

## Conclusion

In this post we built a node Docker image the test-driven way. Instead of manually validating our setup at runtime we were able to write tests for our setup upfront, which made it easier for us to verify our image configuration.

The resulting image is configurable, so we’re free to choose our runtime, but still comes with a safety net which makes sure everything is in place. Best practices known from software development also apply to building Docker images!

The whole setup can be found on [GitHub](https://github.com/s1h-org/docker-tdd), if you have any questions, feel free to get in touch with me!