こんにちは。ユーザベース Product Team の old_horizon です。
早速ですが明日 8/5 (木) 19:30 から、Qiita さんと合同で Qiita x Uzabase Tech Meetup #3 を開催します!ぜひ皆様ご参加ください。
今回は登壇するチームメンバーと日々取り組んでいる E2E テストにおける改善についてご紹介します。
- 開発チームにおける E2E のこれまで
- ファイルダウンロードを行う E2E テストの課題
- Selenium Grid でファイルダウンロードを行う E2E を実行する
- Zalenium に拡張を組み込む
開発チームにおける E2E のこれまで
B2B SaaS 事業のプロダクト開発を担当する私達のチームは、E2E テストも含む TDD を徹底しているのが特徴です。特に Web アプリケーションについては Selenium による自動テストを行っています。
プロダクトの成長に伴いマイクロサービスが増え続ける中で、各サービスのテストを高速かつ安定的に実行できる環境が必要になりました。
従来は E2E 実行環境 (Jenkins など) で Selenium を含む Docker イメージを立ち上げて実行していましたが、現在は Zalenium を使って Kubernetes 上に構築した Selenium Grid を利用しています。
これにより Selenium 環境のスケールが容易になり、マシンパワーを要する E2E の並列実行も容易に実施できるようになりました。このように多くの恩恵を受けましたが、一点だけこれまでの方法で実現できていたことが難しくなりました。それはファイルダウンロードを行う E2E テストです。
ファイルダウンロードを行う E2E テストの課題
公式ドキュメントに記載があるように、Selenium にファイルダウンロードに関するサポートはありません。
しかしながら私達のプロダクトは Word や Excel 形式によるデータのエクスポート機能が充実しています。これらに対して E2E テストを実施しないわけにはいきません。そのため従来は、次のように検証していました。
- E2E 実行に必要なツール類 (Maven, Gauge 等) とブラウザ、WebDriver を含む Docker イメージを作成
- 上記の Docker イメージからコンテナを起動して E2E を実行
- E2E ではブラウザを操作してから、ダウンロード先ディレクトリの中身を検証
つまりE2E 実行コンテナ内でブラウザを起動することで、ダウンロードされたファイルもコンテナ内に保存される仕組みになっていました。
しかし Selenium Grid の導入により、E2E 実行コンテナとブラウザの稼働環境が別々になりました。したがってブラウザでダウンロードしたファイルは E2E 実行コンテナではなく、ブラウザの稼働環境側に保存されます。Selenium には E2E 実行コンテナからブラウザ稼働環境にダウンロードされたファイルを取得する手段がないため、この類のテストをパスさせることができません。
Selenium Grid でファイルダウンロードを行う E2E を実行する
この課題を解決するため、Selenium Grid に ブラウザでダウンロードしたファイルを取得するエンドポイントを追加しました。
成果物はこちらからご覧ください。このリポジトリを使って解説を進めていきます。
https://github.com/old-horizon/selenium-grid-file-download-plugin
動作確認は以下の環境で行いました。
- Ubuntu 18.04
- JDK 11
- Apache Maven 3.6.3
- Groovy 3.0.4
- Docker 20.10.7
- Gauge 1.3.3
Selenium Grid の拡張方法
Selenium Grid 3 は拡張機能を備えており、独自のサーブレットを Hub と Node に組み込むことができます。
この仕組みを活用して、Node (ブラウザの稼働環境) にダウンロードされたファイルを取得するエンドポイントを追加しました。
実装方針
Selenium Grid は Hub が WebDriver のリクエストを受け付けると、配下にある Node を一つ選択してブラウザをそこで実行します。また各セッションと Node の対応関係は Hub だけが知る設計になっています。
この前提を踏まえて、次の流れで Node にダウンロードされたファイルを取得することにします。
- E2E テストでは WebDriver からセッション ID を取得し、HTTP クライアントで Hub に追加したエンドポイントにセッション ID とファイル名を含むリクエストを送る
- Hub はセッション ID から対応する Node を特定し、Node に追加したエンドポイントにファイル名を含むリクエストを送る
- Node がファイルの内容を返す
- Hub は Node から返された内容を、最初のリクエストを送った E2E テストの HTTP クライアントに返す
上記の太字で示した部分が、独自サーブレットで実装する対象になります。
エンドポイントの設計
E2E テストからアクセスする、Hub 側のエンドポイントは下記の通りです。
- GET /grid/admin/Downloads/{sessionId}
ダウンロードディレクトリのファイル一覧を JSON で返す - DELETE /grid/admin/Downloads/{sessionId}
ダウンロードディレクトリのファイルをすべて削除する - GET /grid/admin/Downloads/{sessionId}/{fileName}
ダウンロードディレクトリの指定されたファイルの中身を返す - DELETE /grid/admin/Downloads/{sessionId}/{fileName}
ダウンロードディレクトリの指定されたファイルを削除する
独自サーブレットの実装
Hub と Node それぞれに追加するサーブレットをビルドする Maven プロジェクトはこちらです。
Hub 側のサーブレットは org.openqa.grid.web.servlet.RegistryBasedServlet
を継承しています。この getRegistry
メソッドが返す Hub の内部状態を参照できるオブジェクトを使って、セッション ID から Node を特定しています。
動作確認
まずはローカルに Hub と Node を両方立てて動作確認をしてみます。
次の事前準備を済ませておきましょう。
- リポジトリ直下で
mvn package
を実行してビルド - Selenium 公式から、Selenium 3 の最終版 Selenium Server (Grid) をダウンロード
(selenium-server-standalone-3.141.59.jar) - ChromeDriver をダウンロード
ダウンロードディレクトリの準備
ローカルのダウンロードディレクトリを一度空にして、動作確認に必要なファイルを準備します。
$ echo 'first' > ~/Downloads/file1.txt $ echo 'second' > ~/Downloads/file2.txt $ echo 'third' > ~/Downloads/file3.txt
Hub と Node の起動
それぞれ下記のようなコマンドで起動します。
Hub
java -cp /home/user/sources/selenium-grid-file-download-plugin/hub/hub-rest/target/hub-rest-1.0.0-jar-with-dependencies.jar:selenium-server-standalone-3.141.59.jar org.openqa.grid.selenium.GridLauncherV3 -role hub -servlet com.github.old_horizon.selenium.hub.Downloads
Node
java -Dwebdriver.chrome.driver=/home/user/opt/chromedriver -cp /home/user/sources/selenium-grid-file-download-plugin/node/node-rest/target/node-rest-1.0.0-jar-with-dependencies.jar:selenium-server-standalone-3.141.59.jar org.openqa.grid.selenium.GridLauncherV3 -role node -hub http://localhost:4444/grid/register -servlet com.github.old_horizon.selenium.node.Downloads
いずれのモードの場合も、次の手順で独自サーブレットを組み込んで起動できることがわかります。
- クラスパスに独自サーブレットを含む jar ファイルを追加
- メインクラスに
org.openqa.grid.selenium.GridLauncherV3
を指定 - コマンドライン引数に
-servlet (独自サーブレットの FQCN)
を追加
独自サーブレットは下記のルールで URL パターンにマッピングされます。
- Hub: /grid/admin/(独自サーブレットのクラス名)
- Node: /extra/(独自サーブレットのクラス名)
エンドポイントにリクエストを送る
エンドポイントにリクエストを送る際には Selenium のセッション ID が必要になります。
そのため下記の Groovy スクリプトを実行してセッションを作成します。
@Grab(group='com.codeborne', module='selenide', version='5.23.1') import java.util.concurrent.TimeUnit import com.codeborne.selenide.Configuration import static com.codeborne.selenide.Selenide.open Configuration.remote = 'http://localhost:4444/wd/hub' open('https://tech.uzabase.com/') TimeUnit.HOURS.sleep(1) // 動作確認が済んだら Ctrl-C で抜ける
Chrome 起動後に Node のログを確認することで、セッション ID が取得できます。
このセッション ID を使って各エンドポイントの動作確認をしていきます。
12:42:56.874 INFO [RemoteSession$Factory.lambda$performHandshake$0] - Started new session 6ca4c682a78a829b65fa9c642ebec706 (org.openqa.selenium.chrome.ChromeDriverService)
GET /grid/admin/Downloads/{sessionId}
先ほど用意したファイルの一覧が返されました。
$ curl -v http://localhost:4444/grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706 * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 4444 (#0) > GET /grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706 HTTP/1.1 > Host: localhost:4444 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 200 OK < Date: Wed, 04 Aug 2021 03:44:22 GMT < Content-Type: application/json < Content-Length: 137 < Server: Jetty(9.4.z-SNAPSHOT) < { "files": [ { "name": "file1.txt" }, { "name": "file3.txt" }, { "name": "file2.txt" } ] } * Connection #0 to host localhost left intact
GET /grid/admin/Downloads/{sessionId}/{fileName}
files1.txt の中身が返されました。
$ curl -v http://localhost:4444/grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706/file1.txt * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 4444 (#0) > GET /grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706/file1.txt HTTP/1.1 > Host: localhost:4444 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 200 OK < Date: Wed, 04 Aug 2021 03:48:46 GMT < Content-Length: 6 < Server: Jetty(9.4.z-SNAPSHOT) < first * Connection #0 to host localhost left intact
DELETE /grid/admin/Downloads/{sessionId}/{fileName}
$ curl -XDELETE -v http://localhost:4444/grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706/file1.txt * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 4444 (#0) > DELETE /grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706/file1.txt HTTP/1.1 > Host: localhost:4444 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 200 OK < Date: Wed, 04 Aug 2021 03:49:46 GMT < Content-Length: 0 < Server: Jetty(9.4.z-SNAPSHOT) < * Connection #0 to host localhost left intact
DELETE 実行後に取得したファイル一覧には、削除した file1.txt が存在しません。
ファイルシステム上も当該ファイルが存在しないことを確認しました。
$ curl -v http://localhost:4444/grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706 * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 4444 (#0) > GET /grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706 HTTP/1.1 > Host: localhost:4444 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 200 OK < Date: Wed, 04 Aug 2021 03:50:59 GMT < Content-Type: application/json < Content-Length: 98 < Server: Jetty(9.4.z-SNAPSHOT) < { "files": [ { "name": "file3.txt" }, { "name": "file2.txt" } ] } * Connection #0 to host localhost left intact
DELETE /grid/admin/Downloads/{sessionId}
$ curl -XDELETE -v http://localhost:4444/grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706 * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 4444 (#0) > DELETE /grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706 HTTP/1.1 > Host: localhost:4444 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 200 OK < Date: Wed, 04 Aug 2021 03:51:54 GMT < Content-Length: 0 < Server: Jetty(9.4.z-SNAPSHOT) < * Connection #0 to host localhost left intact
DELETE 実行後に取得したファイル一覧には、一つもファイルが存在しません。
ファイルシステム上もダウンロードディレクトリが空であることを確認しました。
$ curl -v http://localhost:4444/grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706 * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 4444 (#0) > GET /grid/admin/Downloads/6ca4c682a78a829b65fa9c642ebec706 HTTP/1.1 > Host: localhost:4444 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 200 OK < Date: Wed, 04 Aug 2021 03:51:56 GMT < Content-Type: application/json < Content-Length: 21 < Server: Jetty(9.4.z-SNAPSHOT) < { "files": [ ] } * Connection #0 to host localhost left intact
Zalenium に拡張を組み込む
動作確認ができたので、いよいよ Zalenium に拡張を組み込みます。
実は Zalenium のコアは Selenium Grid そのもので、独自機能はその拡張という形で実装されています。
Docker イメージに同梱されている起動スクリプトでも、メインクラスには Selenium Grid の org.openqa.grid.selenium.GridLauncherV3
が指定されていました。したがって Selenium Grid の起動オプション -servlet
も同様に機能します。
Docker イメージの作成
Zalenium は Hub と Node で別々の Docker イメージを使用するため、それぞれに対応する拡張を組み込んだイメージを作成します。
リポジトリの下記シェルスクリプトを実行してください。
E2E テストへの適用
リポジトリの examples/e2e に Gauge / Kotlin / Selenide による、ファイルダウンロードを行う E2E テストの例を用意しました。実行時に WireMock でスタブサーバーが起動し、ブラウザがそこにアクセスする仕組みです。
ブラウザの実行環境によってダウンロードされたファイルの取得方法が異なるため、DownloadDirectory インターフェースを用意しました。
その適切な実装を返すファクトリーメソッドを呼び出すことで、アサーションの段階ではブラウザの実行環境を意識せずに済む工夫をしています。
次節より E2E テストを実施するため、スタブサーバーが使用する 8080 ポートを空けておいてください。
なおポート番号、それに伴う Selenium の接続先変更は src/test/resources/uat.properties (以下 uat.properties) から行えます。
ローカルのブラウザを使用する場合
まずはローカルのブラウザを使用して E2E テストを実施してみます。
uat.properties の selenide.remote
から始まる行を # でコメントアウトしておきましょう。
この状態で examples/e2e
に移動して mvn test
を実行します。
するとローカルのブラウザが操作され、全件成功することが確認できます。
Zalenium 上のブラウザを使用する場合
いよいよ Zalenium 上のブラウザを使用して E2E テストを実施します。
まずは拡張を組み込んだ Zalenium を Docker で動作させるため、以下のコマンドを実行します。
docker run --rm -ti --name zalenium -p 4444:4444 -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/videos:/home/seluser/videos --privileged zalenium-file-download start --seleniumImageName docker-selenium-file-download
Zalenium 公式に記載されている通り --seleniumImageName
オプションで Node として使用する Docker イメージを指定できます。
Node 側にも拡張が入っている必要があるため、それを含む上記でビルドした Docker イメージを使用します。
次に、下記の通り uat.properties を書き換えます。
- selenide.baseurl: localhost を LAN 内の IP アドレスに書き換え
- Docker コンテナ側からホスト側を参照するため
hostname -I
などで確認するのが簡単です
- selenide.remote: 先頭につけた # を取り除いて有効化
また http://localhost:4444/grid/admin/live にアクセスして、Zalenium の Live Preview を開いておきましょう。ここで Node のブラウザの動作をリアルタイムに見ることができます。
改めて examples/e2e
に移動して mvn test
を実行します。
Zalenium の Live Preview から Node のブラウザが操作されている様子が確認できたのちに、全件成功するはずです。
おわりに
これからも私達の開発チームは E2E ファーストの TDD に取り組んでいきます。ビジネスの激しい変化に対応するために、多数のシステムや機能が今後も生まれていくことでしょう。それでも生産性を下げることなく迅速なリリースを継続していくために、自動テストの技術力も磨いていきたいと思います。
Qiita x Uzabase Tech Meetup #3 参加者募集!
ユーザベースの Product Team は エクストリームプログラミング (XP) を忠実に実践している組織です。今回ご紹介した TDD に限らず、ペアプロといった他のプラクティスにも強度高く取り組んでいます。
新しい技術にも果敢に挑戦していると自負しておりますので、ぜひ明日 8/5 (木) 19:30 のイベントで私達を知っていただきたいです。
オンライン開催にはなりますが、皆様のお越しをお待ちしております!