Edited at

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に乗り換えを検討している人は使ってみてはどうか。