End-to-End Testing Web Apps: The Painless Way

Michael Lynch

WebアプリのE2Eテストを手軽に行う方法

原文は Michael Lynch により に公開されました。 このブログを購読する

わかります、あなたは半信半疑ですよね。これまでのガイドは手軽なWebアプリのテストを謳いながら、蓋を開けてみれば特定の技術スタック専用だったり、有料のサードパーティサービスが必須だったりしました。私はそんなことはしません。

このガイドでは、ほぼどんなWebアプリにも適用できる、シンプルで柔軟なE2Eテストのテンプレートを紹介します。唯一の要件は、アプリがDockerで実行できることです。

本当にそれだけです!Rubyアプリでも、Reactアプリでも、Enterprise Java Beansアプリでも、あるいはあなたが独自に作った風変わりなWebスタックでもテストできます。開発環境がWindowsでもLinuxでもMacでも関係ありません。何より、複雑な設定やDocker以外のソフトウェアのインストールは一切不要です。

このチュートリアルで使うのは無料のオープンソースツールだけで、どこかにアカウント登録する必要もありません。CircleやTravisのような継続的インテグレーション環境でテストを実行する際も、特別な対応は不要です。開発マシンで使うのと同じワンライナーでテストを実行できます。

主役のCypress

追記(2022-10-25): 私は現在、WebアプリケーションのE2EテストにCypressを推奨していません。新規プロジェクトでは、代わりにPlaywrightを使うことをおすすめします

このテストを可能にするのがCypressです。ブラウザ自動化の分野では比較的新しい存在で、オープンソースのE2Eテストフレームワークであり、専任チームが精力的に開発を続けています。ビジネスモデルはDockerと似ており、どちらも無料のオープンソースツールを提供しつつ、そのマネージドサービスを販売することで開発を支えています。

Cypressのロゴ

CypressはWebアプリの自動テストのためのオープンソースツールです。

私が初めてCypressを知ったのは昨年、地方のソフトウェアカンファレンスでGleb Bahmutov氏がデモをしているのを見たときでした。CypressがSeleniumに一切依存していないと聞いて、興味を惹かれました。これまでのE2Eテストの経験はどれもひどいもので、その苦労の元凶はいつもSeleniumでした。

Seleniumのロゴ

Seleniumは最も古く、最も広く使われているブラウザ自動化ツールですが、扱いにくく時代遅れです。

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

Cypressのロゴ

Cypressの優れた機能のひとつが、テストの各ステップでブラウザの動きを録画し、失敗の診断に役立てられることです。

早速Cypressのドキュメントを読んでみたのですが、ドキュメントのほとんどがユーザーがNode.jsスタックを使い、ヘッドレスコンソールではなくグラフィカルな環境で開発していることを前提にしていてがっかりしました。

それでもCypressには有望な未来を感じました。1年後に進捗を確かめてみると、CypressとDocker Composeを組み合わせた新しいサンプルアプリケーションが公開されていました。そこで一気に腑に落ちたのです。Docker Compose上で動くCypressを見たことで、そのパターンをどんなWebアプリにも応用する方法がはっきりしました。今回は、そのパターンとアプリへの適用方法を紹介します。

再利用可能なE2Eテストのパターン

CypressとDocker Composeを組み合わせることで、ほぼどんなWebアプリにも適用できる柔軟なテストパターンが実現します。アプリの実装について前提を置く他のテストツールとは異なり、この手法ではテストフレームワークとテスト対象のアプリを完全に分離できます。

Dockerコンテナアーキテクチャの図

Docker Compose、Cypress、Webアプリの関係

Docker Composeを使えば、Cypressをひとつのコンテナで、アプリを別のコンテナで実行できます。アプリ側はCypressについて何も知る必要がなく、Cypress側もアプリについて知っておくべきことはHTTPリクエストを送るネットワークポートだけです。

テスト対象のシンプルなWebアプリ

テスト対象のサンプルとして、Sentimentalyzerを紹介します。世界一おバカなテキスト感情分析アプリで、文章のサンプルからユーザーの気分を推測しようとします。

It's a nice day todayと入力すると、Sentimentalyzerはあなたが幸せだと判断します:

Sentimentalyzerでテキストを入力する様子Sentimentalyzerが結果を表示する様子

幸せなテキストを分析するSentimentalyzer

Who ate ALL MY WAFFLES?と入力すると、怒っていると判断します:

Sentimentalyzerでテキストを入力する様子Sentimentalyzerが結果を表示する様子

怒りのテキストを分析する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テストのコードはすべて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でE2Eテストを作る準備が整いました。

E2Eテストの作成

