Webアプリのエンドツーエンドテストを手軽に行う方法
半信半疑になるのも無理はありません。これまで「手軽にできるWebアプリのテスト」をうたうガイドの多くは、蓋を開けてみれば特定の技術スタック専用だったり、有料のサードパーティサービスが必須だったりしました。でも、このガイドではそんなことはしません。
本ガイドでは、ほぼあらゆるWebアプリに適用できる、シンプルで柔軟なエンドツーエンドテストのテンプレートを紹介します。唯一の条件は、アプリがDockerで実行できることです。
本当にそれだけです。Rubyアプリでも、Reactアプリでも、Enterprise JavaBeansアプリでも、あるいは自作の風変わりなWebスタックでも構いません。開発環境がWindowsでもLinuxでもMacでも関係ありません。しかも、複雑な設定やDocker以外のソフトウェアのインストールも必要ありません。
このチュートリアルでは無料のオープンソースツールだけを使い、どこかにアカウント登録をする必要もありません。CircleやTravisのような継続的インテグレーション環境でテストを実行するときも、特別な作業は不要です。開発マシンで使っているのと同じワンライナーで実行できます。
主役はCypress
追記(2022-10-25): 現在、WebアプリケーションのエンドツーエンドテストにCypressは推奨していません。新規プロジェクトでは代わりにPlaywrightを使うことをおすすめします。
このテストを可能にする立役者がCypressです。ブラウザ自動化の分野に比較的最近登場した、オープンソースのエンドツーエンドテストフレームワークで、専任チームが活発に開発を続けています。ビジネスモデルはDockerと似ており、どちらも無料のオープンソースツールを提供し、そのマネージドサービスを販売することで開発資金を得ています。

CypressはWebアプリの自動テストのためのオープンソースツールです。
私が初めてCypressを知ったのは昨年、Gleb Bahmutov氏が地域のソフトウェアカンファレンスでデモを行っているのを見たときでした。CypressがSeleniumにまったく依存していないと聞き、興味をひかれました。これまで経験したエンドツーエンドテストはいつも散々なものでしたが、その原因は常にSeleniumにありました。

Seleniumは最も歴史が長く普及しているブラウザ自動化ツールですが、扱いにくく時代遅れでもあります。
Seleniumは圧倒的に最も人気のあるブラウザ自動化フレームワークですが、15年前に設計されたJavaベースのツールにありがちな問題をすべて抱えています。インストールは面倒で、構文はぎこちなく、テストが失敗したときの手がかりもほとんど得られません。Gleb氏の洗練されたCypressのデモでは、こうした悩みがすべて解消されるように見えました。

Cypressの気の利いた機能のひとつが、テストの各ステップでブラウザの様子を録画し、失敗の診断に役立てられることです。
早速Cypressのドキュメントを読んでみましたが、ほぼすべてのドキュメントがユーザーはNode.jsスタックを使い、ヘッドレスなコンソールではなくグラフィカルな環境で開発していることを前提にしていて、がっかりしました。
それでもCypressには将来性を感じました。1年後に進捗を確認してみると、CypressとDocker Composeを組み合わせた新しいサンプルアプリケーションが公開されていました。そこで一気に合点がいきました。Docker Compose上でCypressが動くのを見て、あらゆるWebアプリにこのパターンを応用する方法がはっきりしたのです。今回は、そのパターンとアプリへの適用方法を紹介します。
再利用可能なエンドツーエンドテストのパターン
CypressとDocker Composeを組み合わせることで、ほぼあらゆるWebアプリに適用できる柔軟なテストパターンが生まれます。アプリの実装について前提を置く他のテストツールとは異なり、この手法ではテストフレームワークとテスト対象のアプリを完全に分離できます。

Docker Compose、Cypress、Webアプリの関係
Docker Composeを使えば、Cypressをひとつのコンテナで、アプリを別のコンテナで実行できます。アプリ側はCypressのことを何も知る必要がなく、Cypressがアプリについて知っておく必要があるのはHTTPリクエストを送るポート番号だけです。
テスト対象のシンプルなWebアプリ
テスト対象のサンプルとして、世界一単純なテキスト感情分析アプリ「Sentimentalyzer」を紹介します。ユーザーの文章から気分を推測するアプリです。
たとえばIt's a nice day todayと入力すると、Sentimentalyzerはあなたが幸せだと判断します。


幸せなテキストを分析するSentimentalyzer
Who ate ALL MY WAFFLES?と入力すると、怒っていると判断します。


