Selenium
Elixir
docker
Phoenix

Phoenix + Headless ChromeでE2Eテスト

docker-seleniumを使ってDocker環境でPhoenix + Headless ChromeでE2Eテストをする

Installation

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

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を使う

mix.exs
{:hound, "~> 1.0"}
$ mix deps.get

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

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

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

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

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

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

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

テスト実行

$ 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テストが出来た。

補足

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

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

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

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

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

方法1: dockerのネットワークモードをhostネットワークにし、admin.localhostにアクセスさせる
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コンテナからアクセス出来る必要がある

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は以下のような形になる

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ネットワークに所属させる
networks:
  - app
  • chromeコンテナのextra_hosts で追加のホストをhostsファイルに書き込む
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が正式に開発中止したこともあるのでHeadless Chromeに乗り換えを検討している人は使ってみてはどうか。