はじめに
Cypress の勉強のため、公式ドキュメントの概要~入門を簡単にまとめました。
皆様の参考になれば幸いです。
この記事で分かること
- Cypress の概要
- Selenium との比較
- 入門 (サンプルアプリの作成)
- Next.js + TypeScript + Cypress の簡単なサンプルアプリの作成
環境
{
"cypress": "^7.5.0",
"typescript": "^4.3.4"
}
1. Cypress とは
Github: cypress-io/cypress: Fast, easy and reliable testing for anything that runs in a browser.
公式 Doc: Why Cypress? | Cypress Documentation
Cypress はフロントエンドのテストツールであり、以下のすべてのを作成・テストできる。
- エンドツーエンド(E2E)テスト
- 統合(インテグレーション)テスト
- ユニットテスト
1.1. Cypress のエコシステム (Cypress ecosystem)
Cypress はローカルにインストールされるテストランナーと、テスト記録用のダッシュボードで構成される。
- 開発中: ローカルでアプリケーションを開発中、常にテストを回すことができる (=TDD に最適!)
- 開発後: テストを構築し、Cypress を CI ツールと統合した後、ダッシュボードでテストの実行結果を記録・確認できる
1.2. 特徴(Features)
-
タイムトラベル:
- Cypress はテスト実行時にスナップショットを取得できる
- コマンドログにカーソルを合わせると、各ステップの詳細を確認できる
-
デバッグ可能:
- テストの失敗理由を推測せず、開発ツールから直接デバッグできる
- そのため、読み込み可能なエラーとスタックトレースにより、デバッグが非常に高速になる
-
自動待機:
- テストに
await
やsleep
はいらない - Cypress は、コマンドやアサーション時に要素が見つからない場合、自動で再試行する
- テストに
-
Stubs, Spies, Clocks:
- sinonの Spy や Stub をラップしている
- Clocks(Date、setTimeout、clearTimeout、setInterval, clearInterval)のラッパーも提供している
-
ネットワークトラフィック制御
- サーバーを使用せずに、ネットワークリクエストを制御、Stub, テストできる
-
一貫した結果
- Selenium や WebDriver を使用していないため、高速で一貫性のある信頼性の高いテストを提供する
-
スクリーンショットとビデオ
- 失敗時に、以下を表示できる
- 自動的に撮影されたスクリーンショット
- CLI から実行した場合のテストスイート全体のビデオ
- 失敗時に、以下を表示できる
-
クロスブラウザテスト
- CI パイプライン上で、Firefox および Chrome 系のブラウザを用いて最適なテストを実行できる
1.3. サンプル
- 公式動画: Cypress をインストールしてから実際にテストケースを蹴るところまで、簡潔にまとまっている
- cypress-io/cypress-realworld-app: Cypress のテスト手法やシナリオを実践するためのアプリケーション
1.4. テストタイプ
E2E テスト
一般的な E2E テストのように、ブラウザでアプリケーションにアクセスし、実際のユーザーと同じように UI を介してアクションを実行する。
it("adds todos", () => { cy.visit("https://todo.app.com"); cy.get(".new-input").type("write code{enter}").type("write tests{enter}"); // confirm the application is showing two items cy.get("li.todo").should("have.length", 2); });
コンポーネントテスト
一部の Web フレームワーク (Rect or Vue) からコンポーネントをマウントすることで、コンポーネントテストも実行できる。
jest や mocha のようにコンポーネントを jsdom でレンダリングするのではなく、実際のブラウザ上にコンポーネントをマウントし、そのコンポーネントに対してテストを行う。
※ただし、現状(2021/06/19)段階でアルファ版なので注意
import { mount } from "@cypress/react"; // or @cypress/vue import TodoList from "./components/TodoList"; it("contains the correct number of todos", () => { const todos = [ { text: "Buy milk", id: 1 }, { text: "Learn Component Testing", id: 2 }, ]; mount(<TodoList todos={todos} />); // the component starts running like a mini web app cy.get("[data-testid=todos]").should("have.length", todos.length); });
API テスト
Cypress は任意の HTTP 呼び出しを実行できるため、API テストも可能。
it("adds a todo", () => { cy.request({ url: "/todos", method: "POST", body: { title: "Write REST API", }, }) .its("body") .should("deep.contain", { title: "Write REST API", completed: false, }); });
その他
多数のプラグインを活用することで、a11y、画像回帰、電子メールテスト等々が可能。
2. Selenium との比較
2.1. アーキテクチャ
- Selenium は、ブラウザの外部で実行され、ネットワーク経由でコマンドを実行する
- Cypress は、ブラウザ内で、アプリケーションと同じランタイムで実行される
Cypress の特徴
- アプリケーションのイベントにリアルタイムでアクセス可能
- ネットワークトラフィックをその場で読み取り、変更できるため、ネットワーク層でも動作する
- それにより、ブラウザに出入りするすべてを変更可能
- ブラウザの自動化を妨げるコードを変更できる
- Cypress は自動化プロセス全体を制御する
- それによりブラウザの内外で発生するすべてを理解できる
- それにより、Cypress はどのテストツールよりも一貫した結果を提供できる
- Cypress はローカルマシンにインストールされる
- 自動化タスクのために、OS の機能を追加利用できる
- スクリーンショットの撮影、ビデオの録画、ファイル操作、ネットワーク操作、等々
2.2. ホストオブジェクトへのネイティブアクセス
Cypress はアプリケーション内で動作するため、全てのホストオブジェクト(window
, document
, DOM element
, Application Instance
, function
, timer
, service worker
, etc)へのネイティブアクセスを提供する。
つまり、テストコードにおいて、アプリケーションコードと同じオブジェクトにアクセス可能。
2.3. 新しい種類のテスト
上記の特徴により、従来では時間と費用のかかった以下のようなケースを人為的に作成可能。
- ブラウザ or アプリケーションの機能をスタブし、テストケースで必要に応じて動作するように強制する
- Redux のようにデータストアを公開し、テストコードから直接アプリケーションの状態を変更する
- サーバーから空のレスポンスを返すことで、「空のビュー」などのエッジケースをテストする
- レスポンスのステータスコードを 500 に変更することで、アプリケーションの異常系のテストが可能
- DOM 要素を直接変更(Ex. 非表示 DOM を表示するように)
- プログラムでサードパーティのプラグインを利用可能
- 以下のような複雑な UI ウィジェットの代わりに、テストコードから直接メソッドを呼び出し、制御が可能
- Ex. 複数選択、オートコンプリート、ドロップダウン、ツリービュー、カレンダー
- テスト時、アプリケーションコードが実行される前に、Google Analytics が読み込まれないようにする
- アプリケーションが新しいページに移行したり、アンロードされるたびに同期通知を受け取る
- テストで必要な時間を待たずにタイマー or ポーリングが自動的に起動するように、時間を前後に動かす
- アプリケーションに応答する独自のイベントリスナーを追加する
- テスト中に異なる動作をするようにアプリケーションコードを更新できる
- テストコードから WebSocket メッセージを制御する
- サードパーティのスクリプトを条件付きでロードする
- アプリケーションで直接関数を呼び出す
2.4. ショートカット
Cypress は、特定の状況を生成するために「ユーザーのように振る舞う」ことを矯正しない。
Cypress では、プログラムからアプリケーションを操作及び制御できる。
- 例
- 他のツール
- ログインページにアクセス
- ユーザー名とパスワードを入力
- 送信
- リダイレクト後、テストを実施 (これをテストケース毎に実行)
- Cypress
- cy.request()で直接同期的にログインリクエストを送信し、テストを行う
- 他のツール
cy.request()
を利用した場合、リクエストがブラウザから送信されたかのように Cookie を自動的に取得・設定する。また、CORS も完全にパイバスされる。
2.5. 取りこぼしのないテスト (Flake resistant)
- Cypress はアプリケーションで同期的に発生するすべてを理解している
- ページロード、アンロードのタイミングで通知される
- イベント発生時、要素の変更を見逃さない
- 要素がアニメーション中か自動で判定し、停止するまで待機する
- さらに、要素がちゃんと表示され、有効(
disabled=false
)になり、親要素にカバーされなくなるまで待機する - ページ遷移が始まると、次ページが完全に読み込まれるまで待機する
- 特定のネットワークリクエストが終了するのを待つように指示もできる
2.6. デバッグ可能
Cypress は使いやすさを大切にしている。
- テストに失敗すると、失敗した正確な理由を示す数百のエラーメッセージが出力される
- 以下の要素を視覚的に表現する豊富な UI がある
- コマンドの実行、アサーション、ネットワークリクエスト、スパイ、スタブ、ページロード、URL の変更
- アプリケーションのスナップショットにより、コマンドが実行された状態にタイムトラベルできる
- テスト実行中に開発者ツールが使える
- すべてのコンソールメッセージ、ネットワークリクエストを確認できる
- 要素の検査ができる
- テストコードやアプリケーションコードでデバッガーステートメントを使用できる
これらにより、開発とテストのすべてを蔵時に行うことができる。
2.7. トレードオフ
これらの機能を可能にするに行ったトレードオフがある。
- テストコードはあくまでクライアントサイドで評価される。サーバーサイド(Node.js)では評価されない
- 複数タブ、複数ブラウザをサポートしていない
- Same Origin には対応しているが、Cross Origin には対応していない
3. Getting Started
簡単な Next.js (+ Typescript) アプリを起動し、テストを作成・実行してみる。
※ Next.js の部分は任意のフレームワークに置き換えて良いです。
※ 不要であれば読み飛ばしてください。
3.1. 環境
OS 別のセットアップ方法はこちらに詳細が記載されているので、適宜確認する。
$ node -v
v16.3.0
$ npm -v
7.15.1
3.2. ソース
https://github.com/eyuta/cypress-nextjs-test
3.3. 準備
3.3.1. インストール
# Next.jsインストール。他でも可。既存プロジェクトでも可。
yarn create next-app --typescript # or npx create-next-app --ts
cd my-app
# 依存関係を分離するため、直下にe2eフォルダを作成し、そこにCypressをインストールする
mkdir e2e
cd e2e
echo e2e/node_modules >> .gitignore
yarn init -y # or npm init -y
yarn add -D cypress typescript # or npm install -D cypress typescript
npx tsc --init --types cypress --lib dom,es6
3.3.2. Cypress を開く
npx cypress open
, yarn run cypress open
で実行可能。
package.json
を使う場合は、 scripts に以下を追加する。
{
"scripts": {
"cypress:open": "cypress open",
"cypress:run": "cypress run"
}
}
yarn cypress:open
これにより、以下が行われる。
- テストランナー用のブラウザが起動される
-
/e2e/cypress
フォルダが作られ、配下にサンプルテストケースが配置される -
/e2e/cypress.json
が作成される
テストランナー起動後、Tests
の適当なファイル名をクリックすると、テストが実行されます。
CLI ツールの詳細はこちら
ダッシュボードの使い方はこちら
3.4. 新しいテストケースの追加
新しいテストケースを追加する。
その前に、Next.js を使用している場合は、一旦サーバーを別のターミナルで起動しておく。
yarn dev
echo "
context('Next.js test', () => {
it('should access localhost', () => {
cy.visit('http://localhost:3000');
cy.get('h1')
.should('have.text', 'Welcome to Next.js!')
});
});
" > e2e/cypress/sample.spec.ts
Next.js を使用していない場合は、適当なサイトにアクセスするテストケースを作成する (Cypress がサンプルページを提供している)。
echo "
context('google search', () => {
it('should perform basic google search', () => {
cy.visit('https://google.com');
cy.get('[name="q"]')
.type('subscribe')
.type('{enter}');
});
});
" > e2e/cypress/sample.spec.ts
yarn cypress:open
で起動したブラウザにおいて、Tests
にsample.spec.ts
が追加される。
そして、sample.spec.ts
をクリックすると、作成したテストケースが実行される。
3.5. 成功/失敗するテストケースの追加
先程のsample.spec.ts
もしくは別のファイルに、以下を追記し、実行する。
すると、成功した場合はチェックが付き、失敗した場合にエラーが表示されることが分かる。
基本的なページアクセス、クエリ、イベント発火方法等々については、こちらを見るか、サンプルテストを見ると分かりやすい。
エラーの見方は、こちらに詳細がある。
describe("My First Test", () => {
it("Match!", () => {
expect(true).to.equal(true);
});
it("Does not match!", () => {
expect(true).to.equal(false);
});
});
3.6. アプリのテスト
同じアプリケーション(ホスト)に頻繁にアクセスする場合、baseUrl
を指定すると良い。
それにより、cy.visit()
と cy.request()
に自動的にプレフィックスが付与されるため、ホスト名とポートを省略できる。
{
"baseUrl": "http://localhost:3000"
}
describe('The Home Page', () => {
it('successfully loads', () => {
cy.visit('/')
})
})
3.7. テスト戦略
3.7.1. 初期データの用意
テスト前にサーバーサイド(データベース等)でセットアップや破棄を行いたい場合、beforeEach
やafterEach
を使う。
また、以下のメソッドも有用。
cy.exec()
システムコマンドを実行する。
describe("The Home Page", () => { beforeEach(() => { // reset and seed the database prior to every test cy.exec("npm run db:reset && npm run db:seed"); }); it("successfully loads", () => { cy.visit("/"); }); });
cy.task()
pluginsFile を介して Node でコードを実行する。
例 1: ログの出力
spec.jscy.task("hello", { greeting: "Hello", name: "World" });
plugins/index.jsmodule.exports = (on, config) => { on("task", { // deconstruct the individual properties hello({ greeting, name }) { console.log("%s, %s", greeting, name); return null; }, }); };
例 2: TODO アプリで、TODO が追加された後、それが本当に DB に保存されているかテストする
spec.jsimport { enterTodo, resetDatabase } from './utils' describe('cy.task', () => { beforeEach(resetDatabase) beforeEach(() => { cy.visit('/') }) it('finds record in the database', () => { // random text to avoid confusion const id = Cypress._.random(1, 1e6) const title = `todo ${id}` enterTodo(title) // confirm the new item has been saved // https://on.cypress.io/task cy.task('hasSavedRecord', title).should('equal', true) }) })
cypress/plugins/index.jsconst fs = require('fs') const path = require('path') const repoRoot = path.join(__dirname, '..', '..') const findRecord = title => { const dbFilename = path.join(repoRoot, 'data.json') const contents = JSON.parse(fs.readFileSync(dbFilename, 'utf8')) const todos = contents.todos return todos.find(record => record.title === title) } module.exports = (on, config) => { // "cy.task" can be used from specs to "jump" into Node environment // and doing anything you might want. For example, checking "data.json" file! on('task', { hasSavedRecord (title) { console.log('looking for title "%s" in the database', title) return Boolean(findRecord(title)) } }) }
参考: Incredibly Powerful cy.task | Better world by better software
3.7.2. サーバーのスタブ
cy.request()で HTTP リクエストを行う際、事前にcy.intercept()を実行しておくことで、HTTP 通信をスタブできる。
cy.intercept( { method: "GET", // Route all GET requests url: "/users/*", // that have a URL that matches '/users/*' }, [] // and force the response to be: [] );
参考: Network Requests | Cypress Documentation
3.7.3. ログイン
ログイン機能のテストを行う場合は、以下のように UI を通してテストするのがおすすめ。
describe("The Login Page", () => { beforeEach(() => { // reset and seed the database prior to every test cy.exec("npm run db:reset && npm run db:seed"); // seed a user in the DB that we can control from our tests // assuming it generates a random password for us cy.request("POST", "/test/seed/user", { username: "jane.lane" }) .its("body") .as("currentUser"); }); it("sets auth cookie when logging in via form submission", function () { // destructuring assignment of the this.currentUser object const { username, password } = this.currentUser; cy.visit("/login"); cy.get("input[name=username]").type(username); // {enter} causes the form to submit cy.get("input[name=password]").type(`${password}{enter}`); // we should be redirected to /dashboard cy.url().should("include", "/dashboard"); // our auth cookie should be present cy.getCookie("your-session-cookie").should("exist"); // UI should reflect this user being logged in cy.get("h1").should("contain", "jane.lane"); }); });
しかし、各テストの前に毎回上記のログイン処理を行うのは冗長。
そこで、/login
に直にログイン情報をポストすることで、ログイン処理を簡略化できる。
it("logs in programmatically without using the UI", function () { // destructuring assignment of the this.currentUser object const { username, password } = this.currentUser; // programmatically log us in without needing the UI cy.request("POST", "/login", { username, password, }); // now that we're logged in, we can visit // any kind of restricted route! cy.visit("/dashboard"); // our auth cookie should be present cy.getCookie("your-session-cookie").should("exist"); // UI should reflect this user being logged in cy.get("h1").should("contain", "jane.lane"); });
その他
こちらの Best Practicesも参考になる。