Phoenix + Headless ChromeでE2Eテスト

[docker-selenium](https://github.com/SeleniumHQ/docker-selenium)を使ってDocker環境でPhoenix + Headless ChromeでE2Eテストをする

# Installation

公式のドキュメントに従いdocker-seleniumのイメージをdocker-compose.ymlに追加する


```yaml:docker-compose.yml
  app:
    image: yourimage

# 〜中略〜
# 以下を追加
  selenium-hub:
    image: selenium/hub:3.14.0-europium
    container_name: selenium-hub
    ports:
      - "4444:4444"
  chrome:
    image: selenium/node-chrome:3.14.0-europium
    depends_on:
      - selenium-hub
    environment:
      - HUB_HOST=selenium-hub
      - HUB_PORT=4444
```

Elixir側は[hound](https://github.com/HashNuke/hound)を使う

```elixir:mix.exs
{:hound, "~> 1.0"}
```

```bash
$ mix deps.get
```

houndのドキュメントに従いセットアップ

```elixir:config/config.exs
config :hound,
  driver: "chrome_driver",
  host: "http://selenium-hub",
  port: 4444,
  path_prefix: "wd/hub/"
```

オプションの`:browser`を`chrome_headless`に指定すると逆にエラーになったのでこのままで

test_helper.exsで`Application.ensure_all_started(:hound)`でhoundを起動させておく

```elixir:test_helper.ex
Application.ensure_all_started(:hound)
ExUnit.start()
```

試しに以下のテストを追加してみる
websocket.orgのブラウザ対応チェックページにアクセスする

```elixir:page_controller_test.exs
defmodule MyAppWeb.PageControllerTest do
  use MyAppWeb.ConnCase
  use Hound.Helpers

  hound_session()

  @tag :chrome
  test "websocket", meta do
    navigate_to("https://websocket.org/echo.html")

    # スクショ撮影
    take_screenshot()
    
    # connectボタンを押下
    find_element(:id, "connect")
    |> click

    # 待ち
    :timer.sleep(2000)

    # id=consoleLogの要素から表示されているテキストを取得
    log =
      find_element(:id, "consoleLog")
      |> visible_text

    # CONNECTEDという文字列が含まれるかassert
    # 出力されていればWebsocketが有効
    assert log =~ "CONNECTED"

    # ページに以下の文字列が含まれるかassert
    assert page_source =~ "This browser supports WebSocket"

    # ページタイトルにwebsocketという文字列が含まれるかassert
    assert page_title() =~ "websocket"
  end
end
```

テスト実行

```elixir
$ mix test --only chrome
Including tags: [:chrome]
Excluding tags: [:test]
.

Finished in 6.7 seconds
39 tests, 0 failures, 38 skipped

Randomized with seed 987879

# スクショも取れてる
$ ls -al screenshot-2018-9-24-18-44-46.png
-rw-r--r--    1 root     root        104170 Sep 24 18:44 screenshot-2018-9-24-18-44-46.png
```

Headless ChromeでE2Eテストが出来た。

### 補足

#### サブドメイン有りのホスト名をテストしたい場合

ローカル環境でテストする場合はサブドメインが無ければそのまま`nginx`や`app`といった`services`で指定されているコンテナ名で名前解決出来るので以下のようにテスト出来るが

```elixir
test "websocket", meta do
  navigate_to("http://nginx/")
  assert page_source =~ "Hello"
end
```

`admin.example.com` のようにサブドメインによってPhoenixのRouteを振り分けるようなホスト名をテストしたい場合そのホスト名でアプリコンテナへ名前解決出来る必要がある

以下でその方法を検討する

##### 方法１： dockerのネットワークモードをhostネットワークにし、`admin.localhost`にアクセスさせる

```yaml
services:
  app: 
    # ~中略~ 
    network_mode: "host"
```

chromeは `*.localhost`というホスト名に対してはサブドメインに関わらず全てlocalhostへリクエストするのでhostネットワークを使うことでサブドメインを使用してアプリにアクセス出来る
ただホストネットワークモードはホストのネットワークリソースを共有するのでホストネットワークモードで動作に影響が出るようなアプリやコンテナが存在する場合テスト出来ず、また競合するポートがある場合コンテナを起動出来ない
経験的にホストネットワークは本番環境では大抵使わないし想定していないバグが出やすいのであまりおすすめは出来ない

##### 方法2: chrome用のコンテナでアプリコンテナの名前解決をする

実際にheadless chromeが走るのはchromeコンテナなのでchromeコンテナからアプリへのネットワーク到達性を確保する必要がある (もっと言えば前段のnginx。Cowboyに直接chromeからアクセスするとなぜかBad Requestになった)
例えば以下のようなdocker-compose.ymlが存在する場合nginxコンテナに`admin.example.com`のようなホスト名でchromeコンテナからアクセス出来る必要がある

```yaml
services:
  nginx: 
    image: nginx:latest
    ports:
      - 80:80
  app:
    image: your/image
    # ~中略~
  chrome:
    image: selenium/node-chrome:3.14.0-europium
    depends_on:
      - selenium-hub
    environment:
      - HUB_HOST=selenium-hub
      - HUB_PORT=4444
```

名前解決する方法はhostsファイルだけに限らずサービスディスカバリ用のコンテナを追加するなど色々あるが、個人的には余計なイメージを導入したり依存を追加せず、出来る限りデフォルト状態で対処したいので今回はhostsファイルに追加する方法を取る

なぜhostsファイルなのかというとnginx-proxyのようなイメージやサービスディスカバリはCI環境などでも同じように動作出来る保証がないからである。
CircleCIなどはCI時に依存するコンテナを立ち上げることが出来るがhostネットワークとして動作するのでhostネットワークモードを想定していないとまぁかなりめんどう

既存のイメージにipコマンド等ネットワーク系のコマンドが入っていればnginxコンテナのIPアドレスを名前解決し、それを`/etc/hosts`に書き込むという方法もあるがdocker-seleniumには残念ながら入っていなかったので今回はdocker networksを使う

最終的にはdocker-compose.ymlは以下のような形になる

```yaml
services:
  nginx:
    image: nginx:latest
    ports: "80:80"
    networks:
      - app
  app:
    image: your/image
    networks:
      - app
  chrome:
    image: selenium/node-chrome:3.14.0-europium
    depends_on:
      - selenium-hub
    environment:
      - HUB_HOST=selenium-hub
      - HUB_PORT=4444
    networks:
      - app
    extra_hosts:
      - "admin.example.com:172.30.0.1"
      - "example.com:172.30.0.1"
    
networks:
  app:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 172.30.0.0/16
        - gateway: 172.30.0.1
```

##### 解説

- `- gateway: 172.30.0.1` でこのサブネットのゲートウェイを設定している。このゲートウェイというのは実際はdockerホストとのインターフェイスなので172.30.0.1にアクセスする＝ホストにアクセスするということになる
- 各コンテナに所属するサブネットを`networks`で指定。全コンテナをappネットワークに所属させる

```yaml
networks:
  - app
```

- chromeコンテナの`extra_hosts` で追加のホストをhostsファイルに書き込む

```yaml
extra_hosts:
 - "admin.example.com:172.30.0.1"
 - "example.com:172.30.0.1"
```

これによりchromeコンテナは`http://admin.example.com/`をリクエストするとnginxコンテナにリクエストが飛ぶようになりE2Eが可能になる

この方法の利点は

- CIサービスなどでdocker-composeの構成をそのまま使えない場合でもchromeが名前解決さえ出来ればいいので大抵はhostsを書き換えるだけで済む
- テストコードを変更しないでも環境側だけで対応出来る
- 任意のホスト名を指定出来る

といったあたり。テストコードでホスト名を切り替えるのはテストコードが複雑になるので環境で吸収する方がいい。

ちなみにchromeは`.dev` TLDに対しては最近強制的にHTTPSへリダイレクトするようになってしまったので`.example`, `.test`といった予約済みのテスト用TLDを使うことをおすすめする

## まとめ

Docker環境上でSelenium (ChromeDriver) + Headless ChromeでPhoenixでE2Eテストをした。
自前でChromeDriverとChromeのバイナリを用意してメンテするのは結構面倒なので公式のイメージでサクっと出来るのはありがたかった。
またPhantomJSなどは対応していないWebsocketなどもChromeだと確認出来るのでPhoenix Channels周りのE2Eテストなどでも重宝しそう。

[PhantomJSが正式に開発中止](https://github.com/ariya/phantomjs/issues/15344)したこともあるのでHeadless Chromeに乗り換えを検討している人は使ってみてはどうか。