Dockerイメージへのテスト実装例（Automated Buildsの自動テスト対応含む）

## はじめに

Docker Hub の Automated Builds にはイメージの自動ビルドにテストコードによる自動テストを追加することが出来ます（参考 [Automated repository tests](https://docs.docker.com/docker-hub/builds/automated-testing/)）。`sut` (system under test) という名前のサービスを記述した `docker-compose.test.yml` を作成して、Docker Hub と連携させた GitHub にプッシュすることで Automated Builds がそれを自動的に実行するという仕組みです。テストコードはシェルスクリプトの他、実装次第でいろんな言語やフレームワークを使うことが可能です。

docker-compose を使ってテストするためローカルでも実行することが出来ます。Docker Hub へのイメージのプッシュや Automated Builds が必ずしも必要なわけではありません。そこでこの記事ではローカルで Docker イメージのテストを行いつつ、最後に Automated Builds に対応させるという流れにしています。そして番外編として docker-compose を使わないパターンも紹介します。

この記事は私が実際に Automated Builds の自動テストを使ってみて、どのように実装するのが良いのか試行錯誤して得たノウハウをまとめたものであり、公式ドキュメントや広く使われているパターンを紹介したものではありません。**個人的な考えやアイデアがかなり含まれています。**良くない方法かも知れないしもっと良い方法があるかもしれませんのでご了承ください。

### 公式ドキュメントへのツッコミ

余談ですが公式ドキュメント [Set up automated test files](https://docs.docker.com/docker-hub/builds/automated-testing) へのツッコミです。

```yaml
sut:
  build: .
  command: run_tests.sh
```

提示されていたサンプルコードは残念ながらこれだけでした。必要最小限の動作と Automated Builds の仕組みと設定方法しか書かれていなかったので、ここからどんな感じで Docker イメージのテストを書いていけば良いのかよくわからず、またネットで検索しても良い記事には出会えませんでした。それがこの記事としてまとめた理由です。

## テスト対象について

テスト対象について少し補足しておきます。Docker イメージのテストは docker-compose を使って行うので、正確には「Dockerイメージ」から作った「Dockerコンテナ」をテストすることになります。例えば Docker イメージのメタデータのテストなど、Docker コンテナを作らないテストもあり、本文中のやり方を応用すればそういったテストを書くことも出来ますが、この記事では原則として Docker コンテナをテストしています。なるべく適切な用語を使うよう「Docker イメージ」と「Docker コンテナ」という用語を使い分けていますが、そのせいで逆に混乱させてしまうかもしれません。この記事に関して言えばほとんど同じ意味で使っていると思って構いません。

### 何をテストするべきか？

さて「Docker イメージのテスト」ではなにをテストするべきでしょうか？忘れてはいけないのは Docker Hub は CI サービスではなく Docker イメージのレジストリということです。長々とアプリケーションのテストを行うためのサービスではありませんし、そのように作られてないはずです。ようするに「Docker イメージが想定通りビルドされているか」だけをテストするべきということです。

別の言い方をすれば、アプリケーションのテストでは通常行っていないと思われる Dockerfile や ENTRYPOINT で行っている処理のテストということになります。例えば `COPY` でイメージ内に追加したファイルが想定通りであるか `RUN` で追加したパッケージのコマンドが正しく実行できるか、コンテナ内部で起動したサービスにアクセスできるかなどです。

つまるところ**「（Docker イメージを使って）アプリケーションのテストを行う」のと「（アプリケーションを含んだ）Docker イメージのテストを行う」のは別である**ということです。Docker イメージのテストでは、やったとしてもせいぜい基本的な機能が動いているのを確認する程度（いわゆるスモークテスト）レベルで、本格的なアプリケーションのテストはローカルや専用の CI サービスなどを使って行います。

たまに Docker イメージの形で提供されているアプリケーションで ENTRYPOINT で実行するスクリプトがアプリケーションの機能そのものとなっているものを見かけることがありますが、Docker は原則としてアプリケーション実行環境の仮想化をするために使うものなので、アプリケーションそのものは環境さえ揃っていれば Docker イメージにしなくても動くように作るべきだと思います。

## ディレクトリの基本構成

（公式のサンプルコードから読み取った）プロジェクトのディレクトリ構成は以下のようになると思います。

```
project/
├── Dockerfile
├── README.md
├── docker-compose.test.yml （拡張子 .test.yml で複数作成可能）
├── run_tests.sh （テストコード）
└── prog.sh （例 Dockerイメージが提供する機能のプログラム）
```

シンプルなプロジェクトや Docker イメージを作るためだけのプロジェクトであればこれで十分なのですが、例えば一つのプロジェクトから複数のイメージを作成する等の理由で `Dockerfile` が複数必要だったり、CI の設定などすでにプロジェクトルートに多数のファイルがある場合、そこに Docker イメージとそのテストに関するファイルを追加するとさらにプロジェクトルートが混沌としてきます。また一般的にプロジェクトの `README.md` と Docker Hub の「[Overview](https://hub.docker.com/r/mysql/mysql-server)」は異なる内容（例えば Docker タグの説明等）を表示したくなると思うのですが、上記の構成だとプロジェクトの `README.md` がそのまま Docker Hub に表示されてしまいます。（しかも単純に `README.md` を読み取るだけなのでプロジェクト内へのページや画像へのリンクは切れて、読み取るサイズにも制限があります。）

そのため次のように Docker に関するファイルを `docker` ディレクトリ以下に移動した形を基本構成とします。

```
project/
├── docker
│   ├── Dockerfile
│   ├── README.md （Docker Hub の Overview 用）
│   ├── run_tests.sh
│   └── docker-compose.test.yml
│── .env
│── README.md
└── prog.sh
```

また、以下のような内容の `.env` ファイルをしています。（注意 リポジトリに入れません）

```config:.env
COMPOSE_PROJECT_NAME="project"
```

このファイルは `docker-compose.test.yml` を `docker` ディレクトリ以下に移動したために必要になったものです。 docker-compose のプロジェクト名は Docker コンテナを作成する時の名前の一部に利用されるのですが、デフォルトでは `docker-compose.test.yml` があるディレクトリ名（`project`）が使用されます。`docker` ディレクトリに移動すると、docker-compose のプロジェクト名が `docker` となってしまい、他のプロジェクトでも同じ構成にすると名前がかぶってしまうために明示的に指定しています。ただし `.env` は docker-compose 以外でも使われることがあるファイルで秘密情報を書き込むこともあるのでリポジトリには追加しないようにします。このような面倒があるため、`docker` ディレクトリではなくプロジェクト名等にしたほうがいいかもしれませんが、とりあえずこの点は重要ではないのでサンプルということでこのままにします。

この構成にした場合のローカルでのテストの実行コマンドはプロジェクトのルートディレクトリで `docker-compose -f docker/docker-compose.test.yml up` と入力します。任意で `--build`, `--abort-on-container-exit`, `--exit-code-from` 等のオプションを指定すると便利でしょう。また本番用の（テストコードを含まない） Docker イメージのビルドは `docker build -f docker/Dockerfile .` で行います。


## テストの方針

テストの方針は大きく二通り考えられます。一つは「1. テスト対象のコンテナの中でテストを行う」やり方で、サービスを一つだけ作ります。もう一つは「2. テスト対象のコンテナの外からテストを行う」やり方で、本番用のサービスとそれに対してテストを行うサービスの二つを作るやり方です。

どちらが良いかはプロジェクトによって変わると思いますが、コンテナ内部の構成を検証したり単体のコンテナだけで完結するよう簡単なものであれば前者で、複数のコンテナを連携したりする場合や本番用イメージに手を加えることなく厳密なテストを行いたい時は後者のほうが良いのではないかと思います。

また Automated Builds 対応のために、`docker-compose` を使用していますが、前者であれば `docker` コマンドだけでテストを行うことも可能です。これについては「番外編 Automated Builds対応が不要の場合」で説明します。

### 1. テスト対象のコンテナの中でテストを行う

テスト対象の Docker コンテナの中でテストコードを実行し、内部のプログラムやファイルが想定通りになっているかをテストします。マルチステージビルドを使って、一つの Dockerfile から本番用のイメージとテスト用のイメージをビルドしています。（1.1 を除く）

#### 1.1 テストコードをYAMLに直接埋め込む

一番シンプルな場合の例で `docker-compose.test.yml` にテストコードを直接記述します。本番用イメージを変更せずにそのままテストするためマルチステージビルドは使いません。

```yaml:docker-compose.test.yml
version: "3"
services:
  sut:
    build:
      context: ..
      dockerfile: docker/Dockerfile
    entrypoint: /bin/sh -eux -c
    command:
    - |
      exec 1>&2
      type awk                                        # awkコマンドが実行可能かつ存在するか？
      type /usr/bin/awk                               # awkコマンドが/usr/bin/awkに存在するか？
      file /etc/apache2/apache2.conf                  # apache2.confが存在するか？
      apache2 --version                               # apache2 --versionが実行できるか？
      grep VERSION_CODENAME=buster /etc/os-release    # VERSION_CODENAME=がbusterであるか？
      pidof apache2                                   # apache2が起動しているか？
      nc -vz localhost 80                             # TCP80ポートをオープンしているか
      stat -c "%a %U:%G" /etc | grep "755 root:root"  # アクセス権、ユーザー、グループの確認
```

```Dockerfile:docker/Dockerfile
FROM alpine as release
COPY ./prog.sh /usr/local/bin/
```

公式のサンプルとは違い `build` で `context` と `dockerfile` を定義しています。これは Docker に関連するものを `docker` ディレクトリ以下に移動したために必要となったものです。

`entrypoint` はテストコードを`docker-compose.test.yml`に直接埋め込むために必要です。これにより標準で `ENTRYPOINT` が定義されているは上書きされます。必要な場合はテストコードから実行してください。またテストコードは `docker-compose.test.yml` ファイルの一部なので docker-compose によるパース処理が行われます。`$` は特殊な文字として扱われるので `$$` とエスケープして書く必要があります。

この例ではテストコードをシェルスクリプト (`/bin/sh`) で記述していますが、テスト対象のイメージに含まれているものであれば他の言語も使用できます。しかし言語そのものならまだしもテスティングフレームワークが本番用イメージに含まれていることはまずないので使える言語は限られると思います。個人的には Docker イメージのテストでそこまで複雑なことが必要になるとは思わないので `/bin/sh` か `/bin/bash` で十分だと思います。

本筋から少しずれますが、シェルスクリプトに関しての補足です。`/bin/sh` のオプションの `e` は（テストの）コマンドが失敗したら直ちに中断させるためのもので、これを前提とするとテストコードがシンプルとなります。例えば上記のコードに if 文は存在しませんが手抜きではありません。コマンドが失敗すればそこで中断します（もちろん if 文を使って複雑な検証もできます）。`u` は未定義の変数を使用した場合にエラーにするためのもので、分かっている上でならなくても構いません。`x` はこれから実行するコマンドを出力する機能で実行前のログとして使えます。各コマンドの出力は実行後のログとしてあえて出力しています。例えば `type awk` の出力は `awk is /usr/bin/awk` という”ログ”です。`exec 1>&2` は標準出力を標準エラー出力に変更しています。こうすることで `x` による出力とコマンド実行結果の出力順が混ざらなく（混ざりにくく）なります。

#### 1.2 テストコードを別ファイルに分離する

テストコードが長くなったなどの理由でテストコードを別ファイルに分離する方法です。マルチステージビルドを使ってテストコードを含んだテスト用イメージを作成します。厳密に言えば本番用イメージとテスト用イメージが異なるわけですが、テストコードのファイル（シェルスクリプト）を追加しているだけなので大きな影響はないはずです。公式のサンプルコードに一番近い形です。

```yaml:docker-compose.test.yml
version: "3.4"
services:
  sut:
    build:
      context: ..
      dockerfile: docker/Dockerfile
      target: test
    command: run_tests.sh
```

マルチステージビルドを使用するので Compose ファイルの `Version` は `3.4` (Docker Engine 17.09.0) 以上が必要になります。また、テスト実行時にテスト用イメージを使うために `target: test` を追加しています。

```Dockerfile:docker/Dockerfile
# 本番用イメージ
FROM alpine as release
RUN apk add bash
COPY ./prog.sh /usr/local/bin/

# テスト用イメージ
FROM release as test
COPY docker/run_tests.sh /usr/local/bin/

# デフォルト（--target未指定時）を本番用イメージにする
FROM release as production
```

本番用とテスト用の両方のイメージが含まれています。テスト用イメージは本番用イメージを親イメージとして使いテストコードのファイルを追加しています。最後の行は Docker イメージを `--target` オプションをつけずにビルドした場合に本番用イメージを生成するためのものです。（Dockerfile にデフォルトの target を指定する機能があればいいのですが）

```sh:run_tests.sh
#!/bin/sh -eux
exec 1>&2
bash --version
```

テストでは bash を実際に実行することで、bash が実行できることを確認しています。（実行権限を調べたりするよりも確実な方法です。）

なお本番用イメージにテスト用のコードを含ませないように厳密にするためにこのようにしていますが、この程度であればサイズも小さく影響も少ないので面倒であれば本番用イメージにテストコードまで含めてしまってもいいかなとも思います。

#### 1.3 テスト用にパッケージを追加する

この方法は推奨はしません。なぜなら本番用イメージとテスト用イメージが大きく異なってしまう可能性があるのと、テスト用イメージのビルドに時間がかかるようになるからです。本番用イメージとテスト用イメージが大きく異なると本番用イメージは正しく動かないのにテスト用イメージでは動いてしまう可能性があります。とはいえ、さまざまな理由で避けられないことがあるかもしれません。

例えば [Serverspec](https://serverspec.org/) を使用してテストを書きたい（書かなくてはいけない）とします。（ちなみに私は Serverspec に関してはほぼ素人です。間違い等ありましたらすみません。）Serverspec でテストを行うために ruby や rspec などのパッケージが必要になります。このパッケージはテストのためだけに必要となるものなので本番用イメージには含めたくありません。そこで前項と同様にマルチステージビルドを使ってパッケージを追加したテスト用イメージを作成します。

前提として本番用イメージには alpine を使うことにしますが、alpine には Serverspec のパッケージがありませんでした。そのため `Gemfile` からライブラリをインストールしています。Serverspec (rspec) の話が絡んでいるので、軽くディレクトリ構造を説明しておきます。`Gemfile`とテストコードのファイルが入った `spec` ディレクトリを追加しています。

```
project/
├── docker
│   ├── Dockerfile
│   ├── README.md
│   ├── docker-compose.test.yml
│   ├── Gemfile
│   └── spec
│       ├── spec_helper.rb
│       └── test_spec.rb
│── .env
│── README.md
└── prog.sh
```

まず最初に `docker-compose.test.yml` です。シェルスクリプトの `run_tests.sh` の代わりに、テスト実行コマンドの `rspec` を呼び出すように変更します。

```yaml:docker/docker-compose.test.yml
version: "3.4"
services:
  sut:
    build:
      context: ..
      dockerfile: docker/Dockerfile
      target: test
    command: rspec
```

次に Dockerfile です。テストに必要なパッケージとライブラリをインストールしたテスト用イメージを作成します。

```Dockerfile:docker/Dockerfile
# 本番用イメージ
FROM alpine as release
RUN apk add bash
COPY ./prog.sh /usr/local/bin/

# テスト用イメージ
FROM release as test
WORKDIR /root
RUN apk add ruby-bundler ruby-rspec
COPY docker/Gemfile ./
RUN bundle config silence_root_warning 1 && bundle
COPY docker/spec ./spec

# デフォルト（--target未指定時）を本番用イメージにする
FROM release as production
```

イメージの依存関係に注目してください。テスト用イメージは本番用イメージが親イメージになっています。つまり本番用イメージを変更するたびに、テスト用イメージも作り直さなければいけなくなるので、パッケージとライブラリのインストールを何度も行ってしまいます。これがテスト用イメージのビルドに時間がかかるようになる理由です。

また、本番用イメージだけが必要な場合でも、テスト用イメージまでビルドしてしまいます。この問題に関しては Build Kit を有効にする（環境変数 `DOCKER_BUILDKIT` を `1` にする）ことで回避が可能です。

最後に Serverspec でテストするのに必要なファイルです。

```ruby:docker/Gemfile
source 'https://rubygems.org'
gem 'serverspec'
```

```ruby:docker/spec/spec_helper.rb
require 'serverspec'
set :backend, :exec
```

```ruby:docker/spec/test_spec.rb
require 'spec_helper'

describe package('bash') do
  it { should be_installed }
end
```

このテストでは bash パッケージがインストールされていることを確認しています。ちなみに個人的な意見ですが、私はパッケージがインストールされているかのテストは不要だと考えています。なぜなら本当に確認したいのは「bash が実行できること」なので、それならば実際に bash を実行してみるのが確実で、パッケージで入れようが独自ビルドしようがインストール方法（実現手段）には興味がないからです。

#### 1.4 テスト用にバイナリファイルを追加する

「1.3 テスト用にパッケージを追加する」の改良型とも言えるパターンです。1.3 では本番用イメージが変更されるたびにテスト用イメージも作り直されてしまいましたがそれを回避する方法です

`docker-compose.test.yml` と `run_tests.s` は「1.2 テストコードを別ファイルに分離する」と同じなので省略します。

```Dockerfile:docker/Dockerfile
# 本番用イメージ
FROM alpine as release
RUN apk add bash
COPY ./prog.sh /usr/local/bin/

# テスト用追加ファイルイメージ
FROM alpine as builder
ENV JQ=https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64
RUN wget -q $JQ -O - | install /dev/stdin /usr/local/bin/jq

# テスト用イメージ
FROM release as test
COPY --from=builder /usr/local/bin/ /usr/local/bin/
COPY docker/run_tests.sh /usr/local/bin/

# デフォルト（--target未指定時）を本番用イメージにする
FROM release as production
```

テストコードを書くために `jq` コマンドが必要になったという想定です。「本番用イメージ」と「テスト用追加ファイルイメージ」に依存関係がないことに注目してください。時間がかかるのは jq のダウンロードでコピーだけならファイルサイズが相当大きくない限りに時間はかからないので、ダウンロード済みのイメージを作っておいて実行ファイルをコピーしています。（Dockerfile の `ADD` コマンドで URL から直接追加するのはやめましょう。毎回ダウンロードが行われ、時間がかかる上に相手のサーバーにも負担をかけてしまいます。）

このパターンが「1.3 テスト用にパッケージを追加する」の改良型である理由は、jq のような単一のバイナリだけでなく、ruby や rspec のような複数のファイルで構成される場合でも使えるからです。ただし `apk` や `apt-get` を使った一般的なパッケージのインストール方法ではどこに何がインストールされるかを判断するのは面倒なので、独自ビルドで標準とは違うディレクトリ以下にインストールしたり、パッケージファイルのみをダウンロードしておいてインストールはテスト用イメージで行うなどの工夫をする必要があります。（独自ビルドを行う場合は本番用イメージとビルドを行うイメージの`FROM`は、同じか互換性があるイメージを使用する必要があります。）

補足ですが「テスト用追加ファイルイメージ」はファイルを提供するためだけので汎用性があり使い回すことが出来ます。例えば jq のスタティックバイナリだけを入れたイメージを作っておけば（また Docker Hub などにプッシュしておけば）以下のように短くすることが出来ます。

```Dockerfile:docker/Dockerfile
# 本番用イメージ
FROM alpine as release
RUN apk add bash
COPY ./prog.sh /usr/local/bin/

# テスト用イメージ
FROM release as test
COPY --from=jq-static /usr/local/bin/ /usr/local/bin/
COPY docker/run_tests.sh /usr/local/bin/

# デフォルト（--target未指定時）を本番用イメージにする
FROM release as production
```

参考 jq のスタティックバイナリだけを入れたイメージの作り方

```Dockerfile:Dockerfile.jq
# docker build -f Dockerfile.jq -t jq-static .
FROM alpine as builder
ENV JQ=https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64
RUN wget -q $JQ -O - | install /dev/stdin /usr/local/bin/jq

FROM scratch
COPY --from=builder /usr/local/bin/ /usr/local/bin/
```

そしてここで宣伝を一つ。私が開発している [shellspec](https://github.com/shellspec/shellspec) というテスティングフレームワークは、完全にシェルスクリプトで実装されているため OS 非依存で POSIX 準拠のシェルさえあれば動作します。また公式でテスティングフレームワークのみをインストールした Docker イメージ（scratch イメージをベースとしておりイメージサイズはわずか 40KB）も用意しているので、このような使い方にも適しています。

#### 1.5 Docker コマンドを使ったテスト

Docker コンテナの中から `docker` コマンドを使えるようにすることで、Docker イメージ または Docker コンテナのテストを行うことができます。`docker inspect` を使ってメタデータのテストを行いたい等の場合を想定しています。

```yaml:docker/docker-compose.test.yml
version: "3.4"
services:
  sut:
    build:
      context: ..
      dockerfile: docker/Dockerfile
      target: test
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: run_tests.sh
```

技術的には Docker outside of Docker (DooD) と呼ばれている手法で `volumes` で指定している `/var/run/docker.sock` （UNIXドメインソケットファイル）を経由してコンテナの中からコンテナの外の Docker にアクセスしています。

```Dockerfile:docker/Dockerfile
# 本番用イメージ
FROM alpine as release
LABEL "com.example.vendor"="ACME Incorporated"
EXPOSE 80
COPY ./prog.sh /usr/local/bin/

# テスト用追加ファイルイメージ
FROM alpine as builder
ENV DOCKER=https://download.docker.com/linux/static/stable/x86_64/docker-19.03.5.tgz
RUN wget -q $DOCKER -O - | tar xz -C /opt

# テスト用イメージ
FROM release as test
COPY --from=builder /opt/docker/docker /usr/local/bin/
COPY docker/run_tests.sh /usr/local/bin/

# デフォルト（--target未指定時）を本番用イメージにする
FROM release as production
```

`docker` コマンドが必要なためスタティックバイナリで配布されている Docker のアーカイブをダウンロードしています。

```sh:docker/run_tests.sh
#!/bin/sh -eux

exec 1>&2
cid=$(basename $(cat /proc/1/cpuset))
inspect() {
  docker inspect --format "$1" "$2"
}
[ "$(inspect '{{index .Config.Labels "com.example.vendor"}}' "$cid")" = "ACME Incorporated" ]
[ "$(inspect '{{index .Config.ExposedPorts "80/tcp"}}' "$cid")" != "<no value>" ]
```

このテストでは Docker イメージの LABEL と EXPOSE のテストを行っています。`/proc/1/cpuset` には`/docker/76bd63191859659df42ea9bba0af0767969bab3ac9253db6bb5a54691a5bdc99` のような形で現在の Docker コンテナの ID が入っているのでそれを利用してコンテナの情報を取得しています。

また詳細は省略しますが、Serverspec にも `docker` コマンドを内部的に呼び出す `docker_container` や `docker_image` というリソースタイプがありますので Serverspec でテストを書くこともできます。

#### 1.6 Docker API を使ったテスト

`docker` コマンドが使用できると言う話でピンときた方もいるかと思いますが（HTTP プロトコルベースの）Docker API を使うこともできます。「1.5 Dockerコマンドを使ったテスト」のちょっとした問題点は、`docker` コマンドのバイナリサイズが 60MB 以上とかなり大きいことです。イメージはキャッシュされるのでそれほど困らないかもしれないですが小さくて済むならそれに越したことはないでしょう。また `docker inspect` で使用されている go のテンプレートよりも、`jq` の方が慣れているという人も多いと思います。

`docker-compose.test.yml` は前項と同じなので省略します。

```Dockerfile:docker/Dockerfile
# 本番用イメージ
FROM alpine as release
LABEL "com.example.vendor"="ACME Incorporated"
EXPOSE 80
COPY ./prog.sh /usr/local/bin/

# テスト用追加ファイルイメージ
FROM alpine as builder
RUN apk update && apk fetch curl jq -o /var/local

# テスト用イメージ
FROM release as test
COPY --from=builder /var/local/ /var/local/
RUN apk add /var/local/*.apk
COPY docker/run_tests.sh /usr/local/bin/

# デフォルト（--target未指定時）を本番用イメージにする
FROM release as production
```

今回は curl と jq を使いますが、ビルドが少し大変そうだったのでサンプルを兼ねてパッケージのダウンロードのみを行ってテスト用イメージでインストールしています。インストール処理が必要な分、単純なコピーよりも少し時間がかかってしまうようです。

```sh:docker/run_tests.sh
#!/bin/sh -eux

exec 1>&2
cid=$(basename $(cat /proc/1/cpuset))
inspect() {
  curl -sSfL --unix-socket /var/run/docker.sock "http://localhost/containers/$2/json" | jq -r "$1"
}
[ "$(inspect '.Config.Labels["com.example.vendor"]' "$cid")" = "ACME Incorporated" ]
[ "$(inspect '.Config.ExposedPorts["80/tcp"]' "$cid")" != "null" ]
```

実装が `docker` コマンドから `curl` と `jq` になっているだけでやっていることは変わりません。Docker というよりも単に API のテストを行っているだけなので、各種テスティングフレームワークを使うことも可能でしょう。（あ、shellspec には便利な HTTP ライブラリ等ないので結局は curl や jq を使います。jq を使わない JSON 用のマッチャーは作りたいと思っていますが。）

### 2. テスト対象のコンテナの外からテストを行う

テスト対象のサービスとテストを行う `sut` サービスを別々に定義し、テスト対象のDocker コンテナの外部からテストを行います。それぞれ別の Dockerfile を使うのでマルチステージビルドは使いません。テスト対象のイメージは本番用イメージと全く同じものが使えますし、`sut` サービスで使用するイメージはテスト実行の専用のイメージなので、テスト対象と同じ親イメージである必要はなくパッケージを簡単にインストールできるというメリットがあります。その反面コンテナ内部のテストはしづらくなります。

#### 2.1 サーバーのテスト

コンテナで動くウェブサーバー等に接続できるかといったテストを行います。

```yaml:docker/docker-compose.test.yml
version: "3"
services:
  web:
    build:
      context: ..
      dockerfile: docker/Dockerfile
  sut:
    build:
      context: .
      dockerfile: Dockerfile.test
    depends_on:
      - web
    command: run_tests.sh
```

`sut` サービス用の `Dockerfile.test` にはイメージにアプリケーションを含む必要がないため `context` を `Dockerfile.test` と同じ場所にしています。

```Dockerfile:docker/Dockerfile
FROM debian
RUN apt-get update && apt-get install -y nginx
COPY ./prog.sh /usr/local/bin/
CMD ["/usr/sbin/nginx", "-g", "daemon off;"]
```

```Dockerfile:docker/Dockerfile.test
FROM alpine
RUN apk add curl
COPY run_tests.sh /usr/local/bin/
```

テスト対象のイメージとテスト実行用イメージが別でよいので、それぞれ別の親イメージを使用しています。キャッシュを効かせるためにマルチステージビルドでコピーしたりという工夫も必要ないため一般的なパッケージマネージャーを使ってインストールすることが出来ます。

```sh:docker/run_tests.sh
#!/bin/sh -eux

exec 1>&2
curl http://web/ # webサービスへのhttpリクエストに反応するか
```

この例ではテスト対象がウェブサーバーであるため `curl` コマンドを使ってテストしていますが、データベースサーバーなどではそれぞれ適したツールを使います。

#### 2.2 docker コマンドを使用した外部からのテスト

「1.5 Docker コマンドを使ったテスト」と同様に外部から `docker` コマンドを使用してテストすることも可能です。`docker inspect` を使ったメタデータのテストの他、`docker exec` を使ってテスト対象の Docker コンテナでコマンドを実行することでコンテナ内部のテストを行うことも出来ます。テスト自体は少し面倒になりますがテスト実行用のイメージを作るのは楽になります。

```yaml:docker/docker-compose.test.yml
version: "3"
services:
  web:
    build:
      context: ..
      dockerfile: docker/Dockerfile
  sut:
    build:
      context: .
      dockerfile: Dockerfile.test
    depends_on:
      - web
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: run_tests.sh
```

```Dockerfile:docker/Dockerfile
FROM debian
RUN apt-get update && apt-get install -y nginx
COPY ./prog.sh /usr/local/bin/
CMD ["/usr/sbin/nginx", "-g", "daemon off;"]
```

```Dockerfile:docker/Dockerfile.test
FROM docker
COPY run_tests.sh /usr/local/bin/
```

`sut`サービスで使うイメージには公式でalpineベースの[Dockerが入ったDockerイメージ](https://hub.docker.com/_/docker)が用意されているのでそれを使うことができます。もちろん別のイメージを使用したりこのイメージにパッケージを加えて使うことも可能です。

```sh:docker/run_tests.sh
#!/bin/sh

exec 1>&2
inspect() {
  docker inspect --format "$1" "$2"
}
project=$(inspect '{{ index .Config.Labels "com.docker.compose.project"}}' $HOSTNAME)
cid=$(docker ps -q -f "label=com.docker.compose.project=$project" -f "label=com.docker.compose.service=web")

[ "$(inspect '.Config.Labels["com.example.vendor"]' "$cid")" = "ACME Incorporated" ]
[ "$(inspect '.Config.ExposedPorts["80/tcp"]' "$cid")" != "null" ]

docker exec -i $cid /bin/sh <<HERE
set -eux
exec 1>&2
/usr/sbin/nginx -V
HERE
```

最初にテスト対象のコンテナIDを突き止める必要があります。その後のメタデータのテストは同じです。また、`docker exec` でテストコードを送り込むことでテスト対象のコンテナでシェルスクリプトを実行してコンテナ内部のテスト（nginx が実行できるか？）を行っています。

#### 2.3 Docker APIを使用したテスト

「1.6 Docker API を使ったテスト」と同様に Docker API を使うことも出来ます。`curl` と `jq` を使用した場合のテストは今までの応用で出来ますので、少し変わった所で Serverspec の Docker バックエンドを使用したテストを紹介します。細かい説明は省略します。

```yaml:docker/docker-compose.test.yml
version: "3"
services:
  web:
    build:
      context: ..
      dockerfile: docker/Dockerfile
  sut:
    build:
      context: .
      dockerfile: Dockerfile.test
    depends_on:
      - web
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: rspec
```

```Dockerfile:docker/Dockerfile
FROM debian
RUN apt-get update && apt-get install -y nginx
COPY ./prog.sh /usr/local/bin/
CMD ["/usr/sbin/nginx", "-g", "daemon off;"]
```

```Dockerfile:docker/Dockerfile.test
FROM debian
WORKDIR /root
RUN apt-get update \
 && apt-get install -y ruby ruby-rspec ruby-serverspec ruby-docker-api
COPY spec ./spec
```

```ruby:docker/spec/spec_helper.rb
require 'serverspec'
require 'docker'

container = Docker::Container.get(ENV['HOSTNAME'])
project = container.info['Config']['Labels']['com.docker.compose.project']
web = Docker::Container.all(filters: {
  label: [
    'com.docker.compose.project=' + project,
    'com.docker.compose.service=web'
  ]
}.to_json).first

set :backend, :docker
set :docker_container, web.id
```

```ruby:docker/spec/test_helper.rb
require 'spec_helper'

describe package('nginx') do
  it { should be_installed }
end
```

## Docker Hub の Automated Builds の設定

さて、それではローカルでテストができるようになった所で、最後に Docker Hub の Automated Builds の設定を行います。アカウントの作成やリポジトリの作成は済ませてあるという前提です。Automated Builds の設定といっても、実はプロジェクトを GitHub と関連付け BUILD RULES の設定をするだけで完了です。その点に特に難しいところは無いと思います。ただ Docker Hub の Overview をプロジェクトの `README.md` とは別のものにするために少し小細工をします。

まずプロジェクトのディレクトリ構造を再掲します。

```
project/
├── docker
│   ├── Dockerfile
│   ├── README.md （Docker HubのOverview用）
│   └── docker-compose.test.yml
│   └── hooks
│       └── build
│── .env
│── README.md
└── prog.sh
```

一箇所 `hooks/build` というファイルを追加しています。これが小細工（ワークアラウンド）です。内容は以下のようなものになります。

```sh:hooks/build
#!/bin/sh
docker build -f "$DOCKERFILE_PATH" -t "$IMAGE_NAME" ..

# おそらくこれが標準のビルドコマンド。末尾のドットが異なります。
# docker build -f "$DOCKERFILE_PATH" -t "$IMAGE_NAME" .
```

そしてもう一箇所 Docker Hub の BUILD RULES の設定を変更します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54918/74d56d8a-0c68-3931-67a6-50f03deae309.png)

注目すべき点は **Build Context** です。ビルドコンテキストとして `docker` ディレクトリを指定しています。ただしこのビルドコンテキストは偽のビルドコンテキストで本当のビルドコンテキストは `hooks/build` で一つ上のディレクトリ（つまりプロジェクトルート）を使用しています。要するにプロジェクトルートで `docker build -f docker/Dockerfile .` でビルドするのと同じことをしています。

このようにした理由は Docker Hub が Overview に使用する `README.md` を `Dockerfile` と同じディレクトリや実際のビルドコンテキストではなく、設定の Build Context 以下にあるものを使用するためです。正直バグっぽい挙動な気もするのですが修正すると動きが変わるのでそのままなのでしょう。Overviewとして使うファイルを指定できれば良いのですが。

## 番外編 Automated Builds 対応が不要の場合

ここまでは Docker Hub の Automated Builds に対応させるのを前提としてコードを書いてきましたが Docker Hub にプッシュすることはない、ローカルでテストできれば十分、別の CI でテストをしたいという理由で、Automated Builds 対応が必要ない場合もあると思います。そういう場合の docker-compose を使わない実装例です。

まず「1. テスト対象のコンテナの中でテストを行う」のパターンのテストを行うこととします。「2. テスト対象のコンテナの外からテストを行う」のパターンだと複数のサービスを使うことになるので docker-compose を使用した方が楽です。

「1.2 テストコードを別ファイルに分離する」のコードをベースとします。docker-compose を使わないので、`docker-compose.test.yml` は作成しません。Dockerfile とテストコードのファイルのみです。ディレクトリ構成も一番シンプルな形に戻します。

```
project/
├── Dockerfile
├── README.md
├── run_tests.sh
└── prog.sh
```

```Dockerfile:Dockerfile
# 本番用イメージ
FROM alpine as release
RUN apk add bash
COPY ./prog.sh /usr/local/bin/

# テスト用イメージ
FROM release as test
COPY run_tests.sh /usr/local/bin/
CMD [ "run_tests.sh" ]

# デフォルト（--target未指定時）を本番用イメージにする
FROM release as production
```

```sh:run_tests.sh
#!/bin/sh -eux
bash --version
```

あとは docker-compose の代わりに docker を使ってテストを実行します。


```sh
# 本番用イメージの作成手順
$ docker build -t project .

# テスト用イメージの作成と実行
$ docker build --target test -t project-test .
$ docker run --rm project-test

# タグ名をつけずにビルドと実行を同時行う場合
$ docker run --rm "$(docker build -q --target test .)"
```

「1.4 テスト用にバイナリファイルを追加する」への応用は簡単だと思いますので省略します。

## さいごに

以上、私が実際に使用してるパターンや、またこういうのが欲しくなるだろうなと考えながら実装例をまとめてみました。元々この調査を始めたのは文中でも紹介した自作のテスティングフレームワークの shellspec を Docker や CI と上手く組み合わせて使うには何が必要か？を洗い出すための作業だったりします。実際にやってみると本番用イメージとテスト用イメージの依存関係の問題から時間がかかるパッケージのダウンロードとインストールが発生してそれをいかに効率よく解決するかに工夫が必要になりました。そしてそれを解決する方法としてマルチステージビルドが重要な鍵になることもわかりました。面白い発見としては scratch ベースのイメージに実行ファイルだけを入れるという使い方です。このイメージはそのままでは実行できないのでただのアーカイブですが、Docker のキャッシュの仕組みを上手く活用することが出来ます。あれこれ工夫を重ねたおかげで shellspec の方もかなり良い感じで Docker や CI と連携できるようになったと思います。（Docker 連携はまだベータ版ですが。）

ただ私は Docker イメージのテストには（自作の shellspec も含め）テスティングフレームワークは使わないと思います。なぜなら Docker イメージのテストはそれほど大規模なものにはならないはずだし、ならないようにするべきだと考えているからです。（テストの大部分はアプリケーションのテストとして行います。）「1.2 テストコードを別ファイルに分離する」のパターンを基本とし、どうしても必要な場合は何かしらの小さなパッケージを入れる程度です。そして Docker イメージのテストで主に行うであろうファイルやプロセスの確認や通信テストといった内容であればシェルスクリプトが適してるのではないでしょうか？