怒りのテキストを分析するSentimentalyzer
アルゴリズムは単純です。文字の50%以上が大文字なら、ユーザーは叫んでいる、つまり怒っていると判断します。そうでなければ、気分は落ち着いているとみなします。
プロジェクト構成
サンプルプロジェクトのファイル構成は次のとおりです。
main.go <- source for my web app, Sentimentalyzer
Dockerfile <- defines how to run Sentimentalyzer in a Docker container
e2e/ <- folder that contains all the files for my end-to-end tests
cypress.json <- Cypress configuration
docker-compose.yml <- glue that binds together my app container with the Cypress container
integration/
spec.js <- defines the end-to-end test for Sentimentalyzer
本番ロジックはすべてルートフォルダに、エンドツーエンドテストのコードはすべてe2eフォルダにまとまっています。
Sentimentalyzerをローカルで実行する
ここではあえてアプリのソースコードは載せません。Cypressのテストはアプリの実装をまったく見ずに書けることを強調したいからです。SentimentalyzerはたまたまGoで作られていますが、PythonやAngularで実装していてもテストは同じです。興味があれば、ソースコードはGitHubで公開しています。
手元でSentimentalyzerを試すには、次のコマンドを実行してください。
git clone https://github.com/mtlynch/hello-world-cypress.git
cd hello-world-cypress
docker build --tag sentimentalyzer .
docker run \
--interactive \
--tty \
--env PORT=8123 \
--publish 8123:8123 \
sentimentalyzer
上記のコマンドで、ローカルマシンのhttp://localhost:8123にSentimentalyzerのサーバーが起動します。
アプリをDockerコンテナで実行できるようになったので、Cypressでエンドツーエンドテストを作る準備が整いました。
エンドツーエンドテストを作成する
最初のCypressエンドツーエンドテストを書くのに必要なファイルは3つだけです。
cypress.jsondocker-compose.ymlintegration/spec.js
cypress.json
このファイルでは、Cypressの設定オプションを指定します。
{
"pluginsFile": false,
"supportFile": false
}
これらの設定自体に深い意味はありません。Cypressが不要なヘルパーファイルを自動生成しないよう、falseに設定しているだけです。
docker-compose.yml
このファイルでは、Sentimentalyzer用とCypress用のDockerコンテナを定義し、両者が通信できるようにします。
version: "3.2"
services:
sentimentalyzer:
build: ../
environment:
- PORT=8123
cypress:
image: "cypress/included:4.4.0"
depends_on:
- sentimentalyzer
environment:
- CYPRESS_baseUrl=http://sentimentalyzer:8123
working_dir: /e2e
volumes:
- ./:/e2e
特に注目すべき行をいくつか解説します。
image: "cypress/included:4.4.0"
cypress/includedは、CypressがあらかじめインストールされたCypress Dockerイメージのファミリーです。cypress/baseやcypress/browsersといった他のファミリーは、実行時にクライアント側でCypressをインストールすることを前提としています。cypress/includedイメージを使うことで、コンテナ起動と同時にCypressがテストを実行するようになります。
depends_on:
- sentimentalyzer
depends_onを指定することで、Cypressがテストを実行する前にSentimentalyzerが起動していることが保証されます。
environment:
- CYPRESS_baseUrl=http://sentimentalyzer:8123
環境変数CYPRESS_baseUrlは、CypressがSentimentalyzerにアクセスするためのURLを指定します。CypressとSentimentalyzerは同じDocker Compose構成で動いているため、Cypressはコンテナ名(sentimentalyzer)をホスト名として使ってリクエストを送ることができます。
working_dir: /e2e
volumes:
- ./:/e2e
最後に、Dockerのボリュームマウント機能を使って、CypressのDockerコンテナがホストマシンのファイルシステムの一部を共有するようにしています。
ホストマシンの./e2eディレクトリの中身は、Dockerコンテナ内の/e2eというパスにそのまま現れます。これにより、Cypressが実行中に書き出すログやスクリーンショット、動画が、コンテナから手動でコピーしなくてもホストマシンですぐに利用できます。このようにホストのボリュームをバインドしておくと、Dockerイメージ全体を再ビルドしなくてもテストを編集してすぐに再実行できます。
working_dirの行は、Cypressがファイルシステム上で/e2eディレクトリをカレントディレクトリとして扱うようにするためのものです。
integration/spec.js
設定の話はこれで終わりです。いよいよ楽しい部分、テストを書いていきましょう。
it("detects angry sentiment", () => {
cy.visit("/analyze");
cy.get("#feelings").type("I REALLY need some COFFEE");
cy.get("form").submit();
cy.get(".results p").should("contain", "You are feeling: Angry");
});
it("detects content sentiment", () => {
cy.visit("/analyze");
cy.get("#feelings").type("I think coffee in the morning is just swell!");
cy.get("form").submit();
cy.get(".results p").should("contain", "You are feeling: Content");
});
Cypress APIに馴染みがなくても、その意味は直感的に理解できるはずです。平たく言えば、どちらのテストも次の流れで進みます。
- ブラウザでSentimentalyzer Webアプリの
/analyzeパスに移動する。 - テキストフィールドを探す。
- テキストを入力する。
- フォームを送信する。
- 結果を読み取る。
最初のテストを1行ずつ見ていきましょう。
cy.visit("/analyze");
この行は、CypressにSentimentalyzerの/analyzeパスをブラウザで読み込むよう指示しています。Cypressは先ほどのdocker-compose.ymlで定義した環境変数CYPRESS_baseUrlと組み合わせるため、完全なURLはhttp://sentimentalyzer:8123/analyzeになります。このURLは開発マシンからはアクセスできませんが、Cypressコンテナ内では有効なアドレスです。
cy.get("#feelings").type("I REALLY need some COFFEE");
次に、Cypressにテキストフィールドを探させます。フィールドにはfeelingsという一意のIDが付いているので、CSSセレクタの構文で#feelingsと指定すれば簡単に取得できます。

