この記事は GiXo アドベントカレンダー の 24 日目の記事です。
昨日は、 React で作る中規模 SPA のレイヤードアーキテクチャ でした。
MLOps Div. の濱田です。
本日は 12 月 24 日ですね。学校では期末で、冬休みが近いのではないでしょうか。学期末といえばテストということで、今回は Cypress を用いた E2E テストについてご紹介します。なお、テスト対象の Web アプリケーションは React.js + TypeScript で実装されており、バックエンドに Firebase を活用しています。
目次
テストの種類
せっかくの機会ですので、E2E テストの話をする前に、テストの種類について整理していきます。E2E テストの中身について知りたい方は、こちらの章は飛ばしてかまいません。
一口に「Web アプリケーションのテスト」といっても、その内容は多岐に渡ります。
JSTQB ( Japan Software Testing Qualifications Board ) によれば、テストは下記のように区分されます。
- テストレベル ( 開発工程 ) に基づく分類
- コンポーネントテスト
- 統合テスト
- システムテスト
- 受け入れテスト
- テストタイプ ( 品質特性 ) に基づく分類
- ホワイトボックステスト
- 機能テスト
- 非機能テスト
- ユーザービリティテスト
- 負荷テスト
- セキュリティテスト … etc.
そして、すべてのテストタイプは、すべてのテストレベルで実行できるとされています。
では、E2E テストはどこに分類されるのでしょうか。システムテストの一環として行われそうですが、E2E テストはワークフローを確認するという点で、適切でないとも考えられます。
…難しいですね。Stack Overflow にも、この手のテストの分類方法についての質問が数多く挙げられています。どのようなテストが存在するのか認識しておくことはもちろん大切ですが、さらに重要なのは個々のテストがプロジェクト内で何を意味し何を確認するためのテストなのか、認識を合わせておくことでしょう。
今回のプロジェクトでは、Web アプリケーションのリファクタリングを行う際に、E2E テストを導入しました。テストを実装する時間は限られていたため、「ユーザーがアプリケーション上で目的を達成できること」を最低限の目標としました。そのためここでは E2E テストを次のように定義していきます。
「実際にブラウザを操作し、ユーザーが Web アプリケーション上で目的を達成するまでの一連のシナリオを確認するためのテスト」
E2E テストの選択肢
E2E テストを行う上で、ライブラリの選択肢はいくつかあります。
- Cypress
- Puppeteer
- TestCafe
- Selenium
この中でも導入のしやすさ、実行時間の早さ、GitHub のスターの数、インストール数 ( ≒ ドキュメントの豊富さ ) など諸々を考慮して Cypress を選択しました。実際に使ってみると、シンプルに実装できながらも、高機能で使い勝手は良いです。さらについ先日、4,000 万ドルの資金調達を発表したとのことで、今後のさらなるサービス拡大が期待されます。他の選択肢として、 Autify は有償ながらもノーコードでテストシナリオが作成できるということなので、いずれ触ってみたいところです。
Cypress を導入する
React の新規プロジェクトを作成するところから始めます。
1 |
npx create-react-app react_typescript_e2e --template typescript |
必要なライブラリをインストールします。
1 |
yarn add -D cypress @cypress/instrument-cra cross-env |
ディレクトリ構成
下記の構成を目指します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
. ├── e2e │ ├── .eslint.js │ ├── package.json │ └── tsconfig.json │ ├── src │ └── hoge │ ├── cypress.env.json ├── cypress.json ├── package.json └── tsconfig.json |
テストファイルは root > e2e
配下に配置し、src
とは別のディレクトリで管理します ( TypeScript Deep Dive でも推奨されています ) 。理由としては、意図せぬ lint エラーを避けるためです。例えば、Cypress は Mocha の構文を利用しているため、多くのコマンドが Jest と重複します ( describe
beforeEach
expect
など ) 。ディレクトリを分離することで、 lint 時に意図せず Jest の警告が発生することを防ぎます。
root ディレクトリにおける設定
package.json
に下記を追記します。
1 2 3 4 5 |
"scripts": { "start": "react-scripts -r @cypress/instrument-cra start", "cy:open": "cross-env NODE_PATH=src cypress open", "cy:run": "cross-env NODE_PATH=src cypress run --headless" } |
- Windows 環境で実行できるように、
cross-env
を指定しています - cypress コマンド実行時に
NODE_PATH=src
を指定することで、テストディレクトリから src 配下のモジュールを絶対パスでインポートできるようになります。
yarn start
でアプリを立ち上げてから yarn cy:open
を実行すると Cypress の UI が立ち上がり、サンプルのテストファイルが表示されます。
また、root ディレクトリ配下に cypress
ディレクトリが作成されます。私は、こちらのディレクトリ名を e2e
に変更して運用しています。
1 |
mv cypress/ e2e/ |
ディレクトリ名を変更したので、cypress.json
に下記を追記します。
1 2 3 4 5 6 7 8 9 10 11 |
{ "baseUrl": "http://localhost:3000", "fileServerFolder": "e2e", "fixturesFolder": "e2e/fixtures", "integrationFolder": "e2e/integration", "screenshotsFolder": "e2e/screenshots", "videosFolder": "e2e/videos", "supportFile": "e2e/support/index.ts", "pluginsFile": "e2e/plugins/index.ts", "testFiles": "**/*.spec.ts" } |
ここまでで、ひとまず Cypress によるテストを実行可能な環境が整いました。
テストコードを TypeScript で動かせるように設定する
テストコードの拡張子は .js
になっていますが .ts
として扱えるようにします。e2e
ディレクトリに移動します。integration/examples
ディレクトリは削除し、package.json
を作成します。
1 2 3 |
cd e2e rm -r integration/examples echo '{}' > package.json |
必要なライブラリをインストールします。
1 2 3 4 5 |
yarn add typescript yarn add -D cypress @testing-library/cypress eslint-plugin-cypress eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser prettier eslint-config-prettier eslint-plugin-prettier |
.eslintrc.js
を作成することで、lint が効くようになります。
1 2 3 4 5 6 7 8 9 10 11 |
module.exports = { extends: [ 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:cypress/recommended', 'prettier', ], plugins: ['@typescript-eslint', 'prettier', 'cypress'], root: true }; |
これで .ts
ファイルを扱えるようになりました。e2e
配下の .js
ファイルの拡張子をすべて .ts
に変更してください。そのうえで 1 つ、新しくテストを作成してみます。 e2e/integration/sample.spec.ts
を作成し、下記を記述してください。
1 2 3 4 5 6 7 8 |
describe('Check text', () => { it('check text', () => { cy.visit('http://localhost:3000'); // code タグ内にテキストが存在することを確認する cy.contains('code', 'src/App.tsx'); }); }); |
下記のように code
タグ内に src/App.tsx
という文字列が存在することを確認するテストです。
yarn cy:run
を実行すると、無事にテストが通ることを確認できるはずです。
スナップショットテスト
UI が変わらないことを確かめる際は、スナップショットテストを導入すると良いでしょう。e2e
配下で、必要なライブラリをインストールします。
1 |
yarn add -D @types/cypress-image-snapshot cypress-image-snapshot |
e2e/plugin/index.ts
を下記のように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { addMatchImageSnapshotPlugin } from 'cypress-image-snapshot/plugin'; /** * @type {Cypress.PluginConfig} */ export default function ( on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions, ): Cypress.PluginConfigOptions { addMatchImageSnapshotPlugin(on, config); return config; } |
e2e/support/commands.ts
を下記のように変更します。
1 2 3 |
import { addMatchImageSnapshotCommand } from 'cypress-image-snapshot/command'; addMatchImageSnapshotCommand(); |
root ディレクトリの package.json
でスナップショットテストを行うスクリプトを追加します。また、既存のスクリプトでは failOnSnapshotDiff=false
として、スナップショットテストを行わないようにします。
1 2 3 4 5 6 7 |
"scripts": { "start": "react-scripts -r @cypress/instrument-cra start", "cy:open": "cross-env NODE_PATH=src cypress open --env failOnSnapshotDiff=false", "cy:run": "cross-env NODE_PATH=src cypress run --headless --env failOnSnapshotDiff=false", "cy:snap": "cross-env NODE_PATH=src cypress run --headless", "cy:snap:update": "cross-env NODE_PATH=src cypress run --headless --env updateSnapshots=true" } |
前章で作成した e2e/integration/sample.spec.ts
に、スナップショットテストを追加しましょう。
1 2 3 4 5 6 7 8 9 10 11 |
describe('Check text', () => { it('check text', () => { cy.visit('http://localhost:3000'); // code タグ内にテキストが存在することを確認する cy.contains('code', 'src/App.tsx'); // スナップショットテスト cy.matchImageSnapshot('test'); }); }); |
yarn cy:snap
を実行すると、snapshots/sample.spec.ts/test.snap.png
が作成されます。こちらがスナップショットです。
この状態で一部の文字を red に変更して、再度テストを実行してみます。
テストが失敗し、snapshots/sample.spec.ts/__diff_output__ に差分のスクリーンショットが保存されていることが確認できます。
左が元の画面、右が変更後の画面、中央が差分の画面です。
ここまでで、無事にスナップショットテストが実行できることを確認しました。
テストカバレッジを取得する
root ディレクトリにて、ライブラリを追加します。
1 |
yarn add -D nyc @cypress/code-coverage |
package.json
に追記します。
1 2 3 4 |
"nyc": { "report-dir": "e2e/coverage", "reporter": [ "text", "lcov" ] } |
e2e/plugins/intex.ts
を下記のように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import task from '@cypress/code-coverage/task'; import { addMatchImageSnapshotPlugin } from 'cypress-image-snapshot/plugin'; /** * @type {Cypress.PluginConfig} */ export default function ( on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions, ): Cypress.PluginConfigOptions { task(on, config); addMatchImageSnapshotPlugin(on, config); return config; } |
e2e/support/index.ts
を下記のように変更します。
1 2 3 |
import './commands'; import '@cypress/code-coverage/support'; |
yarn cy:run
を実行すると、コンソールにカバレッジが表示されるようになります。
GitHub Actions で CI を行う
package.json
に、Chrome と Firefox で Cypress による CI を実行するためのスクリプトを追加します。
1 2 3 4 5 6 7 8 9 |
"scripts": { "start": "react-scripts -r @cypress/instrument-cra start", "cy:open": "cross-env NODE_PATH=src cypress open --env failOnSnapshotDiff=false", "cy:run": "cross-env NODE_PATH=src cypress run --headless --env failOnSnapshotDiff=false", "cy:snap": "cross-env NODE_PATH=src cypress run --headless", "cy:snap:update": "cross-env NODE_PATH=src cypress run --headless --env updateSnapshots=true", "cy:run:chrome": "cross-env NODE_PATH=src cypress run --headless --browser chrome --env failOnSnapshotDiff=false", "cy:run:firefox": "cross-env NODE_PATH=src cypress run --headless --browser firefox --env failOnSnapshotDiff=false" } |
.github
ディレクトリを作成し、workflow を定義した yml ファイルを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
. ├── .github │ └── workflows │ └── e2e-test.yml │ ├── e2e │ ├── .eslint.js │ ├── package.json │ └── tsconfig.json │ ├── src │ └── hoge │ ├── cypress.env.json ├── cypress.json ├── package.json └── tsconfig.json |
e2e-test.yml に下記を記述します。
コンテナイメージは、こちらから選択可能です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
name: E2E test on: [push] jobs: e2e-with-cypress: runs-on: ubuntu-latest container: image: cypress/browsers:latest options: --user 1001 # Firefox で動作確認するための設定 steps: - uses: actions/checkout@v2 - uses: cypress-io/github-action@v2 with: runTests: false # インストールのみ - name: Install cypress dependencies run: yarn --frozen-lockfile working-directory: e2e ############################## # Google Chrome ############################## - name: E2E with Google Chrome uses: cypress-io/github-action@v2 with: install: false start: yarn start wait-on: 'http://localhost:3000' command: yarn cy:run:chrome ############################## # Firefox ############################## - name: E2E with Firefox uses: cypress-io/github-action@v2 with: install: false start: yarn start wait-on: 'http://localhost:3000' command: yarn cy:run:firefox |
これで、 push 時に E2E テストが実行されるようになりました。
その他 Tips
ダッシュボード
Cypress はダッシュボードを提供しています。
- 月 500 テストまで、無料でテスト実行結果を保存しておけます
- CI と連携させることができます
- テストの動画を見ることができます
- 結果をチームメンバーに共有可能です
有償プランで、制限を拡張できます。
環境変数の隠蔽
隠蔽したい下記のような情報は、cypress.json
ではなく cypress.env.json
で管理し、GitHub の管理から外すと良いです。
- ログインテストを行う際のメールアドレス・パスワード
- Firebase の接続情報
- Cypress ダッシュボードの Key
これらを CI で利用したい場合は、GitHub Secrets にも登録しておきます。
Cypress のバージョン
Cypress は頻繁にバージョンアップされます。改善されるのは良いですが、最新バージョンはバグを含んでいる場合があります。バージョンアップは GitHub の Issue と照らし合わせつつ、注意して行ってください
テストを書き始める前に
テストシナリオをあらかじめ書き出して、そちらに沿って書いていくと良いでしょう。E2E テストはユニットテストに比べて時間がかかるため、必要な分だけ責務を持たせることが賢明です。
おわりに
Cypress を利用した E2E テストを紹介しました。時間がかかる、壊れやすいなどの弱点もありますが、目的に合わせて使えば有用です。実際、E2E テストのおかげで、安心感を持ってリファクタを進めることができました。興味を持たれた方は、ぜひ導入を検討してみてください。
現在、弊社のフロントエンドは主に React + TypeScript + Firebase で開発を進めています。こちらの記事をご覧になっている方々は近い環境で開発を行っていると推察されますので、下記のアドベントカレンダーもオススメです。
- SPA の First View 表示速度を改善する
- Firestore のデータを TypeScript と Security Rules で安全に扱う話
React で作る中規模 SPA のレイヤードアーキテクチャ
最後に宣伝を…
私が所属する MLOps Div. では絶賛メンバー募集中です。紹介記事はこちら。興味を持っていただけた方は、是非目を通してみてください。
さらに、私達と一緒に働きたい!と思っていただけた方がいらっしゃれば、ぜひこちらからご応募ください。まずはカジュアル面談で話だけ聞いてみたい、という方も歓迎です。
さて、いよいよ明日は 大トリ、弊社代表の網野より「データに基づく判断・意思決定の環境を整え、世界の考える総量を最大化するために」を公開予定です。お楽しみに!
Shota Hamada (濱田 翔大)
MLOps Div. 所属
Kaggle 、将棋、コーヒー。React + TypeScript によるフロントエンドの開発を担当しています。