フューチャー技術ブログ

Cypress - 書きやすいテストの秘密と独自コマンドの実装

@testing-library/cypressの存在を知らずに、それっぽいものを作ろうとしたときにいろいろ調査した記録です。Cypressにはテストコードが縦と横に短くなる工夫がされており、そのメカニズムにしたがった独自コマンドを実装するにはコツが必要です。

実装は次のところにありますが、@testing-library/cypressの方がメンテされているので、こちらは実際には使わないのをお勧めします。

https://gitlab.com/osaki-lab/cypress-aria

Cypressのテストが縦横に短く書けるわけ

CypressはWebDriver系(Selenium)やChrome DevTool Protocol系(Puppeteer)のツールとAPIの粒度が異なります。Seleniumはそもそもウェブサイトのタイトルに「Browser Automation Tool」とありますし、PuppeteerのAPIもそれに近いです。ソースコードに書かれているコードと、ブラウザ上で動作する挙動に違いはありません。

Cypressは一言で要素探索のAPI呼び出しといっても、検索とパターンマッチを裏で高速に繰り返します。デフォルトのタイムアウトは4秒で、その間、100mS間隔ぐらいでリクエストを繰り返します。これの何がよいかというと、最近のReactやVueやAngularといった仮想DOMやIncremental DOMなソリューションと相性が良い点です。

これらのフロントエンドでは、何かしらのボタン操作やその結果のAPIアクセスの結果で、非同期で画面が更新されます。いつ画面の更新処理が終わったのか通知が来るわけではありません。例えば、ウェブサーバーに問い合わせをして、その結果を受けて画面表示する場合、WebDriverやCDP系ツールでは自分でウェイトを置いて、0.1秒待つ、といったことをします。

Cypressは次のようなメソッドチェーンの命令になっており、should("exist")が成功するまでタイムアウト時間(デフォルト4秒)までの間、100mSぐらいことにfind("button.ok")を繰り返します。find()の関数の呼び出し結果にこのDOM要素が入ってくるわけではなく、これらのAPIの裏で複雑な動きをします。そもそもJSなのにawaitを使わないで済むのはこれが実際の命令と一対一に対応していないからです。

cy.find("button.ok").should("exist");
Cypressがawaitを使わなくて済むことの説明図

明示的なwaitを書かずに済むことで、処理時間と成功率のトレードオフを考えなくても済むというメリットがあります。

まず、逐次処理のパターンではテストが失敗しないためには余裕を持った大きめの待ち時間をテストコードに書く必要があります。テストの時間を短くしようとして50パーセンタイルな時間を書けば50%は失敗するということです。そして、たいてい99パーセンタイルは50パーセンタイルよりも遥かに大きな値であることが往々にしてあります。

Cypressでは成功次第次の処理が実行されるため、毎回待ち時間の最大値で待つのに比べて、実行時間が短縮されますし、待ち時間が足りなくてテストがランダムに壊れるのを防ぐためにウェイトを調整するといった不毛な作業が減ります。また、待ち時間を忘れてテストが失敗するというわかりにくい不具合も減ります。

また、ウェイトを自分で入れる必要性があまりないのでコードが縦に短くなります。表示を待つだけならNightwatchでもSeleniumできますが、待つための余計なコードを書く必要がなく、クリックなどもすべてリトライしてくれるところがCypressの良いところです。また、逐次処理ではなく、期待する状態の宣言なので、awaitなどがいらないので横も短くなります。

ただし、waitが100%不要になるかというと、部分的には必要です。例えばレビューの星の数を設定して数が変化するテストを書こうとしているとします。星の数が設定されて更新されるまでにタイムラグがあるとすると、find()は変更前の状態で早々にマッチしてしまうため、処理が先に進んでしまいます。この場合はエラーになってしまうので処理の完了を待つcy.wait()が必要となります。

