Cypress ノート

Cypress を少しさわった後に知っておくと捗りそうなもの。Cypress v5.3.0 で動作確認済みです。(v6 でも問題はなさそうでした。)

Cypress が何を利用しているか

f:id:ryotah:20201125175811p:plain

Cypress はテスト実行時に複数のツールを利用しています。何かにつまづいた時、それが「Cypress の話」なのか「Cypress が利用しているツールの話」なのか切り分けて考えられると適切な情報を探しやすいかもしれません。

  • Cypress Driver
    • Driver というライブラリがブラウザにロードされます。cy オブジェクトなどはこのパッケージで定義されています。
  • Mocha
    • テストランナーライブラリ
    • before, beforeEach などのフックについて知りたい場合は Mocha のドキュメント で確認できます
  • Chai
  • chai-jquery

さらに詳しく

要素のクエリ

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 される結果だけでなくリトライの挙動も変わるのですが、リトライに関しては別のセクションで取り上げます。

アサーション

f:id:ryotah:20201125175912p:plain

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') がスキップされる仕様になっているからです。

さらに詳しく

アサーションを書く

デフォルトアサーションは便利ですが、それだけでは求めるテストが書けない場合もあります。そのような場合は shoud, and, expect を利用します。(andshouldエイリアスです。)

公式ドキュメントでは shouldand を Implicit (暗黙的な) Subjects、expect を Explicit (明示的な) Subjects をという言葉を使って説明しています。

Implicit Subjects

shouldand を利用してアサーションを記述します。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 を利用してアサーションを記述します。この expectChaiexpect です。以下のサンプルコードのように 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);
});

さらに詳しく

閑話: 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() と同じようにサブジェクトは変更されない。
リトライに関する注意 コールバック関数内のアサーションが失敗した場合、コールバック関数を再実行する。コールバック関数では、複数回実行してほしくない副作用に注意する必要がある。 コールバック関数内のアサーションが失敗しても、コールバック関数は再実行されない。

さらに詳しく

コマンドのリトライ

f:id:ryotah:20201125180014p:plain

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(...);
    });
  });
  // ...
});

さらに詳しく

cy.clock と Vue

Cypress には時間の関係するグローバル関数を上書きする clock というコマンドがあります。

例えば、ブラウザ内の時間を常に 2020 年 10 月 10 日 にしたい場合 cy.clock(Date.UTC(2020, 10, 10), ['Date']); のように記述します。clockDate 関数だけでなく 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 のテストの方針、お作法、ベストプラクティスといわれているものを簡単に紹介します。詳細は各リンク先でご確認ください。

  • Best Practices | Cypress Documentation
    • テストを実行するためにログインが必要な場合でも、常に UI を利用してログインをする必要はない。例えば cy.request を利用して処理をショートカットをする。
    • 要素の選択には data-* 属性やテキストなど、壊れにくいものを優先して利用する
    • const, let, var に Cypress コマンドの戻り値を割り当てない。コマンドが Yield する値にアクセスあるいは値を保持したい場合は then() を利用する。
    • コントロールできない外部サイトを利用することを避ける。外部サービスの利用が必要な場合は、提供されている APIcy.request() を利用して呼びだす。
    • テストは常に独立して実行可能で、前のテストに依存しないようにする
    • ユニットテストのようにテストを小さく(One Assert Per Test)書かず、複数のアサーションを記述する
    • 状態リセットには after, afterEach ではなく before, beforeEach を使う
    • など
  • Testing Your App | Cypress Documentation
    • ローカル開発環境に対して Cypress を実行するメリット、サーバーにシードデータを用意させる方法 (cy.exec(), cy.task(), cy.request())、サーバーのスタブ、ログインフローのショートカット、などについて書かれています。

その他に読んでおくとよさげなガイドページ

  • Variables and Aliases | Cypress Documentation
    • cy.get('button') が返すオブジェクトは button 要素でも jQuery でラップされた button 要素でもありません。Cypress が Yield する 要素に直接アクセスしたい場合、Cypress のコマンドである then(Promise の then とは別物)を利用する必要があります。このドキュメントでは then の利用ケースや、.as() を利用したエイリアスという機能が説明されています。エイリアスは実行時のデータの共有に使えるだけでなく、DOM 要素, routes, requests に対して利用できます。
  • Interacting with Elements | Cypress Documentation
    • クリックなどインタラクティブな動作を行う場合、対象となる要素は操作可能 (actionable) 状態でなければなりません。このドキュメントでは、どういったコマンドが対象になるか、actionable な状態とは具体的にどのように定義されているのか、などが説明されています。