type()関数は、指定したフィールドにテキストを入力するようCypressに指示します。
次に、Cypressはフォームを送信する必要があります。Cypressにはこのよくある操作のためにsubmit()関数が用意されています。ページには<form>要素がひとつしかないので、CSSセレクタformで取得して送信するのは簡単です。
cy.get("form").submit();
フォームを送信すると、Sentimentalyzerの結果ページに遷移するはずです。Cypressは"You are feeling: Angry"というテキストを確認する必要がありますが、今回はそれを含む<p>タグにID属性がないため、少し工夫が必要です。

ここでもCSSセレクタの構文を使い、クラスが"results"のDOMノード配下にある<p>要素を指定して該当のテキストを探します。
cy.get(".results p").should("contain", "You are feeling: Angry");
containアサーションは、<p>タグが期待どおりのテキストを含んでいるかを検証します。
テストを実行する
準備が整ったので、Cypressを実際に動かしてみましょう。テストは次のシンプルなコマンドで実行できます。
cd e2e
docker-compose up --exit-code-from cypress
--exit-code-from cypressフラグは、Docker ComposeにCypressコンテナの終了コードをdocker-composeコマンド全体の終了コードとして使うよう指示します。つまり、テストが成功すれば終了コードは0、失敗すれば0以外になります。この挙動は、コマンドの終了コードで成功・失敗を判定するビルドスクリプトや継続的インテグレーションの設定で便利です。
コンソールでの実行の様子は次のとおりです。
Cypressはテスト実行のたびに動画を録画します。失敗の診断に大いに役立つ、私のお気に入りの機能です。
エンドツーエンドテストのCypressによる録画(1/4倍速)
テスト失敗時のスクリーンショット
上では成功するテストを紹介しました。Cypressのテストが失敗するとどうなるでしょうか。失敗時もテスト実行の動画は生成されますが、加えてアサーションが失敗した箇所を示すスクリーンショットも出力されます。

テスト失敗時にCypressが生成したスクリーンショット(Cypressは「Furious」を期待しましたが、実際は「Angry」でした)
これは私が他のツールで感じていた大きな悩みを解決してくれます。Seleniumでもスクリーンショットは取得できますが、アサーションの前か後でしか撮れません。そのため、Seleniumがテスト失敗を報告しているのに、スクリーンショットではテスト失敗後にブラウザの状態が変わってしまい、正しく表示されているように見える、といったやっかいな状況が起きていました。
Cypressではスクリーンショットがアサーションと同時に撮られるため、この問題が起きません。テストが失敗した場合、スクリーンショットには失敗の瞬間にCypressが実際に見ていたものが正確に映ります。
自分のWebアプリに応用する
自分のWebアプリでエンドツーエンドテストを始めるのに必要なのは、この3つのファイルだけです。手順は次のとおりです。
- e2eフォルダを自分のプロジェクトにコピーします。
docker-compose.ymlのsentimentalyzerセクションを、自分のアプリ用のDockerコンテナに置き換えます。- アプリのUIフローに合わせて
integration/spec.jsを書き換えます。
ソースコードと追加サンプル
このデモの完全なソースコードはGitHubで公開しています。
また、よくあるCypressのユースケースを示すブランチもいくつか用意しています。
さらに学ぶために
本ガイドではCypressの基本的な使い方を紹介しました。より高度な機能については、公式ドキュメントをご覧ください。
追記(2019-05-02): 本記事への反響を受け、CypressチームがCypressをプリインストールした公式Dockerイメージを公開しました。本チュートリアルは新しいイメージを使うように更新しています。イメージの詳細やCypressとDockerを併用するコツについては、Cypressのブログ記事もご覧ください。
イラストはLoraine Yow氏によるものです。この記事に早い段階でフィードバックをくれたCypressチームのGleb Bahmutov氏に感謝します。
記事をランダムに読む