それ以外に、E2Eテストとして信頼できるテストが書けるための機能としては、きちんと人が操作できるかどうかをCSSや属性も見てチェックしてくれる点もポイントです。ブラウザの見た目の完全なシミュレートではないのですが、テストが成功したのに手動でテストしたらバグっている、みたいなことが減ります。

  • 要素がvisibleなのか?
  • disabledな要素を操作しようとしていないか?
  • readonlyがついているのにタイプしようとしてないか?
  • 親要素のスクロールとかoverflowとかで隠れていないか?
  • 他の要素に隠されていないか?

Cypressのリトライポリシーに従ったコマンドの作り方

同じ複雑なセレクターを何度も繰り返し書くのを楽にしてあげるだけであれば、コマンドを作って、その中でcy.get()cy.find()を使ってあげればOKです。

しかし、AまたBを取得、みたいなケースではうまく書かないと、Aの取得でタイムアウト、Bの取得でもタイムアウトと2倍時間がかかって、リトライの挙動が他のコマンドと違う動きになってしまいます。リトライポリシーに準拠するには、Cypressの作法に従って書く必要があります。

公式ドキュメントにはここを参上にするように、と書かれていますがピンポイントで参考にするのはちょっと難しい実装でした。これだけ見ても実装方法がよくわからなかったのでこれを読み解いた&モダンなTypeScriptの書き方を紹介していきます。

https://github.com/cypress-io/cypress-xpath/pull/12/files

ここで紹介する以外にもボタンなどさまざまな要素取得を実装したのがこちらのパッケージです。

https://gitlab.com/osaki-lab/cypress-aria

ただ、@testing-library/cypressというもっと前から開発されているパッケージがあるので、これを使う必要はありませんので、参考実装としてみていただければと思います。

これから作るコマンドの要件

aria属性に従って要素取得を実装したときは、「aria-labelがあればそれを名前とする、aria-labelledbyがあれば、参照先のDOMのテキストを名前とする、親が<label>for=で自分の要素のIDがついていたらそちらを名前とする、みたいなOR条件で要素を取得します。

まずはコマンドの枠組みです。ここに追加していきます。

index.ts
/// <reference types="cypress" />

Cypress.Commands.add( // ポイント1: コマンド登録
'ariaLink',
{ prevSubject: ['optional', 'element', 'document'] },
aria
);

// ポイント2: asyncな関数
function async ariaLink(subject, selector, options = {}) {
const resolveValue = async () => {
const value = なんらかの処理();
return cy.verifyUpcomingAssertions(value, options, {
onRetry: resolveValue, // ポイント3: 自分自身をretry対象にしつつassetする関数を定義
})
};
return resolveValue()
}

ポイントはコード中に書いた3箇所です。

コマンドのコンテキスト

Cypress.Commands.addを呼び出すところはすでに紹介しました。名前と実際に呼び出される関数以外に実行コンテキストのオプションがあります。

prevSubjectはcy.の直後に呼ばれるべきか、他のコマンドで絞り込んだあとに呼ぶのか、どちらを想定しているのかという指定です。trueなら他のコマンドの後限定、falseなら先頭限定、’optional’なら両方です。また、element/document/windowで、他のコマンドの結果としてもらえる要素を設定できます。

cy.get{prevSubject: false}, cy.find{prevSubject: true}ですね。

src/index.js
Cypress.Commands.add(
'mycommand',
{ prevSubject: ['optional', 'element', 'document'] },
mycommand
);

コンテキストの取得

コマンドは次の形式をしています。前述のコンテキストの元になるのがsubjectです。selectorは1つの文字列です。追加のオプションはオブジェクトの形式で渡します。

src/index.js
function mycommand(subject, selector, options = {}) {
})

前の設定でprevSubjectに”element”を渡したり、trueを設定した場合はsubjectからコンテキストを取り出します。コマンドの関数の先頭でcontextNodeを初期化します。要素探索を実装する場合はこのcontextNodeの中から探すようにしていけばOKです。

// コンテキストの取得
let contextNode;
let withinSubject = cy.state('withinSubject');
if (Cypress.dom.isElement(subject)) {
contextNode = subject[0];
} else if (Cypress.dom.isDocument(subject)) {
contextNode = subject;
} else if (withinSubject) {
contextNode = withinSubject[0];
} else {
contextNode = cy.state('window').document;
}

