Docker Composeのテストについて考えてみた

この記事は [NTTコミュニケーションズ Advent Calendar 2018](https://qiita.com/advent-calendar/2018/nttcom) の10日目です。

## 3行で要約

- Docker Composeで構成されたWebサービスのテストに関する考察
- dockerize + Chromium + Puppeteer + Jest = ブラウザテスト用のコンテナ
- CI環境のホストが占有できるのであれば、`network_mode: "host"`でテスト用のコンテナを動かそう



## Docker Composeのテストをしたいが……？

こんにちは、@nitkyです。最近は、認定スクラムマスターになったり、大学に通ったりしながら元気にやっています。

突然ですが、**Docker Composeのテスト**は皆様どうしてますか？ Composeで構成されたWebサービスのテスト、特にサービス全体が正しく動いているかどうかをテストしたいとき、Docker的な手法でテストも含めて完結させたいなと思うことがあります。

### テスト環境自体のコンテナ化
ここでは、ブラウザを使ったテストについて考えてみましょう。ブラウザを使うようなテストは、そもそも「テスト環境自体をコンテナ化したい」という需要があります。その場合、テスト用のコンテナとCompose上のサービスは、どのように接続されるべきでしょうか？

大きく分けて実現方法は2つ[^1]あると考えられます。

1. **アイデアが閃く派**「Composeワールドの中に、テスト用のコンテナをサービスとして追加すれば？」
2. **現実は非情だよ派**「E2E[^2]テストはユーザと同じ環境でやらないと意味がないから、外部からやるしかない」

前者で上手くいくなら、Composeに含まれるサービスの一つとしてテストを定義できそうです。

しかし、実際は**コンテナのポートをホストマシンに割り当ててサービスを利用する**はずなので、テスト環境も可能な限りそちらに合わせたいという後者の意見も分かります。特に前者の場合、実際のサービスとは異なる問題が新たに発生する可能性があります。

それでは、実際にComposeのテスト手法について考えていきましょう。


## この記事の対象者

- Compose上に構成されたサービスのテストをしたいと考えている方
- 特にWebサービスのブラウザテストをしたいと考えている方



## アプリケーションのテスト概要

[Compose](https://docs.docker.com/compose/)とは、複数のコンテナを使ったDockerアプリケーションを定義＆実行するためのツールです。

今回は、この仕組みを使って作られたWebアプリケーションのテストについて考えます。



### テスト対象となるWebサービス

[公式ドキュメントのWordPress起動手順](https://docs.docker.com/compose/wordpress/)を参考に、テスト対象となるWebサービス(WordPress)を用意します。

```yaml:docker-compose.yml
version: '3.3'

services:
   db:
     image: mysql:5.7
     volumes:
       - db_data:/var/lib/mysql
     restart: always
     environment:
       MYSQL_ROOT_PASSWORD: somewordpress
       MYSQL_DATABASE: wordpress
       MYSQL_USER: wordpress
       MYSQL_PASSWORD: wordpress

   wordpress:
     depends_on:
       - db
     image: wordpress:latest
     ports:
       - "8000:80"
     restart: always
     environment:
       WORDPRESS_DB_HOST: db:3306
       WORDPRESS_DB_USER: wordpress
       WORDPRESS_DB_PASSWORD: wordpress
volumes:
    db_data:
```



早速、用意した`docker-compose.yml` を動かしてみましょう。

```bash
$ docker-compose up
```



![WordPressのインストール画面.png](https://qiita-image-store.s3.amazonaws.com/0/217024/b768dd7a-317c-8639-0962-766d418bc30d.png)
[図1: WordPressのインストール画面]



日本語を選択して、設定を入れていきます。

| サイトのタイトル | test-compose     |
| ---------------- | ---------------- |
| ユーザ名         | admin            |
| パスワード       | changeme         |
| メールアドレス   | test@example.com |

[表1: WordPressの設定]


![WordPressの設定.png](https://qiita-image-store.s3.amazonaws.com/0/217024/2f972bcb-973e-417f-00ef-4108259126a8.png)
[図2: WordPressの設定]



以上で、テスト対象となるWebサービスの準備が完了しました。



### テスト内容

以下のURLを対象に、動作を確認していきます。

| 起動したサービスをcompose側から見たとき | composeサービスをホスト側から見たとき    |
| :------------------------------ | ---------------------------------- |
| http://wordpress/wp-login.php   | http://localhost:8000/wp-login.php |

[表2: ログインページURLの見え方]

どちらからも、WordPressのログイン画面はこのように見えています。

![WordPressのログイン画面.png](https://qiita-image-store.s3.amazonaws.com/0/217024/6c16f860-4c0b-51e9-9632-4df6d184b9e3.png)
[図3: WordPressのログイン画面]


実際にAdminでログインしてみると、このようなダッシュボードが表示されます。


![WordPressのダッシュボード.png](https://qiita-image-store.s3.amazonaws.com/0/217024/b135274a-d61f-670c-2509-bf88d1cb1ec2.png)

[図4: WordPressのダッシュボード]


正常な動作が見えてきたので、実際に二つのテストシナリオについて考えてみましょう。（テスト自体のクオリティはツッコミどころ満載ですが、今回は気にしないでください……）


#### テストシナリオ1: ログインページの表示

*「ログインページが正しく表示されるかテストする」*

1. ログインページを表示
2. タイトルの確認
  1. Titleタグに`Log In`が含まれるかテスト



#### テストシナリオ2: Adminログイン

*「ログインページから `admin:changeme`でログインして、ダッシュボードへ行けるかテストする」*

1. ログインページを表示
2. 指定の入力フォームに値を代入
  1. ユーザ名：`admin`
  2. パスワード：`changeme`
3. ボタンを押してログイン
4. ダッシュボードの表示を確認
  1. Titleタグに`Dashboard`が含まれるかテスト



## ブラウザテスト環境のコンテナ化

ブラウザテスト環境のコンテナ化で障害となりそうなことを表にまとめます。

| 問題点                                                       | 解決に必要なサービス・アプリケーション                                             |
| :----------------------------------------------------------- | ------------------------------------------------------------ |
| コンテナ上のサービスが使用可能になるタイミングが不明 | [Dockerize](https://github.com/jwilder/dockerize)            |
| コンテナ上で使用できるヘッドレスブラウザ                     | [Chromium](https://www.chromium.org/) |
| ヘッドレスブラウザ操作のオートメーション方法                     | [Puppeteer](https://github.com/GoogleChrome/puppeteer)       |
| ブラウザ操作のオートメーションのテストケース化                       | [Jest](https://jestjs.io/ja/)                                |

[表2: ブラウザテスト環境のコンテナ化における問題点と対策]



あくまでこれは一例ですが、サクッと解決してテスト用コンテナを実際に作成していきます。
（詳細は各自でリンク先を確認してください）


### テスト用コンテナの構成要素

上の調査より、ブラウザテスト環境のコンテナ化には大きく分けて以下の4つが必要だと判明しました。

- dockerize
- Chromium
- Puppeteer
- Jest

早速、コンテナの中に詰め込んでいきましょう。

### テスト用コンテナの作成

実際にコンテナ化すると、`Dockerfile`は以下のようになります。
（本番環境で利用する場合はバージョンの固定をオススメします）

```dockerfile
FROM node:alpine

# Installs dockerize
ENV DOCKERIZE_VERSION v0.6.1
RUN apk add --no-cache openssl
RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
    && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
    && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz

# Installs latest Chromium package.
RUN apk update && apk upgrade && \
    echo @edge http://nl.alpinelinux.org/alpine/edge/community >> /etc/apk/repositories && \
    echo @edge http://nl.alpinelinux.org/alpine/edge/main >> /etc/apk/repositories && \
    apk add --no-cache \
      chromium@edge \
      nss@edge

# Tell Puppeteer to skip installing Chrome. We'll be using the installed package.
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true

WORKDIR /app

# Install Puppeteer.
RUN yarn add puppeteer

# Add user so we don't need --no-sandbox.
RUN addgroup -S pptruser && adduser -S -g pptruser pptruser \
    && mkdir -p /home/pptruser/Downloads \
    && chown -R pptruser:pptruser /home/pptruser \
    && chown -R pptruser:pptruser /app

# Run everything after as non-privileged user.
USER pptruser

# Jest works with Chromium
RUN npm init -y
RUN npm i jest-puppeteer puppeteer jest
```

以下のコマンドでテスト環境専用のコンテナを作成します。

```
$ docker build -t puppetester .
```

テスト用コンテナが作成できました！


### Jestの設定
JestはReactアプリケーションのテストを主目的として作られていますが、一般的なWebアプリケーションのテストにも非常に有益なツールです。Puppeteerを利用したテストも可能なので、その設定をしていきます。


```js:jest.config.js
module.exports = {
    "preset": "jest-puppeteer",
};
```

`# Add user so we don't need --no-sandbox.`というコメントがどこかにあった気がしますが、気にせず`--no-sandbox`や`/dev/shm`の容量問題に対処するための引数を追加します。（雑）

```js:jest-puppeteer.config.js
module.exports = {
    launch: {
        executablePath: '/usr/bin/chromium-browser',
        args: [
          '--no-sandbox',
          '--disable-setuid-sandbox',
          '--disable-dev-shm-usage'
        ],
    },
};
```

これでJest+Puppeteerの設定が終わりました。


### テストシナリオの作成

テストシナリオ１、２を作成していきます。`document.querySelector` という回りくどい感じの画面遷移方法になっていますが、`page.click()`だと上手く画面遷移ができなかったので、このような方法で書いています。（検証だし、動けばいいや）

```js:login.test.js
const target_url = process.env.TARGET_URL;

describe('Wordpress Test Cases', () => {
  beforeAll(async () => {
    await page.goto(target_url);
  });

  test('It appears "Log In" in Log-in title.', async () => {
    expect(await page.title()).toMatch("Log In");
  });

  test('Admin Log-in.', async () => {
    await page.type('input[name="log"]', 'admin');
    await page.type('input[name="pwd"]', 'changeme');
    await page.evaluate(() => {
      document.querySelector('input[type="submit"]').click();
    });
    await page.waitForNavigation({
      waitUntil: 'domcontentloaded'
    });
    expect(await page.title()).toMatch("Dashboard");
  });
});


```

`tests`フォルダを新たに作成して

 - `jest.config.js`
 - `jest-puppeteer.config.js`
 - `login.test.js`

以上、３つのファイルを配置してください。

ね、簡単でしょ？　

## Docker Composeで構成されたWebアプリケーションのテスト

テストするための環境がやっと整ったので、以下のそれぞれについて比較検証をしていきます。
（全然簡単じゃなかった……）

1. **アイデアが閃く派**「Compose世界の中に、テスト用のコンテナをサービスとして追加すれば？」
2. **現実は非情だよ派**「E2E[^2]テストはユーザと同じ環境でやらないと意味がないから、外部からやるしかない」


### アイデアが閃く派の検証

いわゆる、一般的なComposeのサービスとしてテスト用のコンテナを追加する書き方です。
つまり、コンテナネットワーク内からの単純なテスト実行になります。

```yaml:test-from-container-nw.yml
version: "3.3"

services:
  test:
    hostname: test
    image: puppetester:latest
    working_dir: /app/browser
    volumes:
      - ./tests:/app/browser
    environment:
      - TARGET_URL=http://wordpress:80/wp-login.php
```
テスト実行コマンドは以下の通りです。

```bash
$ docker-compose up -d
$ docker-compose -f docker-compose.yml -f test-from-container-nw.yml run --user 'pptruser' --rm test sh -c 'dockerize -wait ${TARGET_URL} /app/node_modules/.bin/jest'
$ docker-compose stop
```


### 現実は非情だよ派の検証
ホストの`http://localhost:8000/wp-login.php`としてテストしたいのですが、当然このままではコンテナの中から疎通できません。

解決方法としては二つ考えられます。
- Nestedな環境で(いわゆるDocker in Docker)で検証する
- ネットワークモードにホストを指定する

今回は、検証の容易さを考えて、後者（ホストのネットワークスタックをコンテナ内で利用）を選択しました。
その場合、テスト用Composeファイルは以下のような構成になります。

```yaml:test-from-host-nw.yml
version: "3.3"

services:
  test:
    hostname: test
    image: puppetester:latest
    working_dir: /app/browser
    volumes:
      - ./tests:/app/browser
    environment:
      - TARGET_URL=http://localhost:8000/wp-login.php
    network_mode: "host"
```
テスト実行コマンドは以下の通りです。

```bash
$ docker-compose up -d
$ docker-compose -f test-from-host-nw.yml run --user 'pptruser' --rm test sh -c 'dockerize -wait ${TARGET_URL} /app/node_modules/.bin/jest'
$ docker-compose stopdp
```

### テスト結果の比較検証
コンテナネットワーク内からの単純なテスト（アイデアが閃く派）と、ホストのネットワークスタックを利用した場合のテスト（現実は非常だよ派）の実行結果をそれぞれ比較します。

**コンテナネットワーク内からの単純なテスト結果(1 failed, 1 passed)**

```
 FAIL  ./login.test.js
  Wordpress Test Cases
    ✓ It appears "Log In" in Log-in title. (13ms)
    ✕ Admin Log-in. (62ms)

  ● Wordpress Test Cases › Admin Log-in.

    expect(received).toMatch(expected)

    Expected value to match:
      "Dashboard"
    Received:
      ""

      21 |       waitUntil: 'domcontentloaded'
      22 |     });
    > 23 |     expect(await page.title()).toMatch("Dashboard");
         |                                ^
      24 |   });
      25 | });
      26 |

      at Object.toMatch (login.test.js:23:32)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        0.733s
Ran all test suites.
```

**ホストのネットワークスタック利用した場合のテスト結果（2 passed）**

```
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.602s
Ran all test suites.
```

コンテナネットワーク内からの単純なテストでは、テストシナリオ2（Adminログイン）が失敗しています。これはWebアプリケーションが、セキュリティ上の問題から、ドメイン/オリジンに応じてアクセスできるページ等を制限している場合があるからです。

今回はホスト側(`http://localhost:8000/wp-login.php`)から初期設定を行ったこともあり、利用可能なアドレスがデフォルトで`http://localhost:8000`に制限されていました。

![WordPressの実際の設定.png](https://qiita-image-store.s3.amazonaws.com/0/217024/36ce2e71-114a-0ea1-6c5e-8ab30cddec7d.png)
[図5:　WordPressの実際の設定]

実際、`await page.screenshot({path: 'result.png'})`のようなコードをテストに挿入してブラウザ画面のスクリーンショットをとると、`http://wordpress/wp-login.php`からのAdminログイン試行では**真っ白な画面**が表示されることが分かります。

結論として、今回のようなWebサービスのテストに関しては、**ホストのネットワークスタックを利用したテストコンテナの接続**をオススメします。少なくとも、コンテナネットワーク内からの単純なテストよりも、こちらの方が実環境に近い状態でテストできていることが比較検証結果から分かりました。

（逆にいうと、「コンテナネットワーク内でテストを完結したい！　テスト用コンテナをサービスとして追加したい！」と考えている場合は、オリジンの問題を常に頭に入れながら作業する必要があります）

## まとめ

- コンテナネットワーク内でテストを完結したいと思っていても、テスト用コンテナをサービスとして追加する方法は思わぬ落とし穴にハマることがある
- CI環境のホストが占有できるのであれば、ブラウザテスト等のテスト用コンテナの実行には`network_mode: "host"`が便利
- kubernetesだと、この辺りがingressで実は良い感じに解決されている

え、kubernetes？　あ、はい使ったことありますよ。運用や人件費を考えたら、とりあえずはGKE……？？？　え？？？　何？？？？　あ、オンプレ？？？　え、いきなり？？？　オンプレk8s基盤をVMware製品のノリで僕が（ここで筆が途切れている）

## 【番外編】Makefileを使ってコマンドをまとめる

こんな感じでまとめると、ローカルやJenkinsでのテスト実行が非常に楽になります。

```bash:build.sh
cd $(dirname $0)
docker build -t puppetester .
```

```makefile:Makefile
all: build test run
build:
	./puppetester/build.sh
	docker-compose build
clean:
	docker-compose rm -f -s
test:
	docker-compose up -d
	docker-compose -f test-from-host-nw.yml run --user 'pptruser' --rm test sh -c 'dockerize -wait $${TARGET_URL} /app/node_modules/.bin/jest'
	docker-compose stop # 止める必要がない場合は止めなくても良い
run:
	docker-compose up
stop:
	docker-compose stop
```

### (例)テストの実行

```bash
$ make test
```

## 今回の検証コードについて

手元で試してみたい場合は、こちらをご参照ください。
https://github.com/nitky/test-compose/

## 参考リンク
- https://blog.pusher.com/full-stack-testing-docker-compose/
- https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md



[^1]: 実際は「仲間がきて助けてくれる」だった。ネ申。
[^2]: ここでは便宜上E2Eと言っていますが、Webブラウザでざっくり処理を試すくらいの理解で大丈夫です。