Cypress ノート
Cypress を少しさわった後に知っておくと捗りそうなもの。Cypress v5.3.0 で動作確認済みです。(v6 でも問題はなさそうでした。)
Cypress が何を利用しているか
Cypress はテスト実行時に複数のツールを利用しています。何かにつまづいた時、それが「Cypress の話」なのか「Cypress が利用しているツールの話」なのか切り分けて考えられると適切な情報を探しやすいかもしれません。
Cypress Driver
- Driver というライブラリがブラウザにロードされます。
cy
オブジェクトなどはこのパッケージで定義されています。
- Driver というライブラリがブラウザにロードされます。
Mocha
- テストランナーライブラリ
before
,beforeEach
などのフックについて知りたい場合は Mocha のドキュメント で確認できます
Chai
- アサーションライブラリ
.should('have.length', 3)
の'have.length'
が意味しているところ、あるいは他のアサーションのパターンについて知りたい場合は Chai のドキュメント で確認できます- オフィシャルガイドの Assertions -> Chai にも一覧があります
chai-jquery
Chai
の エクステンションです.should('not.be.checked')
のように DOM 要素 (厳密には jQuery オブジェクト) のアサーションをしたい場合は chai-jquery のドキュメント から確認できます- オフィシャルガイドの Assertions -> Chai-jQuery にも一覧があります
さらに詳しく
- Bundled Tools | Cypress Documentation
- Trade-offs | Cypress Documentation => Inside the browser
要素のクエリ
jQuery ライクに記述
jQuery と同じような記述で要素をクエリすることが可能です。
下のコードを例にすると、cy.get(...)
が jQuery の $(...)
と同じクエリ動作になります。find
, children
, first
は名前もクエリ動作も jQuery と同じです。
// Each method is equivalent to its jQuery counterpart. Use what you know! cy.get("#main-content") .find(".article") .children('img[src^="/static"]') .first();
さらに詳しく
- Introduction to Cypress | Cypress Documentation => Querying Elements
- API ドキュメント の Commands でクエリ用のコマンド (
closest
,eq
,filter
,find
,parent
parents
, ...) や DOM 操作用のコマンドが確認できます。jQuery に存在しないクエリ用のコマンド (contains
) もあります 。 - Cypress Testing Library を導入することで jQuery ライクにクエリする代わりに DOM Testing Library のメソッド (
findByText
,findByTitle
など) を利用することも可能です。
invoke
利用頻度は低いかもしれませんが invoke というコマンドを紹介します。
cy.get(".modal").invoke("show"); // Invoke the jQuery 'show' function
invoke
自体は jQuery と直接関係ない機能ですが、このように利用することで Cypress が用意していない jQuery のメソッドも簡単に実行できます。以下コードの様に invoke
を利用しないで直接 jQuery オブジェクトを扱うことも可能です。
// Not recommended cy.get(".modal").then(($modal) => $modal.show());
ここでは詳細は省きますが、両者の挙動はまったく同じというわけではありません。例えば involve
を利用した方は Cypress のリトライ機能が働きます。
contains
よく利用すると思われるコマンド、contains も紹介しておきます。
cy.get(".nav").contains("About"); // Yield el in .nav containing 'About' cy.contains("Hello"); // Yield first el in document containing 'Hello'
このように、指定したテキストが含まれている要素を返します。完全一致ではなく「含まれているかどうか」を確認します。
contains
は第 1 引数に selector を指定することもできます。この場合コマンドの結果 Yield される結果が異なります。
<nav class="nav"> <ul> <li>Top</li> <li>About</li> </ul> </nav>; cy.get(".nav").contains("About"); // Yield `<li>About</li>` cy.contains(".nav", "About"); // Yield <nav class="nav">...</nav>
実は Yield される結果だけでなくリトライの挙動も変わるのですが、リトライに関しては別のセクションで取り上げます。
アサーション
Cypress のアサーションは大きく分けると、Cypress が自動で実行するデフォルトアサーションとユーザーが記述するアサーションに分かれます。
デフォルトアサーション
Cypress のコマンドの多くはデフォルトアサーションを持っています。そのため、テストによってはアサーションがまったくない(ように見える)ことがあります。
例えば "New Project"
というテキストが存在することをテストしたい場合以下のように書きます。
- ✅
cy.contains("New Project");
- ⚠️
cy.contains("New Project").should("exist");
.should("exist")
が不要
以下のコード内のコマンド visit
, get
, contains
, click
, type
これらは全てデフォルトアサーションを持っています。
// https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#When-To-Assert cy.visit("/home"); // expects the page to send text/html content with a 200 status code. cy.get(".main-menu") // expects the element to eventually exist in the DOM. .contains("New Project") // expects the element with content to eventually exist in the DOM. .click(); // expects the element to eventually be in an actionable state. cy.get(".title").type("My Awesome Project"); // expects the element to eventually be in a typeable state. cy.get("form").submit();
各コマンドがどのような内容をアサートするかは API ドキュメントの Rules に記述されています。
DOM が存在しなことをテストする
cy.get(".does-not-exist").should("not.exist");
デフォルトアサーションは便利ですが、常にアサートが実行されてしまうと存在しないことのテストができなくなります。例えば上の例では cy.get(".does-not-exist")
の時点でエラーになってしまいます。 .should("not.exist")
に到達することができません。
ですが、実はこのサンプルは問題なく動きます。.should()
コマンドをつなげると、デフォルトアサーション .should('exist')
がスキップされる仕様になっているからです。
さらに詳しく
- Introduction to Cypress | Cypress Documentation => Default Assertions
アサーションを書く
デフォルトアサーションは便利ですが、それだけでは求めるテストが書けない場合もあります。そのような場合は shoud
, and
, expect
を利用します。(and
は should
のエイリアスです。)
公式ドキュメントでは should
と and
を Implicit (暗黙的な) Subjects、expect
を Explicit (明示的な) Subjects をという言葉を使って説明しています。
Implicit Subjects
should
や and
を利用してアサーションを記述します。Cypress で推奨される方法はこちらです。特別な理由がない場合アサーションを記述する場合は should
あるいは and
を利用します。
cy.get("tbody tr:first").should("have.class", "active");
should
の最初に渡す引数は chainers と呼ばれる .
でつなげた文字列です。chainers は Cypress の内部で expect(...).to.have.class('active')
のように変換されます。
記述可能な chainer は以下から確認できます。
- Assertions | Cypress Documentation => Chai
- Assertions | Cypress Documentation => Chai-jQuery
Explicit Subjects
expect
を利用してアサーションを記述します。この expect
は Chai
の expect
です。以下のサンプルコードのように should
, and
, then
のコールバック内でアサーションを記述します。同一サブジェクトに対して複数のアサーションを実行したい場合や、アサートする前に何か処理を追加したい場合に便利です。
cy.get("p").should(($p) => { // massage our subject from a DOM element // into an array of texts from all of the p's let texts = $p.map((el, i) => { return Cypress.$(el).text(); }); // jQuery map returns jQuery object // and .get() converts this to an array texts = texts.get(); // array should have length of 3 expect(texts).to.have.length(3); });
さらに詳しく
- Introduction to Cypress | Cypress Documentation => Writing Assertions
閑話: should()
, and()
, then()
の違い
この 3 つのコマンドの違いを確認します。
should()
と and()
API ドキュメントに書いてありますが and()
は should()
のエイリアスです。
ソースコードではこのように定義されています。
Commands.addAll( { type: "assertion", prevSubject: true }, { should() { // eslint-disable-next-line prefer-rest-params return shouldFn.apply(this, arguments); }, and() { // eslint-disable-next-line prefer-rest-params return shouldFn.apply(this, arguments); }, } );
エイリアスが存在することで、以下のように自然な表現でアサーションを記述することができます。
cy.get("nav").should("be.visible").and("have.class", "open");
should()
と then()
should()
はアサーションを作成できるコマンドで、then()
は前のコマンドから Yield されたサブジェクトを操作するためのコマンドです。
2 つは別の目的をもったコマンドですが、コールバック関数を利用できその中でアサーションを実行できるという点では非常に似ており、混同しやすいように思います。
// should(callbackFn) cy.get(".connectors-list > li").should(($lis) => { expect($lis).to.have.length(3); expect($lis.eq(0)).to.contain("Walk the dog"); expect($lis.eq(1)).to.contain("Feed the cat"); expect($lis.eq(2)).to.contain("Write JavaScript"); }); // then(callbackFn) cy.get("button").then(($btn) => { const cls = $btn.attr("class"); cy.wrap($btn).click().should("not.have.class", cls); });
以下はコールバック関数をした場合の両者の違いです。
- | should() |
then() |
---|---|---|
Yield | コールバック関数で返された結果は無視される。Yield するサブジェクトは変更されない。 | コールバック関数で返された内容が新しいサブジェクトとして Yield される。 undefined を返した場合は should() と同じようにサブジェクトは変更されない。 |
リトライに関する注意 | コールバック関数内のアサーションが失敗した場合、コールバック関数を再実行する。コールバック関数では、複数回実行してほしくない副作用に注意する必要がある。 | コールバック関数内のアサーションが失敗しても、コールバック関数は再実行されない。 |
さらに詳しく
- should | Cypress Documentation
- then | Cypress Documentation
- 実装は packages/driver/src/cy/commands/connectors.js ここで確認できます
コマンドのリトライ
Cypress にはコマンドをリトライする仕組みがあります。
リトライは Cypress のコア機能の一つです。リトライは自動で行われ、そして「感覚的に」正しくテストが進むように働きます。そのため意識することが少ないかもしれません。ですがこの挙動を理解しておくと、意図せずテストが失敗した時などに、その原因に気付きやすくなるかもしれません。
リトライとは何か
簡単説明すると、アサーションが失敗した時に一つ前のコマンドを再試行してくれる機能です。例えば以下のテストで「要素が 2 つ」というアサーションが失敗した場合、一定期間 (初期値は 4 秒です) cy.get
コマンドが再試行されます。
cy.get(".todo-list li") // command .should("have.length", 2); // assertion
なぜリトライするのか
最近の Web アプリケーションでは非同期な処理が一般的なため、アサーション実行時にテスト対象が想定通り描画完了しているとは限りません。描画までタイムラグがあるケースや、描画に必要なデータをバックエンドから取得中の可能性もあります。
こういったケースに、複雑な記述を必要とせず対応するため、Cypress にはリトライ機能が備わっています。
リトライされるのは最後のコマンドだけ
contains
のセクションで「リトライの挙動も変わる」と書きました。それは Cypress の仕様が「Cypress コマンドはアサーションの前の最後のコマンドのみリトライする」ようになっているからです。これが問題になるケースは Only the last command is retried で確認できます。壊れにくいテストを書く方法の一つは以下のようにクエリをマージすることです。
// 🛑 not recommended cy.get(".new-todo").type("todo B{enter}"); cy.get(".todo-list li") // queries immediately, finds 1 <li> .find("label") // retried, retried, retried with 1 <li> .should("contain", "todo B"); // never succeeds with only 1st <li> // ✅ recommended in most cases cy.get(".new-todo").type("todo B{enter}"); cy.get(".todo-list li label") // 1 query command .should("contain", "todo B"); // assertion
さらに詳しく
- 全てのコマンドにリトライ機能が備わっているわけではありません。例えば click コマンドは actionable state になるまで待機するようにできていますが、リトライはしません。デフォルトアサーション同様、各 API ドキュメントの Rules セクションで仕様を確認できます。
- リトライのより詳しい情報は Retry-ability | Cypress Documentation で確認できます
- コマンドのリトライではなく、テストケース自体のリトライ機能もあります
その他
cypress-fiddle
Cypress の動作確認用の環境をすぐにつくれるモジュールです。
https://github.com/cypress-io/cypress-fiddle
const helloTest = { html: ` <div>Hello</div> `, test: ` cy.get('div').should('have.text', 'Hello') `, }; it("tests hello", () => { cy.runExample(helloTest); });
このように HTML とテストコードを同じファイルで記述できます。ちょっとした動作確認にとても便利です。モジュールの設定も簡単です。
ブラウザのデータリセット
Cypress は各テストが実行される前にブラウザのデータをリセットします。つまり、最初のテストでログインが成功して cookie に token がセットされたとしても 2 つ目のテストでそのクッキーを利用することはできないようになっています(デフォルトの挙動では)。
describe('profile', () => { it('foo', () => { // Login cy.request(...).then((response) => { // Set a token cy.setCookie(...); }); cy.visit('/profile/foo'); cy.contains(...); cy.contains(...); }); it('bar', () => { cy.visit('/profile/bar'); // => 🚨 401 Unauthorized Error cy.contains(...); cy.contains(...); }); });
それぞれのテストは独立しているべきという考えに基づき、Cypress は自動でデータを消去してくれます。それぞれのテストで同じ処理が常に必要な場合 beforeEach
を利用することも可能です。
describe('profile', () => { beforeEach(() => { // Login cy.request(...).then((response) => { // Set a token cy.setCookie(...); }); }); // ... });
さらに詳しく
- cookie と localStorage は消去されますが sessionStorage は消去されないようです。(ただし、これに関しては意図された挙動ではないように思えます。)
- How do I preserve cookies / localStorage in between my tests?
- Cypress のドキュメントの FAQ より
cy.clock
と Vue
Cypress には時間の関係するグローバル関数を上書きする clock というコマンドがあります。
例えば、ブラウザ内の時間を常に 2020 年 10 月 10 日 にしたい場合 cy.clock(Date.UTC(2020, 10, 10), ['Date']);
のように記述します。clock
は Date
関数だけでなく setTimeout
, setInterval
など他の時間に関係する関数も上書きするので、日付を上書きしたいだけなら ['Date']
を指定した方が安全のような気がします。
少なくとも Vue 2.x では更新処理にこれらの関数を利用しているようで、['Date']
を追加しないと描画更新が行われない場合がありました。(cy.tick
を利用して時間をすすめることもできますが面倒ですよね)(React も同じかもしれませんが試していません)
beforeEach(() => { // 🛑 not recommended, because setTimeout, setInterval, etc. are also overrided // cy.clock(Date.UTC(2020, 10, 10)); // ✅ recommended cy.clock(Date.UTC(2020, 10, 10), ["Date"]); // 日本時間午前9 });
さらに詳しく
- Cypress でモックをする方法
Cypress のお作法
最後に、Cypress のテストの方針、お作法、ベストプラクティスといわれているものを簡単に紹介します。詳細は各リンク先でご確認ください。
- Best Practices | Cypress Documentation
- テストを実行するためにログインが必要な場合でも、常に UI を利用してログインをする必要はない。例えば
cy.request
を利用して処理をショートカットをする。 - 要素の選択には
data-*
属性やテキストなど、壊れにくいものを優先して利用する const
,let
,var
に Cypress コマンドの戻り値を割り当てない。コマンドが Yield する値にアクセスあるいは値を保持したい場合はthen()
を利用する。- コントロールできない外部サイトを利用することを避ける。外部サービスの利用が必要な場合は、提供されている API を
cy.request()
を利用して呼びだす。 - テストは常に独立して実行可能で、前のテストに依存しないようにする
- ユニットテストのようにテストを小さく(One Assert Per Test)書かず、複数のアサーションを記述する
- 状態リセットには
after
,afterEach
ではなくbefore
,beforeEach
を使う - など
- テストを実行するためにログインが必要な場合でも、常に UI を利用してログインをする必要はない。例えば
- Testing Your App | Cypress Documentation
- ローカル開発環境に対して Cypress を実行するメリット、サーバーにシードデータを用意させる方法 (
cy.exec()
,cy.task()
,cy.request()
)、サーバーのスタブ、ログインフローのショートカット、などについて書かれています。
- ローカル開発環境に対して Cypress を実行するメリット、サーバーにシードデータを用意させる方法 (
その他に読んでおくとよさげなガイドページ
- Variables and Aliases | Cypress Documentation
- Interacting with Elements | Cypress Documentation
- クリックなどインタラクティブな動作を行う場合、対象となる要素は操作可能 (actionable) 状態でなければなりません。このドキュメントでは、どういったコマンドが対象になるか、actionable な状態とは具体的にどのように定義されているのか、などが説明されています。