コンテキストに対応しない(常にグローバルからの取得)であればここは書かなくても良いです。

要素の取得

実際の要素の取得の処理はjQueryを使います。cy.find()やcy.get()は非同期なリトライ付きの取得でしたが、プリミティブな同期的な要素の取得はjQueryです。jQueryは動的なウェブサイトの作成ではいろいろネガティブな話も出てきていますが、DOMを変化させない、要素の取得に限定すればまだまだ便利です。

let value = Cypress.$('a, *[role="link"]', contextNode);
if (selector) {
value = value.filter(function () {
const self = Cypress.$(this);

// aria-label属性で検索
if (self.attr('aria-label') === selector) { return true; }

// aria-labelledby属性で検索
const labelledBy = self.attr('aria-labelledby');
if (labelledBy && Cypress.$(`#${labelledBy}`).text() === selector) {
return true;
}

// テキストを見て検索
if (self.text().trim() === selector) { return true; }

// 画像のalt属性を見て検索
const images = self.find(`img[alt="${selector}"]`);
return images.length > 0;
});
}

ここでは、4パターンの検索を実行して返す実装になっています。一つのセレクターの一筆書きで書けないような複雑な検索処理が行えます。

上記のresolveValue()の中身は最終的にはこうなります。

resolveValue
// コンテキストの取得
let contextNode;
let withinSubject = cy.state('withinSubject');
if (Cypress.dom.isElement(subject)) {
contextNode = subject[0];
} else if (Cypress.dom.isDocument(subject)) {
contextNode = subject;
} else if (withinSubject) {
contextNode = withinSubject[0];
} else {
contextNode = cy.state('window').document;
}

// jQueryを使って要素を取得
let value = Cypress.$('a, *[role="link"]', contextNode);
if (selector) {
value = value.filter(() => {
const self = Cypress.$(this);

// aria-label属性で検索
if (self.attr('aria-label') === selector) {
return true;
}

// aria-labelledby属性で検索
const labelledBy = self.attr('aria-labelledby');
if (labelledBy && Cypress.$(`#${labelledBy}`).text() === selector) {
return true;
}

// テキストを見て検索
if (self.text().trim() === selector) {
return true;
}

// 画像のalt属性を見て検索
const images = self.find(`img[alt="${selector}"]`);
return images.length > 0;
});
}
return cy.verifyUpcomingAssertions(value, options, {
onRetry: resolveValue,
});

ログ出力

最後ですが、必要に応じて探索中の情報のログを出しておくと、TestRunnerで問題追跡がしやすくなりますなぜ見つからなかったのか、途中経過はマッチしていたが、この情報でマッチしなくなったとか。宣言的な書き方は、失敗した時のフィードバックが弱いのでこの手のログを出してあげるのは良いと思います。

ログの出力はCypress.logで行います。

// 成功したらログを出す
const result = await resolveValue()
if (options.log !== false) {
Cypress.log({
name: 'aria',
message: selector,
});
}
return result;

まとめ

Cypressの中身で、書きやすく効率の良いテストを実現する重要な要素であるリトライポリシーについて紹介し、そのリトライポリシーに従ったプラグインの書き方も紹介しました。

  • 逐次処理なら通常のAPIを列挙すればOK
  • 取得のロジックはasyncな関数に納める
  • jQueryを使って要素を取得
  • cy.verifyUpcomingAssertions()にとってきた要素を渡して後段のアサーションを実行
  • cy.verifyUpcomingAssertions()にはこの関数自身をonRetryに渡す
    リトライは繰り返し行われるが、ループを自分で書くのではなく、CypressのAPIにonRetryに渡すことで再起的にループが行われる
  • 要素の探索する場所(コンテキスト)の処理をしたり、ログを出せば完璧

補足

本記事は、Future Tech Night #8というイベントでお話した内容を記事化したものです。
同イベントの他の発表も記事として投稿されてますので、ぜひご覧ください!