最初のCypress E2Eテストを書くのに必要なファイルは3つだけです:

  • cypress.json
  • docker-compose.yml
  • integration/spec.js

cypress.json

このファイルではCypressの設定オプションを指定します:

{
  "pluginsFile": false,
  "supportFile": false
}

cypress.jsonをダウンロード

これらの設定自体は特に面白いものではありませんが、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

docker-compose.ymlをダウンロード

いくつかの行について補足します:

image: "cypress/included:4.4.0"

cypress/includedは、CypressがあらかじめインストールされたCypress Dockerイメージのファミリーです。cypress/basecypress/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)をホスト名として使って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");
});

spec.jsをダウンロード

Cypress APIに馴染みがなくても、その意味は読み取りやすく、直感的に理解できるはずです。簡単に言えば、どちらのテストも次のような流れで進みます:

  1. ブラウザでSentimentalyzer Webアプリの/analyzeパスにアクセスする。
  2. テキストフィールドを探す。
  3. テキストを入力する。
  4. フォームを送信する。
  5. 結果を読み取る。

最初のテストを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で要素を指定すれば簡単です。

feelings要素のHTML idの確認

type()関数で、指定したフィールドにテキストを入力するようCypressに指示します。

次に、Cypressにフォームを送信させる必要があります。Cypressにはこのよくある操作のためのsubmit()関数が用意されています。ページには<form>要素が1つしかないので、CSSセレクタformで取得して送信するのは簡単です:

cy.get("form").submit();

フォームを送信すると、CypressはSentimentalyzerの結果ページに遷移するはずです。Cypressは"You are feeling: Angry"というテキストを確認する必要がありますが、これを含む<p>タグにはID属性がないため、少し工夫が必要です:

結果の<p>タグのCSSセレクタの確認

ここでも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にdocker-composeコマンドの終了コードとしてCypressコンテナの終了コードを使うよう指示します。つまり、テストが成功すれば終了コードは0、失敗すれば0以外になります。この挙動は、コマンドの終了コードで成功を判定するビルドスクリプトや継続的インテグレーションの設定で便利です。

コンソールでの実行の様子は次のとおりです:

Cypressはテスト実行ごとに動画を録画します。これは私のお気に入りの機能で、テスト失敗の診断に大いに役立ちます:

CypressによるE2Eテストの録画(1/4倍速)

テスト失敗時のスクリーンショット

上では成功するテストを紹介しました。ではCypressのテストが失敗するとどうなるでしょうか?失敗時もテスト実行の動画は生成されますが、それに加えて、失敗したアサーションを示すスクリーンショットも出力されます:

失敗時にCypressが出力したスクリーンショット

テストが失敗したときにCypressが生成したスクリーンショット(Cypressは「Furious」という単語を期待していましたが、実際には「Angry」が見つかりました)

これは他のツールで私が経験した大きな悩みを解消してくれます。Seleniumもスクリーンショットには対応していますが、アサーションの前か後でしか取得できません。その制限のせいで、Seleniumはテストが失敗したと言うのに、テスト失敗後にブラウザの状態が変わってしまったためスクリーンショットでは正常に表示されている、といった苛立たしい状況が起きていました。

Cypressではスクリーンショットがアサーションと同時に取得されるため、この問題が起きません。テストが失敗した場合、スクリーンショットにはまさに失敗の瞬間にCypressが見ていたものが写っています。

あなたのWebアプリへの応用

あなたのWebアプリでE2Eテストを始めるのに必要なのは、この3つのファイルだけです。手順は次のとおりです:

  1. e2eフォルダをあなたのプロジェクトにコピーする。
  2. docker-compose.yml内のsentimentalyzerのセクションを、あなたのアプリ用のDockerコンテナに置き換える。
  3. あなたのアプリのUIフローに合わせてintegration/spec.jsを書き換える。

ソースコードと追加のサンプル

このデモの完全なソースコードはGitHubで公開しています:

また、他の一般的なCypressのシナリオを示すために、いくつかのブランチも用意しました:

さらに詳しく

このガイドではCypressの基本を紹介しました。より高度な機能については、Cypressの公式ドキュメントをご覧ください:

追記(2019-05-02): この投稿への反応として、CypressチームがCypressをプリインストールした公式Dockerイメージを公開しました。このチュートリアルも新しいイメージを取り入れる形で更新しています。イメージの詳細やCypressとDockerを併用するさらなるコツについては、Cypressのブログ記事をご覧ください。


イラスト:Loraine Yow。CypressチームのGleb Bahmutov氏には、この記事に初期段階でフィードバックをいただいたことに感謝します。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント