puppeteer で E2E テストにチャレンジして挫折した話

本記事は[「Develop fun!」を体現する Works Human Intelligence #2](https://qiita.com/advent-calendar/2020/whi-2)の 9 日目です。
弊社ではアドベントカレンダーを 2 枚実施しておりますので、[1 枚目](https://qiita.com/advent-calendar/2020/whi)もぜひご覧ください！

本日は、私のチームで 1 年ちょっと前に導入しかけて挫折した「puppeteer で E2E テスト」についての備忘をまとめようと思います。

## ことのはじまり

さかのぼること 1 年と少し前、私が所属するチームでは新規サービスの開発を進めていました。
フロントエンドは React、バックエンドは AWS Lambda を使ったサーバレスな Single Page Application です。
開発の初期段階からどんどん自動テストを導入していこう！という思想のもと、
フロントエンド、バックエンドそれぞれの Unit Test に加え、E2E テストも機能の開発と同時に作成していくことになりました。
当時はいろいろと手探り状態だったことと、まだフロントエンドとサーバーサイドが繋がっていない状態だったため、
「とりあえず[puppeteer](https://github.com/puppeteer/puppeteer)でフロントエンドだけ動かすE2Eテスト[^1] を作ってみて、PR 作成時に実行するようにしよう！」という流れになりました。

## 先に結論

タイトルにもある通り、puppeteer での E2E テスト開発は**わりと早い段階で挫折**しました。

## puppeteer とは？

プログラムから API で Chrome (Chromium) を操作することのできる Node.js で作られたライブラリです。
ブラウザの GUI 上から実行できることはほとんど、puppeteer からも実行可能です。
例えば、以下のようなことができます。

- ページのスクリーンショットや PDF の生成
- フォームの送信・UI テスト・キーボード入力などの自動化
- 最新の自動テスト環境の作成。 最新の JavaScript とブラウザ機能を使用して、最新バージョンの Chrome で直接テストを実行する
- サイトの timeline trace をキャプチャして、パフォーマンスの問題の診断に使う 
- Chrome 拡張機能のテスト
  
上記は puppeteer の [README](https://github.com/puppeteer/puppeteer) から引用しました。
README でもテストについての言及があるので、素直に E2E テストに利用してみることにしました。

## 開発環境

1 年と少し前の話なので、ちょっと古い＆記憶が曖昧なのですが、だいたいこんな感じだったと思います。
React プロジェクトの環境構築は `create-react-app --typescript` で行いました。

- React 16.6.3
- Jest 23.6.0
- puppeteer 1.12.2
- jest-puppeteer 3.9.0（ jest と一緒に puppeteer を利用する場合、[jest-puppeteer](https://github.com/smooth-code/jest-puppeteer) を使うのが [jest 公式](https://jestjs.io/docs/en/puppeteer)でも推奨されています）
- TypeScript 3.2.2
- Node.js 10.15.0（たぶん）

また、該当サービスのリポジトリは monorepo（複数の package をまとめて 1 つのリポジトリで管理する）となっており、同じリポジトリ内に**フロントエンドの package とサーバーの package が共存**しています。

## テスト実行の流れ

Jest + puppeteer での E2E テスト実行の流れは以下のようなイメージです。
Jest でのテスト実行としてはオーソドックスなパターンだと思います。

1. Jest の GlobalSetup（すべてのテストの実行前に、テスト環境をセットアップするスクリプト）を用いて、puppeteer のローンチとテスト対象の画面の起動（今回の場合は自プロダクトのフロントエンドの Build と起動）を行う
2. テストコードを実行する
3. Jest の GlobalTearDown（GlobalSetup の逆で、全てのテストの実行後の後処理を行うスクリプト）を用いて、puppeteer とテスト対象画面を終了する

なお、この時点では**フロントエンドとサーバーサイドが繋がっていない状態**なので、GlobalSetup ではテスト対象の画面（フロントエンド）の起動のみを行なっています。

## 大変だったところ
### その１: セットアップが（ちょっと）大変

create-react-app でセットアップしたプロジェクトでは、デフォルトで用意されている `react-scripts test` を使うことであらかじめ用意された Jest の設定でテストを実行することができます。
しかし、今回は上述のように jest-puppeteer を使ってテスト実行のためのセットアップを行うため、Jest の設定は自前で用意する必要があります。

まずは画面の起動のため、jest-puppeteer-config.js を用意し、puppeteer の起動設定を記載します。

```js:jest-puppeteer-config.js
module.exports = {
  Server: {
    command: `react-scripts build && serve -s build`, // クライアントの起動コマンド
    port: 5000, // ポート指定
    launchTimeout: 30000, // タイムアウト時間(ms)
    debug: true, // debug モードで起動
  },
  launch: {
    headless: true, // ヘッドレス起動
  },
}
```

次に、create-react-app が提供する Jest の設定ではなく自前で Jest の設定を行う必要があるため、jest.config.js も用意します。
E2E テスト用とわかるように、ファイル名は jest.e2e.config.js としておきます。

```js:jest.e2e.config.js
module.exports = {
  preset: 'jest-puppeteer',
    transform: {
      '^.+\\.jsx?$': 'babel-jest',
      '^.+\\.tsx?$': 'ts-jest',
    },
  moduleFileExtensions: [
    'ts',
    'tsx',
    'js',
    'jsx',
    'json',
    'node'
  ],
  transformIgnorePatterns: ['/node_modules/'],
}
```

最後に、テスト実行は `react-scripts test` ではなく `jest` コマンドを直接呼ぶよう、package.json の scripts にコマンドを追加します。
その際に、さきほど用意した jest.e2e.config.js を参照するように設定します。

```json:package.json
{
  "name": "プロダクト名",
  "scripts": {
    "test:it": "jest -c './jest.e2e.config.js' --testMatch '**/__tests__/**/?(*.)+(spec).ts?(x)'",
    ...
  },
  ...
}
```

ここまでの設定で、「フロントエンドを起動して画面を操作するテスト」は実行できるようになりました。

この時点ではまだ**フロントエンドとサーバーサイドが繋がっていない状態**だったのでこれで十分でしたが、
今後開発が進めば**フロントエンドから画面を操作してサーバに API を飛ばすところまで含めたテスト**が必要となってきます。
となると、GlobalSetup でフロントエンドの起動だけでなく、サーバーの起動まで必要になってきます。

jest-puppeteer の機能で GlobalSetup を上書きできる機能があるのでサーバー起動もできないことはないですが、ちょっとめんどくさいです。

### その2: 待ち時間を考慮してテストを書かないといけない

puppeteer は純粋なテスト用のツールというわけではないので、画面を操作するときに「待ち時間」を考慮してくれません。
例えば、

- 画面上に「購入」ボタンがある
- 「購入」ボタンをクリックするとサーバーと通信し、購入処理が走る
- サーバーから処理完了の応答が返ってきたら、購入完了画面に遷移する

というような画面のテストをしたい場合、

1. 「購入」ボタンをクリックする
2. 購入処理が完了するまで待つ
3. 処理完了後、購入完了ページに遷移することを確認する

というテストを作成する必要がありますが、
2 番目の「待つ」という部分は自前で書く必要があります。

待ち時間を短くしすぎると待機時間が足りずにテストに失敗する可能性が高くなってしまいます。
しかし、待ち時間を長くしすぎるとテスト全体の実行時間が長くなってしまいます。

また、SPA ではその特性上、**とある操作をしたときだけ html 上に現れる要素**を多用します。
例えば、

- 画面右上に「ヘルプ」ボタンがある
- 「ヘルプ」ボタンをクリックすると、ヘルプが記載されたポップアップが表示される
- ポップアップの外側をクリックすると、ポップアップが閉じる

というような画面の場合、
SPA では**「ヘルプ」ボタンがクリックされた**という状態のときだけ html 上にポップアップ部分が現れ、
ポップアップが閉じると html からもポップアップ部分が消える、
というような実装をすることが多いです。

この挙動のテストをしたい場合、

1. 「ヘルプ」ボタンをクリックする
2. ポップアップが表示されることを確認する
3. ポップアップの外側をクリックする
4. ポップアップが閉じることを確認する

というテストを作成する必要がありますが、
2 と 4 の確認のタイミングに html の描写が追いつかず、

- ポップアップが表示される前にポップアップを確認しようとして失敗する
- ポップアップが消える前にポップアップが閉じたことを確認しようとして失敗する

という状況が多発します。

サーバーの処理の待ち時間、html の描写の待ち時間、どちらの場合も理想は

- 処理が完了するまでいい感じに待つ
- 不具合を疑うほど長い待ち時間だったら失敗と判定する

という形なのですが、puppeteer ではなかなか実現が難しいです。

## 最終的にどうなったか

セットアップはなんとか完了して E2E テストを実行できる状態にできましたが、
待ち時間の問題からテスト実行が全く安定しませんでした。
PR の作成時に E2E テストを実行するにしても、2 回に 1 回は何かを待ちきれずに E2E テストがエラーを吐き、
失敗を通知する赤いメッセージが Slack に送られてくる状態。
このままではとても開発が進まないということで、puppeteer での E2E テストは頓挫しました。

代わりに E2E テストフレームワークとして [Cypress](https://www.cypress.io/) を採用することとなりました。

## E2Eテストフレームワークに求めるもの

puppeteer での E2E テスト挫折経験から得た、E2E テストフレームワークに求めることは以下の通りです。

- 自前で「待機」を書かなくていい
    - 画面の切り替えや通信を含む処理など、フレームワーク側でいい感じに待ってほしいです。
- あまりに長い時間「待機」していたらエラーにしてほしい
    - どのくらいまでの待ち時間なら許容するかを設定できるといいです。
- フレームワーク 1 つで完結している
    - テスト用のフレームワークとブラウザごとのドライバが必要、とかだと面倒です。
    - puppeteer は puppeteer だけでいいのでここはよかったです。
- テスト実行の様子や実行結果を確認できる
    - 特にテストに失敗したとき、なんで失敗したのかをテスト実行後に確認できるといいです。
    - テスト終了時の画面のスクショや、できれば動画があるといいです。

## おわりに

以上、puppeteer で E2E テストを実現しようとして挫折した話でした。
最後まで読んでいただきありがとうございました！

[^1]: 今思うと厳密には End to End とは言えない...