Cypress の Guides 全部読む
スクラップについて
cypress 公式ドキュメントの Guides をざ〜っと読んで、雑に要約して世界観を整理してく。
Overview: Why Cypress
一言でいうと
- Cypress はモダン Web アプリケーションのための次世代のフロントエンドテストツール
- テストのセットアップ、作成、実行、デバッグを1パッケージで可能にする
- Selenium と比較されることが多いが、基礎概念、アーキテクチャがそもそも異なるので、 Selenium と同じようなツラミが発生することもない
つまり、信頼性のあるテストを早く、簡単に書くためのツールだ
誰が使うべきか
大抵はモダンな JavaScript フレームワークを使用した Web アプリケーションの開発に関わるデベロッパー、QAエンジニアを対象とする。
Cypress は E2E テスト、結合テスト、単体テストいずれのテスト種別にも使うことができ、すべてブラウザ上で実行される。
Cypress のエコシステム
Cypress は、無料のOSSで、ローカルにインストールして使えるテストランナーとテストを記録するためのダッシュボードサービスで構成される。
まずはローカルでテストを書いて毎日手元で実行するのが良い(TDDなら尚良)
テストコードが仕上がってきたら、CIに組み込んで、ダッシュボードサービスで記録しよう。
Cypress のミッション
ミッションはOSSエコシステムを構築することで、生産性を高め、テストを楽しい体験にし、開発者の幸せを生み出すこと
主な特徴
- Time Travel: テストの各ステップ時点でのスナップショットの取得
- Debuggability: 可読性の高いスタックトレースやエラーメッセージを通じてテストが落ちる理由を簡単に確認できる
- Automatic Waiting: 非同期処理に対する待機を自動で行う
- Spies, Stubs, and Clocks: 各関数やサーバレスポンス、時刻を制御することで、常に同じ結果を得られる
- Network Traffic Control: ネットワークの状態を成業することで、エッジケースのテストも可能に
- Consistent Results: Selenium や WebDriver を使用しない、一貫性と信頼性のあるアーキテクチャ
- Screenshots and Videos: テスト失敗時にスクショ撮影や、テスト全体の動画撮影が可能
- Cross browser Testing: Firefox 及び Chrome 系ブラウザ (Chrome, Edge など) が使用可能
テストのセットアップ
Cypress にはサーバやドライバーといった外部依存のインストールや設定を一切おこならずに、60秒もあれば最初のテストを実行することができる。
yarn add -D caypress
yarn cypress open
これだけで初期生成されるサンプルテストの実行結果を確認できる
テストコード作成
Cypress で書かれたコードは、読みやすく理解しやすいことを念頭にしており、API も既に他の主要ツールで馴染み深い構成に倣っている (Jest, Chai など)
describe('My First Test', () => {
it('clicks the link "type"', () => {
cy.visit('https://example.cypress.io')
cy.contains('type').click()
})
})
テスト実行
Cypress のテストは実際のブラウザで即実行され、コードの変更を検知して自動で実行されるので、TDDに向いている。
テストのデバッグ
読みやすいエラーメッセージがある上、いつものブラウザでいつもの devtools を使用できるので、すぐに手慣れたデバッグが可能
テストの種類
Cypress はいずれの種別のテストを書くことも出来る
E2E
Cypress は元々 E2E テストのために設計されており、テストコードは実際のユーザーの挙動を意図したものになる
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)
})
コンポーネントの単体テスト
コンポーネントをマウントすることで、その単体テストもブラウザ上で可能になる
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を直接呼び出してテストすることもできうr
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 や VRT, メールテストなど、実現の幅を広げることもできる
Cypress in the Real World
RWA(Real World App) は、 Cypress が提供する、Cypress を試すためのリアルな Webアプリケーションを使ったサンプルプロジェクトである。
RWA は以下の観点のテストを全て満たしているため、自分たちが Cypress のベストプラクティスに乗っかれているかなどを確認することができる。
- 複数ブラウザ
- 複数デバイスサイズ
- VRT
- CIパイプライン
Overview: Key Differences
他のテストアプローチ(特に Selenium) と Cypress がどう違うのか
アーキテクチャ
Selenium などのテストツールはブラウザを外部で実行して、リモートコマンドを使ってネットワーク経由で非同期に操作するが、 Cypress は対象アプリケーションと同一の実行ループ上で常に同期処理される。
これにより、テスト全体のプロセスを Cypress が制御できるようになるため、以下を実現することができる。
- アプリケーションのイベントに対してよりリアルタイムに応答すること
- より高い特権を必要とするタスクをブラウザの外部で実行すること
- ネットワークレベルでの振る舞いの変更
- スクリーンショット、ビデオの撮影
- ファイルシステムへのアクセス
ネイティブアクセス
Cypress がアプリケーション内で動作するということは、window オブジェクト、DOM要素、タイマー、サービスワーカーといった、全てのオブジェクトにアクセスできるということである
新しい種類のテスト
アプリケーションへのあらゆるアクセスが可能ということは、他のツールでは不可能だった新たなテストが可能になるということである
- ブラウザやアプリケーション機能に対するスタブ
- Redux などのデータストアの書き換え
- サーバーレスポンスのモックによるエッジケース対応
- 複雑な UI 操作を変わりに行うためのコード実行
- Google Analytics を読み込まないようにする
- タイマーの変更による、固定時刻でのテスト
ショートカット
Cypress では cy.request()
といった、直接 HTTPリクエストする仕組みが備わっているため、例えばログインする際に一々ログイン画面を遷移するといった繰り返し作業を行う必要がない。 Cookie の制御や、CORS の対応といった面倒なこともやってくれる。
耐フレーク性(?)
Cypress はアプリケーションで同期的に発生する全てのことを認識、理解しており、イベントを発生させるときに要素を見逃すようなことは起こらない。
要素のアニメーションが完了することも、要素が非同期で表示されて有効化されることも認識して自動で待機する。
ページ遷移する際は、ページが完全に読み込まれるまではコマンドの停止を実行するし、明示的に特定のリクエストが終了するまで停止するように支持することも可能である。
デバッグ容易性
Cypress はなによりもその使いやすさを重視している。
テストに失敗した理由であるエラーメッセージは数百種類用意されており、現在の状態を表すビジュアルUIや、ステップごとのスナップショットが使用可能で、いつも使用している devtool もそのまま利用可能である。
トレードオフ
Cypress はこんなに凄いけど、もちろんトレードオフとなるデメリットもある。それについては Trade-offs で詳しく扱う
Getting Started
ここは基本的なインストール、実行方法がつらつら書かれてるだけなので割愛する
Core Concepts: Introduction to Cypress
Cypress Can Be Simple (Sometimes)
シンプルさこそが、少ない労力でテストを書くための全てだ。
以下コードは初見でも、 /posts/new
を開いて、フォーム入力を行って submit後、作成されたページに遷移して内容を確認しているということが直感的にわかるだろう。
比較的単純なテストではあるものの、これだけでクライアントとサーバーのどれだけのコードのテストが出来ているか考えてみよう。
describe('Post Resource', () => {
it('Creating a New Post', () => {
cy.visit('/posts/new') // 1.
cy.get('input.post-title') // 2.
.type('My First Post') // 3.
cy.get('input.post-body') // 4.
.type('Hello, world!') // 5.
cy.contains('Submit') // 6.
.click() // 7.
cy.url() // 8.
.should('include', '/posts/my-first-post')
cy.get('h1') // 9.
.should('contain', 'My First Post')
})
})
Qyerying Elements
Cypress は jQuery のようなクエリが使える。というか jQuery を内包してるので実質そのまま使える。
cy.get('.my-selector')
メソッドチェインして更に絞り込むといったことも、jQueryの通りに使える。
cy.get('#main-content').find('.article').children('img[src^="/static"]').first()
ただし注意してほしいのは、jQuery と違って、クエリの戻り値が DOM要素にはならないということだ
// jQuery は同期的に発見したDOM要素を戻す
const $jqElement = $('.element')
// Cypress のクエリは DOM要素を戻さないので、 $jqElement と $cyElement は異なる
const $cyElement = cy.get('.element')
なぜなら、 jQuery では該当する要素が見つけられない場合に Empty オブジェクトを戻すが、 Cypress の場合は自動で待機、リトライまで行う仕組みが備わっているからだ。 (内部的には Promise オブジェクトが返っている)
cy
.get('#element')
.then(($myElement) => {
doSomething($myElement)
})
この仕組によって、E2E テスト特有の以下のような状況に対しても、 DOM が現れるタイミングを考慮せずにテストを書くことができる。
- DOM がまだ読み込まれていない
- フレームワークの初期化が済んでいない
- APIリクエストのレスポンスが返ってきていない
- アニメーションが完了していない
Querying by Text Content
contains
メソッドを用いることで、jQuery よりも直感的にテキストベースでの要素取得も可能。
cy.contains('New Post') // ドキュメント全体から検索
cy.get('.main').contains('New Post') // jQuery とあわせて要素ないから検索
これを用いることで、よりユーザーに近い感覚でテストを書けるだろう。ユーザーにとって認知できるのは要素のクラス名などではなく、表示されているテキストに過ぎないのだから。
When Elements Are Missing
Cypress のクエリは非同期で待機とリトライを実行するが、 timeout を指定することで、リトライの上限を決めることができる。(デフォルトは4秒)
cy.get('.my-slow-selector', { timeout: 10000 })
Chains of Commands
Cypress のコマンドチェインは、Promise チェインを代わりに行ってくれる。
開発者が Promise を直接制御することはないが、内部で何が行われているかを意識することも重要だ
cy.get('textarea.post-body').type('This is an excellent post.')
上記の例の場合、 get
で取得された DOM 要素が type
メソッドのサブジェクトとして渡され、要素に対するキー入力が行われる。type
のほかに、以下のような操作を行うことができる。
- blur
- focus
- clear
- check
- uncheck
- select
- dblclick
- rightclick
これらのコマンドは、そのコマンドを実行する前に要素が実行可能な状態になっていることを保証する。例えば click の場合は、クリック可能な状態になっているかだ (要素が現れていて、disabled になっておらず、アニメーションも完了してる など)
Asserting About Elements
アサーションは要素の望ましい状態を宣言するためのコマンドである。Cypress はその状態に達するまで待機し、タイムアウトした場合はテストを失敗とする。
以下がアサーションコマンドの例である。
cy.get(':checkbox').should('be.disabled')
cy.get('form').should('have.class', 'form-horizontal')
cy.get('input').should('not.have.value', 'US')
Subject Management
新しいコマンドチェインの生成は、常に cy.[command]
から始まり、任意のコマンドをチェインすることができる。
ただし、 cy.clearCookies()
のような、常に NULL を戻すコマンドの場合はチェインすることができないので注意。
チェインは Promise を通じて行われるため、何らかの理由で直接チェインに干渉したい場合は、 then
を用いることで介入することができる。
cy
.get('#some-link')
.then(($myElement) => {
const href = $myElement.prop('href')
return href.replace(/(#.*)/, '')
})
.then((href) => {
// href が移行のチェインのサブジェクトになる
}
クエリの戻り値がDOM要素でないため、要素を再利用する場合はサブジェクトにエイリアスを付与する必要がある。
cy.get('.my-selector')
.as('myElement') // myElement でいつでも参照できるようにする
.click()
/* 色々なコードが間に挟まる */
cy.get('@myElement') // 過去に取得した要素を再利用する
.click()
Commands Are Asynchronous
Cypress を使う上で非常に重要なことは、 Cypress コマンドは全て非同期で実行されるということだ。コマンドを実行するとコマンドの内容がキューに積まれ、関数内のコマンドが全て積まれてから順に実行を開始する。
it('changes the URL when "awesome" is clicked', () => {
cy.visit('/my/resource/path') // エンキュ ー
cy.get('.awesome-selector') // エンキュー
.click() // エンキュー
cy.url() // エンキュー
.should('include', '/my/resource/path#awesomeness') // エンキュー.
})
// 関数実行終了後にキューから順に実行する
非同期で実行されることを念頭に置くと、以下のような同期コードを混在させると意図通り動かないことがわかる。
it('does not work as we expect', () => {
cy.visit('/my/resource/path')
cy.get('.awesome-selector')
.click()
// この時点では visit も click も行われてないので、常に [] が返ってきてしまう
let el = Cypress.$('.new-el')
})
同期コードを含める場合は、Promise を利用すること
it('does not work as we expect', () => {
cy.visit('/my/resource/path')
cy.get('.awesome-selector')
.click()
.then(() => {
let el = Cypress.$('.new-el')
// 次のサブジェクトを return する
})
})
Commands Run Serially
Cypress コマンドは関数実行終了後に、エンキューされたコマンドを順に実行する
it('changes the URL when "awesome" is clicked', () => {
cy.visit('/my/resource/path') // 1.
cy.get('.awesome-selector') // 2.
.click() // 3.
cy.url() // 4.
.should('include', '/my/resource/path#awesomeness') // 5.
})
Commands Are Promises
内部的には Promise を使用することで、前述のようなコードの実行順を制御している。
ただし単純に Promise でラップしただけでなく、リトライ能力を備えているため、 async/await
を使用することが出来ないようになっている。
Commands Are Not Promises
Cypress API は Promise が使用されているが、 Promise と同じように使うことはできず、以下のような違いがある
- 複数のコマンドを並列実行することはできない
- チェインまたは return すべきところをし忘れるということが起こらない
- コマンドの失敗に対して catche をチェインすることができない
Cypress コマンドの多くは、ブラウザの状態を書き換える冪等性のない処理を多く含んでいるため、並列実行を許すと結果に一貫性のないテストが出来てしまうため、それを避けるために厳格な制約を設けている。
Cypress コマンドが失敗した場合に、そこからエラー回復をする組み込みコマンドは存在しない。コマンドは全て成功するか、失敗した場合その時点でテストが失敗して終了する。
Assertions
Cypress におけるアサーションは、要素/オブジェクト/アプリケーションの望ましい状態を記述することである。
これは他のテストツールと比べるとユニークな定義だが、 Cypress のコマンドには自動リトライが備わっていることから、開発者の主張に対してテストコードの振る舞いが変わることを意味する。
Assertoing in English
Cypress のアサートは、英語を書くように記述することができる。
cy.get('button').click().should('have.class', 'active')
上記は、 button
要素をクリックすると、active
クラスが付与されることの記述である。
APIリクエストの場合も、以下のように英語っぽく記述できる。
cy.request('/users/1').its('body').should('deep.eq', { name: 'Jane' })
When To Assert?
Cypress はアサーション関数を大量に提供するが、実は最良のテストはアサーションを一切使わない。
cy.visit('/home')
cy.get('.main-menu').contains('New Project').click()
cy.get('.title').type('My Awesome Project')
cy.get('form').submit()
上記コードにはアサーションが含まれていないが、各コマンドを実行することで以下が保証されている。
-
/home
から正常なレスポンスが返ってきている -
.main-menu
という要素がNew Project
というテキストを持ち、クリックできる -
.title
という要素にテキストを入力できる -
form
要素で submit できる
Default Assertions
多くのコマンドは、内部でデフォルトアサーションの仕組みを持っているため、明示的なアサーションは不要である。
- visit:
text/html
形式のデータがステータス200 で返ってくること - request: リモートサーバーが存在し、レスポンスが返ってくること
- contains: 対象の要素が存在すること
- get: 対象の要素が存在すること
- type: キー入力が可能な状態になっていること
- click: クリック可能な状態になっていること
- its: 対象の要素が存在すること
DOMに基づくコマンドは基本的にアサーション失敗時にリトライ処理を行うが、 request など、一度の失敗で終了するコマンドのあるので注意 (request はリトライの冪等性がないため)
デフォルトアサーションと真逆のアサーションを使用したい場合は、 not
アサーションを使用する。 例えば要素が存在しないことが望ましい場合は、以下のようにする。
cy.get('#modal').should('not.exist')
List of Assertions
Cypress のアサーションは、 Chai, Chai-jQuery, Sinon-Chai を用いる。これらを使用した経験のある人なら、すんなり使いこなすことが出来るだろう。
Writing Assertions
アサーションの記述にはいくつか方法がある。
通常は should
を用いて、サブジェクトの内容を評価する。その際、 and
をチェインすることで複数のアサーションを記述できる
cy.get('#header a')
.should('have.class', 'active')
.and('have.attr', 'href', '/users')
expect
を使うことで、サブジェクトを明示してアサーションを記述できるが、特定の用途でしか使用することはないだろう。
cy.get('tbody tr:first').should(($tr) => {
expect($tr).to.have.class('active')
expect($tr).to.have.attr('href', '/users')
})
Explict Subjects
expects
を使用したアサーション (Explict Subjects) は、単体テストで用いられることが多いだろう。
ただし、 should
は自動リトライの仕組みで繰り返し実行されるため、この中で冪等性のない副作用のあるコードを書かないように気をつけよう。
Timeouts
全てのコマンドは同じようなタイムアウトの仕組みを持っている。たとえそれがデフォルトアサートであろうとなかろうと。
Applying Timeouts
コマンドチェインした際のタイムアウトは、各アサーションごとに適用される
cy.get('.mobile-nav').should('be.visible').and('contain', 'Home')
上記の場合、以下の3種類のタイムアウトが適用される
-
.mobile-nav
が出現するまで最大4秒待機 - サブジェクトが visible になるまで最大4秒待機
- サブジェクトが Home というテキストを持つまで最大4秒待機
タイムアウト値の明示した場合、移行のチェインしたコマンドにも適用されるので注意。以下の場合は、チェインした全てのコマンドにも10秒のタイムアウトが設定される
cy.get('.mobile-nav', { timeout: 10000 })
.should('be.visible')
.and('contain', 'Home')
Default Values
デフォルトのタイムアウト値は、コマンド種別によって異なる。一般的に時間がかかるとされる操作については、長めに設定されているため、不都合が生じない限りは明示的な指定は不要そうだ
Core Concepts: Writing and Organizing Tests
この章で扱うこと
- テストコードとそのファイルをどのような構造で管理するか
- どんな言語でテストコードを書けるのか
- 単体テストと結合テストをどのように扱うのか
- テストをどのようにグルーピングするのか
Folder structure
プロジェクトに Cypress を導入すると、自動的に推奨される以下のようなフォルダ構成でサンプルテストが生成される
/cypress
/fixtures
- example.json
/integration
/examples
/1-getting-started
- todo.spec.js
/2-advanced-examples
- actions.spec.js
- (以下様々なユースケースでのサンプルファイルが続く)
/plugins
- index.js
/support
- commands.js
- index.js
Configuring Folder Structure
フォルダ構成のルール(どのファイルがどのフォルダにあるか) は開発者が任意にカスタマイズできるが、特に理由がない限りはデフォルトの設定を使うことが推奨されている
Test files
テストファイルはデフォルトでは cypress/integration
に配置される。テストコードは js
jsx
ts
tsx
coffee
cjsx
などが使用可能。
Cypress はデフォルトで ES2015 をサポートしているため、 import
export
によるモジュールを利用することができる。
Fixture Files
テストコードから使用する静的ファイルは、 cypress/fixtures
に配置する。
これらのファイルは多くの場合、 cy.fixture()
関数を使用し、ネットワークリクエストにファイルを付与するために使われる
Asset Files
テスト実行中に生成されたアセットを含む、テスト実行後に生成される可能性のあるフォルダがいくつかあるが、これは .gitignore
に入れておいて良い
Assest Files には以下のようなものがある
- downloads: テスト中にダウンロードしたファイル
- screenshots: テスト中に撮影したスクリーンショット
- videos: テスト中に撮影した動画ファイル
Plugins file
プラグインは、プロジェクト読み込み時、ブラウザ起動時、テスト読み込み時などのタイミングで、 Node上で実行されるスクリプトのこと。テストコード自体はブラウザ上で走るという違いがある。
Node上で実行されるため、ファイルアクセスなどのOSレベルの操作や、テストコード自体に対するプリプロセッサとして機能させることができる。
Support file
サポートファイルは、各テストファイルの実行前に自動で呼び出されるため、全てのテストで共通の設定などを記述するのに役立つ
Writing tests
Cypress は Mocha と Chai をベースにしたテストコードを書くことができるので、それに親しんでいるならすぐに使いこなせるだろう。
Test Structure
テストコードの構造は Mocha と同様で、 describe
context
specify
it
を用いて構造を組み立てる。
describe('Unit test our math functions', () => {
context('math', () => {
it('can add numbers', () => {
expect(add(1, 2)).to.eq(3)
})
specify('can multiply numbers', () => {
expect(multiply(5, 4)).to.eq(20)
})
})
})
Hooks
Mocha 同様に、 brefore
beforeEach
after
afterEach
によるフックが可能
before(() => {
// スコープ全体の実行前に実行
})
beforeEach(() => {
// スコープ内の各テストごとに実行前に実行
})
afterEach(() => {
// スコープ内の各テストごとに実行後に実行
})
after(() => {
// スコープ全体の実行後に実行
})
Excluding and Including Tests
it
を it.only
に書き換えることで、同一スコープ上でそのテストのみを実行できるようになる
it('テスト1', () => {
})
it.only('テスト2, () => {
// テスト2 のみ実行されるように
})
it('テスト3', () => {
})
逆に、 it.skip
とすることで、どのテストの実行を省略することができる
it('テスト1', () => {
})
it.skip('テスト2, () => {
// テスト2 は実行されないように
})
it('テスト3', () => {
})
Test Configuration
describe
context
it
specify
の第二引数にオブジェクトを渡すことで、テストスコープレベルでの設定のカスタマイズができる。 (テスト関数は第三引数になる)
例えば以下の場合は、ブラウザが Chrome 以外の場合のみ実行するテストを記述することができる。
describe('When NOT in Chrome', { browser: '!chrome' }, () => {
it('Shows warning', () => {
cy.get('.browser-warning').should(
'contain',
'For optimal viewing, use Chrome browser'
)
})
})
Dynamically Generate Tests
テストケースは動的に生成することも可能
describe('if your app uses jQuery', () => {
;['mouseover', 'mouseout', 'mouseenter', 'mouseleave'].forEach((event) => {
it('triggers event: ' + event, () => {
cy.get('#with-jquery')
.invoke('trigger', event)
.get('#messages')
.should('contain', 'the event ' + event + 'was fired')
})
})
})
Assertion Styles
BDD形式(expect/should
) と TDD形式(assert
) の両方が利用可能
it('can add numbers', () => {
expect(add(1, 2)).to.eq(3)
})
it('can subtract numbers', () => {
assert.equal(subtract(5, 12), -7, 'these numbers are equal')
})
また、 Cypress コマンドの形式にラップすることも可能
cy.wrap(add(1, 2)).should('equal', 3)
Running tests
Cypress では、テストランナーを使って個々のテストファイルを選択、実行するのが最もパフォーマンスが良いと推奨されている。
全てのテストをまとめて実行する場合は、 Run all specs
ボタンを押下すれば良い。
また、検索ボックスからファイルを検索して、絞り込んだファイルのみを実行するといったこともできる。
Test statuses
Cypress のテストステータスは以下の4種類ある
- Passed: 全ての Cypress コマンドが成功した状態
- Failed: 途中の Cypress コマンドが失敗した状態
- Pending:
skip
を用いたことにより実行が省略された状態 - Skipped: 実行する意図があったが、スクリプト上の問題により実行されなかった状態
Watching tests
cypress open
コマンドによってテストランナーが実行されている状態の場合、 Cypress はファイルシステムの変更を検知する。
ファイル変更が検知された場合、そのファイルが自動でリロードされ、テストが実行される。
監視対象ファイルは以下(デフォルト)
- cypress.json
- cypress.env.json
- cypress/integration/
- cypress/support/
- cypress/plugins/
開発環境の条件次第では以下も対象となる場合もある
- アプリケーションコード
- node_modules
- cypress/fixtures/
ファイル監視を無効化したり、対象フォルダをカスタマイズすることも可能
Retry-ability
Cypress の大きな特徴は、動的Webアプリケーションを考慮した自動リトライ機構を持つことだ。
この章では以下を確認できる
- Cypress がコマンドとアサーションでどのようにリトライをしているのか
- コマンドがリトライするときとしないとき
- 特定のfreak発生時にどのように対処するか
Commands vs assesrtions
Cypress で呼び出せるメソッドには、 コマンド
と アサーション
に2種類がある
cy.get('.todo-list li') // command
.should('have.length', 2) // assertion
上記コードは特定の要素が2個存在することをアサートするコードだが、現代の動的な Webアプリケーションにおいては、以下のような理由で、これらを同期的に実行しても意図する結果は得られない。
- アプリケーションがDOMに変更反映を完了してるとは限らない
- アプリケーションがバックエンドからのレスポンスを待機してる状態かもしれない
- アプリケーションがDOMを描画する前に高負荷なロジックを実行しているかもしれない
そのため、Cypress のコマンド/アサーションはリトライ機構を持ち、完了してから次のコマンド/アサーションを実行するようになっている。
Multiple assertions
一つのコマンドに対して、複数のアサーションをチェインしている場合、コマンドはリトライのたびに全てのアサーションを再実行する。
例えば2個のアサーションがチェインされていて、1個目はパスしたが2個目で失敗した場合、リトライ時にはもう一度1個目から再実行される。
Not every command is retried
Cypress がリトライを行うコマンドは、DOMに対するクエリに関連するもの (get, find, contains など) に限る。
例えば click
は、リトライしてしまうとアプリケーションの状態を意図せず書き換えてしまう可能性があるため、リトライが行われないようになっている.
Built-in assertions
Cypress のコマンドにはビルトインのアサーションが含まれてるという話。
以前の章でも触れてるので割愛
Timeouts
デフォルトのタイムアウト時間は4秒。
これは CLI オプションや、各コマンドのオプションで明示的に変更が可能。
なお、タイムアウトを0 にすることで、実質リトライを無効化することができる。
これは SSR などのように、同期で即時描画される要素に対して役立つ
cy.get('#ssr-error', { timeout: 0 }).should('not.exist')
Only the last command is retried
Cypress は様々な実装上の理由から、 アサーション前の最後のコマンドしかリトライしない ことは覚えておく必要がある。
Use retry-ability correctly
Only the last command is retried
を踏まえて、不安定なテストを作らないために以下のような工夫をすると良い
- 複数のクエリを一つにまとめる
- コマンドとアサーションを交互に実行する
-
.should()
のコールバックを使用する - エイリアスを利用する
Core Concepts: Interacting with Elements
この章では以下を扱う
- Cypress が 可視性(visibility) をどう計算しているか
- Cypress が要素の actionable をどう判定しているか
- Cypress が要素のアニメーションをどう扱っているか
- Cypress がこれらのイベントをどう強制しているか
Actionability
click
type
check
select
などのコマンドは、実際のユーザーの操作をシミュレーションして、DOMに対してインタラクティブな操作を行う。
Cypressは、これらのコマンドを実行する前に、要素がそのイベントを受け入れる状態になっているかを監視し、それまで自動で待機してくれる。
チェックする項目は以下の通り
- Visibility
- 要素が画面に表示されていること
-
width
,height
が 0でない -
visibility: hidden
display: none
でない - 親要素の領域から溢れて隠れていない
-
- 要素が画面に表示されていること
- Disabillity
- 要素が disabled になっていない
- Detached
- 要素が DOM 内にアタッチされている
- Readonly
- 要素が readonly になっていない
-
.type()
でのみチェックされる
-
- Animations
- アニメーションが進行中でない
- 要素の位置の変化を元にアニメーションかを判定する
- 判定のしきい値は設定することができ、最適化することでテストのパフォーマンスを上げることも出来る
- アニメーションが進行中でない
- Covering
- 要素が他の要素に覆われていないか
- Scrolling
- 要素が画面上に映るように自動でスクロールする
- 他の要素に覆われている場合にも調整される
Core Concepts: Variables and Aliases
この章で扱うこと
- 非同期コマンドがどう扱われるか
- エイリアスでどうやってコードをシンプルにするか
- Cypress で変数を使う機会がないのは何故か
- オブジェクトや要素、ルートに対してエイリアスを設定する方法
Return Values
Cypress を初めて使う人にとって、その非同期APIは扱いづらいものと感じるだろうが、一度慣れてしまえば同期APIで出来る全てのことを非同期APIで出来ることに気づくだろう。
Cypress の API が非同期なのは、JavaScript の世界になじませるためだ。事実、ほとんどのブラウザAPIやnodeモジュールは非同期で実装されている。
API が非同期であるということは、戻り値を直接使用することができないということだ。
const button = cy.get('button')
const form = cy.get('form')
// button や form には要素オブジェクトが代入されるわけではないので意図通り動かない
button.click()
要素にアクセスしたい場合は .then()
を使って非同期で結果を受け取るようにする
cy.get('button').then(($btn) => {
// $btn には直前のコマンドの結果がはいってくる
})
// ↑の then が完了しない限り↓はキューに積まれたまま実行されない
cy.get(...).find(...).should(...)
thenを用いることで、直前に取得した要素を参照できるため、Cypress で
const
let
var` といった変数宣言を使うことは殆ど無いだろう。
強いて言えば状態が変わったことを比較するために一次変数を使用することが考えられる。
cy.get('#num').then(($span) => {
const num1 = parseFloat($span.text()) // click 前のテキスト
cy.get('button')
.click()
.then(() => {
const num2 = parseFloat($span.text()) // click 後のテキスト
expect(num2).to.eq(num1 + 1) // テキストがインクリメントされてることを確認
})
})
Aliases
例えば beforeEach
で取得された要素を、各テストシナリオでしようとしたい場合には、前述のような then
を使ったパターンでは書けないだろう。
beforeEach(() => {
cy.button().then(($btn) => {
const text = $btn.text()
})
})
it('text を参照したいけど参照する手段がない', () => {
})
そういった場合は、 .as()
を使って、エイリアスを付与することができる。
beforeEach(() => {
cy.get('button').invoke('text').as('text') // this.text で参照できるようにする
})
it('has access to text', function () {
this.text // エイリアスがついてるので参照できる
})
なお、 this
コンテキストを使用する都合、関数は function()
で定義する必要がある。 (アロー関数だと this が束縛されないため)
エイリアスを DOM要素と共に使う場合はやや使い方が異なる。これは再利用のタイミングでクエリを再実行しないと、古いDOMが取得されて不整合が起こってしまうためだ。
cy.get('table').find('tr').as('rows') // テーブルの先頭行を取得するクエリにエイリアスを付与
cy.get('@rows').first().click() // エイリアスに基づいて再取得
Core Concepts: Conditional Testing
この章では以下を扱う
- いつ条件付きテストを行うべきか
- 条件付きテストが重要になるシチュエーション
- 一般的な条件付きテストの戦略
条件付きテストとは、通常のプログラミングの分岐構造と同じように、テストシナリオを分岐させることを指す。以下のようなシチェーションで求められることが多い
- A/B テストを実施している
- 初回アクセスのユーザーにのみウィザードが表示される
- 取得された要素の内容に応じて異なるテストを実施したい
これらのテストは、最初こそ簡単に書くことができるが、容易に不安定なテスト、ランダムで失敗するテスト、エッジケースの追跡が難しいテストに変わっていく。
どのようにしてこれらを克服するか考えていこう。
The problem
条件付きテストは、アプリケーションの状態が安定している時にこそ書けるが、現代の Webアプリケーションは動的で頻繁に変更されることからそれが難しい。
人間にとっては 10ms や 100ms 内に状態が変化したとしても、それに気づかずに変化はなかったと感じますが、ロボットに取っては 10ms は膨大な時間だ。
また、人間には直感が備わっており、スピナーの状態を見てなんとなく非同期読み込みが発生していることを判断できるが、ロボットにはそれができない。
例えば以下のコードでは、 button
要素がアクティブであるかに応じたテストを記述してるが、 「active
クラスが付かない状態で安定したのか、今まさに active
クラスをつけようとする処理が走っているのか」を判断することができず、不安定なテストになってしまう。
it('active クラスの状態に応じた動作をしている', () => {
cy.get('button').then(($btn) => {
if ($btn.hasClass('active')) {
// アクティブ要素に対するテスト
} else {
// 非アクティブ要素に対するテスト
}
})
})
The situations
条件付きテストを正しく書くための唯一の方法は、DOMの状態が100%安定したと保証することだ。
アプリケーションが完全にサーバサイドでレンダリングされている場合、それだけで100% 安定していると保証できる。問題はクライアントサイドレンダリングの場合だ。
現代のたいていの Webアプリケーションは、HTTPレスポンスの読み込みが完了した時点では画面は真っ白になっており、非同期で少しずつコンテンツが描画されていく。
残念なことに、本当に 100% 非同期読み込みが完了したことを保証するには、全ての非同期プロセス (ネットワークリクエスト、 setTimeout, intervals, postMessage, async/await) が完了していることを確認する必要があり、これは非常に難しい。言い換えれば 100% 安定した条件付きテストを書くのは不可能と言える。
Test strategies
DOMが安定したことを100% 保証して条件付きテストを安定させることは不可能だが、それを緩和するための戦略はいくつかある。
- 条件付きテストを行う必要をそもそもなくす
- アプリケーションの挙動を決定論的にする
- DBなど、振る舞いを決定するソースを参照する
- 参照可能な他の領域(cookies, local storage) にデータを埋め込む
- データを DOM に埋め込む
ABテストの場合
アプリケーションが位置情報やIPアドレス、時刻などを元に振る舞いが変わるABテストを行っている場合、条件付きテストが必要になってしまうだろう。
しかし、以下のようにクエリパラメータに基づいて出し分けを行ってくれるようにアプリケーション側から配慮することで、条件付きテストを不要にすることができる。
cy.visit('https://app.com?campaign=A')
...
cy.visit('https://app.com?campaign=B')
...
cy.visit('https://app.com?campaign=C')
ほかにも、どの状態になるか一意に決定できる情報をサーバレスポンスやセッションクッキー、DOMに含めてもらうという手も考えられる。
Welcome wizard の場合
ユーザーが初回アクセスした場合にのみ、ウィザードが表示されるという場合も、同様の手法で安定したテストを行えるだろう