ファイルダウンロードを行う E2E テストを Selenium Grid / Zalenium で実施するための拡張を作る

こんにちは。ユーザベース Product Team の old_horizon です。

早速ですが明日 8/5 (木) 19:30 から、Qiita さんと合同で Qiita x Uzabase Tech Meetup #3 を開催します!ぜひ皆様ご参加ください。

今回は登壇するチームメンバーと日々取り組んでいる E2E テストにおける改善についてご紹介します。

開発チームにおける 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 にダウンロードされたファイルを取得することにします。

  1. E2E テストでは WebDriver からセッション ID を取得し、HTTP クライアントで Hub に追加したエンドポイントにセッション ID とファイル名を含むリクエストを送る
  2. Hub はセッション ID から対応する Node を特定し、Node に追加したエンドポイントにファイル名を含むリクエストを送る
  3. Node がファイルの内容を返す
  4. 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 のブラウザが操作されている様子が確認できたのちに、全件成功するはずです。

f:id:old_horizon:20210804155505g:plain
Zalenium が録画したビデオ

おわりに

これからも私達の開発チームは E2E ファーストの TDD に取り組んでいきます。ビジネスの激しい変化に対応するために、多数のシステムや機能が今後も生まれていくことでしょう。それでも生産性を下げることなく迅速なリリースを継続していくために、自動テストの技術力も磨いていきたいと思います。

Qiita x Uzabase Tech Meetup #3 参加者募集!

ユーザベースの Product Team は エクストリームプログラミング (XP) を忠実に実践している組織です。今回ご紹介した TDD に限らず、ペアプロといった他のプラクティスにも強度高く取り組んでいます。

新しい技術にも果敢に挑戦していると自負しておりますので、ぜひ明日 8/5 (木) 19:30 のイベントで私達を知っていただきたいです。
オンライン開催にはなりますが、皆様のお越しをお待ちしております!

increments.connpass.com

© Uzabase, Inc. All rights reserved.