Cypressのベストプラクティスを翻訳してみた

Cypressの公式ドキュメントに「Best Practices」というページがあったので翻訳してみました。Cypressに特化したプラクティスですが、要素の見つけ方などは汎用的に役に立ちそうです。

原文: Best Practices

目次

  1. 目次
  2. Real World Practices
  3. テストの整理、ログイン、状態の制御
  4. 要素の選択
    1. 動作確認
    2. Real Worldアプリを使った例
    3. テキストのコンテンツ
    4. Cypressとテストライブラリ
  5. 戻り値の割当て
  6. 外部サイトへのアクセス
    1. ソーシャルプラットフォーム認証における潜在的な課題
    2. ログイン時の課題
    3. サードパーティーのサーバ
    4. 送信されたメールの検証
  7. テストを以前のテストの状態に依存させる
    1. 1. ひとつのテストにまとめる
    2. 2. 各テストの前に共有コードを実行する
  8. 単一のアサーションで「小さな」テストを作成する(E2Eテストのみ)
  9. afterまたはafterEachフックの使用
    1. 状態が残るのは友達
    2. マイナス面ばかりでプラス面がない
    3. ステートのリセットは各テストの前に行う
    4. 状態のリセットは必要か?
  10. 不必要な待ち時間
    1. cy.request() の不要な待ち時間
    2. cy.visit() の不要な待ち時間 (End-to-End のみ)
    3. cy.get() のための不要な待ち時間
  11. インテリジェントにテストを実行する
  12. ウェブサーバー
  13. グローバルな baseUrl の設定
    1. baseUrl を設定しないと、Cypressはメインウィンドウをlocalhost + ランダムポートにロードする
    2. Cypressの設定ファイル
    3. baseUrl が設定されると、Cypressはメインウィンドウを baseUrl にロードする
    4. baseUrlの使い方

Real World Practices

Cypressチームは Real World App (RWA) をメンテナンスしています。これは実用的で現実的なシナリオでCypressのベストプラクティスとスケーラブルな戦略を実証するフルスタックのサンプルアプリケーションです。

RWAは、複数のブラウザとデバイスサイズにわたるエンドツーエンドのテストで完全なコードカバレッジを実現するだけでなく、ビジュアル回帰テスト、APIテスト、ユニットテストを含み、効率的なCIパイプラインでそれらすべてを実行します。

アプリには必要なものがすべてバンドルされており、リポジトリをクローンするだけでテストを開始できます。

テストの整理、ログイン、状態の制御

  • アンチパターン: ページオブジェクトを共有し、UIを使ってログインする。
  • ベストプラクティス: アプリケーションにプログラムでログインし、アプリケーションの状態をコントロールする。

2018年2月に開催されたAssertJSというカンファレンスにおいて「ベストプラクティス」の講演を行いました。このビデオでは、アプリケーションを分解してテストを整理するアプローチ方法を紹介します。

リンク: AssertJS – Cypress Best Practices

私たちのサンプルには、いくつかのログイン・レシピがあります。

訳注: ログイン処理のテストだけUIを使い、それ以外はAPI経由でログインしてしまう作戦を取ると、実行時間が大きく改善されます。特にモバイルアプリのテストは遅いので改善時間も大きくなりがち。

要素の選択

  • アンチパターン: 変更の可能性がある、非常にもろい(brittle)セレクタを使うこと。
  • ベストプラクティス: data-*属性を使用してセレクタにコンテキストを与え、CSSやJSの変更から分離する。

あなたが書くすべてのテストには、要素のセレクタが含まれます。頭痛の種を減らすためにも、変更に強いセレクタを書くべきです。

しばしば、ユーザーが要素をターゲットにした問題に遭遇するのを見かけます:

  • アプリケーションが動的なクラスやIDを使用していて、それが変更されてしまう。
  • CSSスタイルやJSの挙動(behavior)による変更によって、セレクタが壊れてしまう。

幸いなことに、これらの問題を回避することは可能です。

  1. idclasstagなどのCSS属性に基づいて要素をターゲットにしない。
  2. textContentを変更する可能性のある要素をターゲットにしない。
  3. data-*属性を追加して、要素をターゲットにしやすくする。

動作確認

操作したいボタンが以下のようにあるとすると・・・:

<button
  id="main"
  class="btn btn-large"
  name="submission"
  role="button"
  data-cy="submit"
>
  Submit
</button>

どうすればこのボタンをターゲットにできるかを調査してみましょう:

セレクタ推奨度メモ
cy.get('button').click()ダメ(Never)最悪。あまりにも一般的なタグを指定しているのでコンテキスト(状況)が読めない。
cy.get('.btn.btn-large').click()ダメ(Neber)悪い。スタイルと連動するため変更の可能性が高い。
cy.get('#main').click()控えめに(Sparingly)良い。ただし、スタイルやJSのイベントリスナーと連動する可能性がある。
cy.get('[name="submission"]').click()控えめに(Sparingly)良い。ただし、HTMLの断片(semantics)となるname属性に結合している。
cy.contains('Submit').click()状況による(Depends)ずっと良い。しかし、まだ変更される可能性のあるテキストコンテンツと結合している。
cy.get('[data-cy="submit"]').click()推奨(Always)最高。すべての変更から切り離される。
tagclass、またはidを使って上記の要素をターゲットにすることは、非常に不安定であり、非常に変更の影響を受けます。要素を入れ替えたり、CSSをリファクタリングしてIDを更新したり、要素のスタイルに影響を与えるクラスの追加や削除を行ったりする可能性があります。

それらの代わりに、要素にdata-cy属性を追加すれば、テストにのみ使用される対象のセレクタが得られます。

data-cy属性は、CSSスタイルやJSの挙動の変更によって変化することはありません。つまり、要素の挙動スタイルと連動することはありません。

さらに、この要素がテストコードによって直接使用される事実を、誰にでも明確に示せます。

ご存知ですか?
Selector Playground は自動的にこれらのベストプラクティスに従います。
ユニークなセレクタを決定するとき、自動的に以下の要素を優先します。

data-cy
・data-test
・data-testid

Real Worldアプリを使った例

Real World App (RWA)では、テスト用の要素を選択するために2つの便利なカスタムコマンドを使用しています:

  • getBySelは、指定されたセレクタにマッチする data-test属性を持つ要素を生成します。
  • getBySelLikeは、指定されたセレクタを含むdata-test属性を持つ要素を取得します。
// cypress/support/commands.ts

Cypress.Commands.add('getBySel', (selector, ...args) => {
  return cy.get(`[data-test=${selector}]`, ...args)
})

Cypress.Commands.add('getBySelLike', (selector, ...args) => {
  return cy.get(`[data-test*=${selector}]`, ...args)
})

参考: cypress/support/commands.ts

テキストのコンテンツ

上記のルールを読んだ後だと、次のように疑問に思うかもしれません。

常にデータ属性を使用する必要がある場合、いつ cy.contains() を使用すればよいでしょうか?

経験則として、次のように自問してください。

要素の内容が変更された場合、テストを失敗させたいですか?

  • 答えが「はい」の場合は、cy.contains() を使用します。
  • 答えが「いいえ」の場合は、データ属性を使用します。

ボタンの<html>をもう一度見てみると・・・

<button id="main" class="btn btn-large" data-cy="submit">送信</button>

問題は、送信テキストの内容がテストにとってどの程度重要かということです。 テキストが送信から保存に変更された場合、テストを失敗させますか?

送信という単語が重要であり変更すべきではないため、答えが「はい」の場合は、cy.contains() を使用して要素をターゲットにします。 このように変更すると、テストは失敗します。

テキストが変更される可能性があるため、答えが「いいえ」の場合は、データ属性を指定してcy.get()を使用します。 テキストを「保存」に変更しても、テストは失敗しません。

Cypressとテストライブラリ

Cypressは テストライブラリプロジェクトを愛しています。 私たちは社内でテストライブラリを使用しており、私たちの哲学はテストライブラリの理念や、テスト作成のアプローチと密接に一致しています。 私たちは彼らのベストプラクティスを強く支持します。

Cypressテストライブラリパッケージを使用すると、使い慣れたテスト ライブラリ メソッド (findByRolefindByLabelTextなど) を使用して Cypressテスト内の要素を選択できます。

特に、コンポーネントのテストに推奨されるアプローチ方法を理解するためのリソースをさらに探している場合は、「Cypress コンポーネントのテスト」を参照してください。

戻り値の割当て

  • アンチパターン: コマンドの戻り値を constlet、または var で割り当てる。
  • ベストプラクティス: エイリアスとクロージャを使用して、コマンドによって得られるものにアクセスして保存する。

初めて Cypress のコードを見た多くのユーザーは、それが同期的に実行されると考えます。

新しいユーザーの場合、次のようなコードを書くのが一般的です。

// DONT DO THIS. IT DOES NOT WORK
// THE WAY YOU THINK IT DOES.
const a = cy.get('a')

cy.visit('https://example.cypress.io')

// nope, fails
a.first().click()

// Instead, do this.
cy.get('a').as('links')
cy.get('@links').first().click()
ご存知ですか?
Cypress ではconstlet、またはvarを使用する必要がほとんどありません。 これらを使用している場合は、リファクタリングを行う必要があります。

Cypress を初めて使用し、コマンドがどのように機能するかをより深く理解したい場合は、Cypress の概要ガイドをお読みください

Cypress コマンドにはすでに慣れているものの、constlet、またはvarを使用している場合は、通常、次の 2 つのうちのいずれかを行っているはずです。

  • テキストクラス属性などの値を保存して比較しようとしている。
  • beforebeforeEachのように、テストとフック間で値を共有しようとしている。

これらのパターンのいずれかを使用する場合は、変数とエイリアスのガイドをお読みください。

外部サイトへのアクセス

  • アンチパターン: あなたが制御していないサイトやサーバーにアクセスしたり、それらとやりとりしたりする。
  • ベストプラクティス: 自分が管理する Web サイトのみをテストする。 サードパーティのサーバーにアクセスしたり、サードパーティのサーバーへの要求を避ける。 必要に応じて、cy.request()を使用して、API 経由でサードパーティのサーバーと通信ができる。 可能であれば、cy.session()を介して結果をキャッシュし、繰り返しのアクセスを避ける。

多くのユーザーが最初に行おうとすることの 1つは、テストにサードパーティのサーバーまたはサービスを含めることです。

次のような状況では、サードパーティのサービスへのアクセスが必要になる場合があります。

  1. OAuth経由で、別のプロバイダーを使用する場合のログインをテストする。
  2. サーバーがサードパーティサーバーの更新を確認する。
  3. 電子メールをチェックして、サーバーが「パスワードを忘れた場合」という電子メールを送信したかどうかを確認する。

必要に応じて、これらの状況をcy.visit()cy.origin()でテストできます。 ただし、これらのコマンドは、自分の管理下にあるリソースに対してか、ドメインまたはホストされたインスタンスを制御する場合にのみ使用する必要があります。 よって以下のような使用例が一般的です。

  • ユーザー名/パスワード認証による Auth0、Okta、Microsoft、AWS Cognito などのサービスプラットフォームとしての認証。 これらのドメインとサービス インスタンスは通常、あなた、またはあなたの組織によって所有および管理されます。
  • Contentful インスタンスや WordPress インスタンスなどの CMS インスタンス。
  • あなたの管理下にあるドメイン内の他の種類のサービス。

ソーシャルプラットフォーム認証における潜在的な課題

人気のメディアプロバイダーを介したソーシャルログインなど、他のサービスの利用は推奨しません。 ソーシャルログインのテストは、特にローカルで実行する場合に機能する可能性があります。 ただし、これは悪い習慣であると考えており、次の理由からお勧めしません。

  • これは信じられないほど時間がかかり、(cy.session()を使用しない限り)テストの速度が低下する。
  • サードパーティのサイトでは、コンテンツが変更または更新されている場合がある。
  • サードパーティのサイトでは、制御できない問題が発生している可能性がある。
  • サードパーティのサイトは、スクリプトを介してテストしていることを検出し、ブロックする可能性がある。
  • サードパーティのサイトには自動ログインに対するポリシーがあり、アカウントの禁止につながる可能性がある。
  • サードパーティのサイトは、あなたがボットであることを検出し、自動化を防ぐために2要素認証、キャプチャ、その他の手段などのメカニズムを提供する可能性がある。 これは、継続的統合プラットフォームや一般的な自動化では一般的。
  • サードパーティのサイトが A/Bテストを実施している場合がある。

このような状況に対処するための戦略をいくつか見てみましょう。

ログイン時の課題

多くの OAuthプロバイダー、特にソーシャルログインは A/Bテストを実行しています。これは、ログイン画面が動的に変化していることを意味します。 これにより、自動テストが困難になります。

多くの OAuth プロバイダーもまた、それらに対して行うことができる Web リクエストの数を制限します。 たとえば、Google をテストしようとすると、Google はあなたが人間ではないことを自動的に検出し、OAuth ログイン画面を表示する代わりにキャプチャを入力させます。

さらに、OAuth プロバイダーを介したテストは変更可能です。まずサービス上で実際のユーザーが必要になり、その後、そのユーザーの内容を変更すると、ダウンストリームの他のテストに影響を与える可能性があります。

これらの問題を軽減するために選択できる解決策:

  1. cy.origin() 経由でユーザー名とパスワードを使用して、自身が制御できる別のプラットフォームを使用してログインします。これにより、ログインフローを自動化しながら、上記の問題が発生しないことが保証される可能性があります。 cy.session() を利用すると、認証リクエストの量を減らせます。
  2. cy.origin() が選択できない場合は、OAuth プロバイダーをスタブ化し、UI を使用して完全にバイパスします。 アプリケーションをだまして、OAuth プロバイダーがそのトークンをアプリケーションに渡したと信じ込ませます。
  3. 実際のトークンを取得する必要がありcy.origin() がオプションではない場合は、cy.request() を使用し、OAuth プロバイダーが提供するプログラム的なAPI を使用できます。 これらの API は変更の頻度がより低くなり、スロットリングや A/Bテストなどの問題を回避できる可能性があります。
  4. テスト コードで OAuth をバイパスする代わりに、サーバーに助けを求めます。 おそらく、OAuth トークンが行うことはデータベース内にユーザーを生成するだけでしょう。 多くの場合、OAuth は最初のみ役に立ち、サーバーはクライアントとの独自のセッションを確立します。 その場合は、cy.request() を使用してサーバーからセッションを直接取得し、cy.origin() がオプションでない場合はプロバイダーを完全にバイパスします。
レシピ
ログインのレシピにはこれを行う例がいくつかあります。

サードパーティーのサーバ

あなたのアプリケーションで行ったアクションが、他のサードパーティアプリケーションに影響を与えることがあります。このような状況はそれほど一般的ではありませんが、起こりうることです。あなたのアプリケーションが GitHub と連携していて、そのアプリケーションを使って GitHub 内のデータを変更しているケースなどが当てはまります。

そういう場合は、テストを実行した後、cy.visit() で GitHub にアクセスする代わりに、cy.request() を使って GitHub の API にプログラムで直接アクセスできます。

これにより、他のアプリケーションの UI に触れる必要がなくなります。

送信されたメールの検証

一般に、ユーザー登録やパスワード忘れなどのシナリオを実行する際には、サーバーがメールの配信を予約します。

  • 1. アプリケーションがローカルで実行されており、SMTPサーバーを介して直接メールを送信している場合、Cypress内部で動かした一時的なローカルのテストSMTPサーバーを使用できます。詳細については、「Testing HTML Emails using Cypress」を参照してください。
  • 2. アプリケーションがサードパーティの電子メールサービスを使用している場合、または SMTP リクエストをスタブできない場合は、API アクセスでテスト電子メール受信トレイを使用できます。詳細については、「Full Testing of HTML Emails using SendGrid and Ethereal Accounts」をお読みください。

Cypressは、受信したHTMLメールをブラウザで読み込んで、メールの機能とビジュアルスタイルを検証することもできます。

  • 3. 他のケースでは、cy.request()コマンドを使用して、キューに入れられたメールや配信されたメールを教えてくれるサーバー上のエンドポイントを照会してみる必要があります。そうすれば、UIを介さずにプログラムで知ることができます。これを実現するためには、サーバーはこのエンドポイントを公開する必要があります。
  • 4. また、cy.request()を使って、メールを読み取るAPIを公開しているサードパーティのメール受信サーバーにアクセスできます。その場合、適切な認証情報が必要になりますが、サーバーが提供してくれる場合もありますし、環境変数を使用することもできます。メールサービスの中には、メールにアクセスするためのCypressプラグインを提供しているものもあります。

テストを以前のテストの状態に依存させる

  • アンチパターン: 複数のテストを結合する。
  • ベストプラクティス: テストは常に、互いに独立して実行でき、なおかつパスできるようにする。

テストの結合が間違っているのか、あるいはあるテストが以前のテストの状態に依存しているのかを知るには、たったひとつのことをするだけでよいのです。

テストを it から it.only に変更し、ブラウザをリフレッシュします。

このテストが単独で実行されパスすれば・・・、おめでとうございます。良いテストが書けています。

もしそうでなければ、リファクタリングしてアプローチを変えるべきです。

これを解決する方法は以下があります:

  • 以前のテストで繰り返されたコードを、before あるいは beforeEach フックに移す。
  • 複数のテストをひとつの大きなテストにまとめる。

フォームに入力する次のようなテストを想像してみましょう。

// an example of what NOT TO DO
describe('my form', () => {
  it('visits the form', () => {
    cy.visit('/users/new')
  })

  it('requires first name', () => {
    cy.get('[data-testid="first-name"]').type('Johnny')
  })

  it('requires last name', () => {
    cy.get('[data-testid="last-name"]').type('Appleseed')
  })

  it('can submit a valid form', () => {
    cy.get('form').submit()
  })
})

上記のテストのどこが問題なのでしょうか?すべて連結されている点です!

もし最後の3つのテストのどれかをit から it.only に変えたとしたら、失敗するでしょう。それぞれのテストが合格するためには、前のテストが特定の順番で実行される必要があります。

これを修正する方法を2つ紹介しましょう:

1. ひとつのテストにまとめる

// a bit better
describe('my form', () => {
  it('can submit a valid form', () => {
    cy.visit('/users/new')

    cy.log('filling out first name') // if you really need this
    cy.get('[data-testid="first-name"]').type('Johnny')

    cy.log('filling out last name') // if you really need this
    cy.get('[data-testid="last-name"]').type('Appleseed')

    cy.log('submitting form') // if you really need this
    cy.get('form').submit()
  })
})

このテストに.onlyを設定すれば、他のテストに関係なく正常に実行できます。理想的な Cypress のワークフローは、一度に一つのテストを書いて反復する形です。

2. 各テストの前に共有コードを実行する

describe('my form', () => {
  beforeEach(() => {
    cy.visit('/users/new')
    cy.get('[data-testid="first-name"]').type('Johnny')
    cy.get('[data-testid="last-name"]').type('Appleseed')
  })

  it('displays form validation', () => {
    // clear out first name
    cy.get('[data-testid="first-name"]').clear()
    cy.get('form').submit()
    cy.get('[data-testid="errors"]').should('contain', 'First name is required')
  })

  it('can submit a valid form', () => {
    cy.get('form').submit()
  })
})

この例は理想的です。各テストの間で状態をリセットし、前のテストの内容が後のテストに漏れないようにしているからです。

また、フォームの「デフォルト」状態に対して複数のテストを書いてしまい、少しでも複雑にならないための道も開けています。そうすることで、各テストは無駄がなく、それぞれが独立して実行され、合格するのです。

単一のアサーションで「小さな」テストを作成する(E2Eテストのみ)

  • アンチパターン: ユニットテストを書いているように振る舞うこと
  • ベストプラクティス: 複数のアサーションを追加しても気にしないこと

我々はこのようなコードを書いているユーザーをたくさん見てきました:

describe('my form', () => {
  beforeEach(() => {
    cy.visit('/users/new')
    cy.get('[data-testid="first-name"]').type('johnny')
  })

  it('has validation attr', () => {
    cy.get('[data-testid="first-name"]').should(
      'have.attr',
      'data-validation',
      'required'
    )
  })

  it('has active class', () => {
    cy.get('[data-testid="first-name"]').should('have.class', 'active')
  })

  it('has formatted first name', () => {
    cy.get('[data-testid="first-name"]')
      // capitalized first letter
      .should('have.value', 'Johnny')
  })
})

技術的には問題なく実行できますが、実に過剰なアサーションであり、パフォーマンスも良くありません。

コンポーネントテストやユニットテストでこのパターンを使う理由は以下です:

  • アサーションが失敗したとき、何が失敗したのかを知るために、テストの見出しを頼りにしている。
  • 複数のアサーションを追加するのは良くないと言われ、それを真実として受け入れていた。
  • テストが素早く動作するので、複数のテストを分割してもパフォーマンス上のペナルティはない。

エンドツーエンドのテストでこれをやってはいけない理由は以下です:

  • 統合テストを書くことは、単体テストと同じではない。
  • 大規模なテストにおいても、どのアサーションが失敗したかを常に把握できる(視覚的に確認できる)。
  • Cypress は、テスト間で状態をリセットする一連の非同期ライフサイクルイベントを実行している。
  • テストのリセットは、アサーションを増やすよりもはるかに遅い。

Cypress のテストでは、30 以上のコマンドを発行するのが一般的です。ほぼすべてのコマンドに暗黙のアサーションが含まれている (そして、それゆえに失敗する可能性がある) ため、アサーションを制限しても何の節約にもなりません。

では、これらのテストをどのように書き直すべきでしょうか:

describe('my form', () => {
  beforeEach(() => {
    cy.visit('/users/new')
  })

  it('validates and formats first name', () => {
    cy.get('[data-testid="first-name"]')
      .type('johnny')
      .should('have.attr', 'data-validation', 'required')
      .and('have.class', 'active')
      .and('have.value', 'Johnny')
  })
})

afterまたはafterEachフックの使用

  • アンチパターン: 状態をクリーンアップするために afterafterEach フックを使う。
  • ベストプラクティス: テストが実行される前に、状態をクリーンアップする。

現在のテストによって生成された状態をクリーンアップするために、 after あるいは afterEach フックにコードを追加しているユーザをよく見かけます。

よく見かけるのは、次のようなテストコードです:

describe('logged in user', () => {
  beforeEach(() => {
    cy.login()
  })

  afterEach(() => {
    cy.logout()
  })

  it('tests', ...)
  it('more', ...)
  it('things', ...)
})

なぜフックを使う必要がないのかを見ていきましょう。

状態が残るのは友達

Cypress の良いところは、デバッグのしやすさにあります。他のテストツールとは異なり、テストが終了すると、テストが終了した時点のアプリケーションがそのまま残ります。

これは、テストが終了した状態でアプリケーションを使用する絶好の機会です!これによって、アプリケーションをステップ・バイ・ステップで動かす部分的なテストが書けて、テストとアプリケーションのコードも同時に書けます。

私たちは、このユースケースをサポートするために Cypress を構築しました。実際、テストが終了しても、Cypress は自身の内部状態をクリーンアップしません。私たちは、テスト終了時に状態が残ること(dangling state)を望んでいます!スタブスパイインターセプトのようなものも、テスト終了時には削除されません。つまり、Cypress のコマンドを実行している間や、テスト終了後に手動でアプリケーションを操作するときにも、アプリケーションは同じように動作します。

各テスト後にアプリケーションの状態を削除すると、状態が残ったアプリケーションを使用する能力を即座に失います。最後にログアウトすると、テスト終了時に常に同じログインページが表示されます。アプリケーションをデバッグしたり、部分的なテストを書いたりするためには、常にカスタムcy.logout()コマンドをコメントアウトします。。

マイナス面ばかりでプラス面がない

とりあえず、何らかの理由で、アプリケーションを実行するために、afterafterEachのコードがどうしても必要だと仮定してみましょう。それらのコードが実行されなければ、すべてが失われます。

それはそれで問題ありません。しかし、たとえそうだとしても、afterafterEachフックに入れるべきではありません。なぜでしょう? ここまではログアウトについて話してきましたが、別の例で考えてみましょう。たとえば、データベースをリセットする必要があるというパターンを使ってみます。

考え方は以下のようになります:

テストが終わるたびにデータベースをリセットしてレコードを 0 に戻し、次のテストが実行されるときにきれいな状態で実行できるようにしたい。

それを念頭に置いて、次のように書きます:

afterEach(() => {
  cy.resetDb()
})

ここで問題があります。このコードが実行される保証がないという点です。

もし仮に、次のテストが実行される前に、このコマンドを実行しなければならず、このコマンドを書いたのだとしたら、このコマンドを置く一番まずい場所afterafterEach フックの中です。

なぜでしょうか? なぜなら、テストの途中で Cypress をリフレッシュすると、データベースに部分的な状態が蓄積され、カスタム cy.resetDb() 関数が呼び出されなくなるからです。

この状態のクリーンアップが本当に必要な場合、次のテストは即座に失敗します。なぜでしょうか? 状態のリセットは、Cypress をリフレッシュしたときに起こらなかったからです。

訳注: わかりくいので補足。DBをリセットする行為は問題ないですが、after などにいれると、テストを途中停止したときに状態が残ってしまいます。この状態で次のテストを実行するとDBの状態が中途半端なので失敗してしまいます。こういった状況にテストが影響を受けると、テストの独立性や再現性が悪くなってしまいます。

ステートのリセットは各テストの前に行う

ここでの最も単純な解決策は、リセットコードをテスト実行前に移動することです。

たとえ既存のテストの途中で Cypress をリフレッシュした場合でも、before または beforeEach フックに入れたコードは常にテストの前に実行されます!

これは、Mocha でルートレベルのフックを使う絶好の機会でもあります。

supportFileはテストファイルが評価される前に読み込まれるため、この設定を置くのに最適な場所です。

ルートに追加したフックは、常にすべてのスイートで実行されます!

// cypress/support/e2e.js or cypress/support/component.js

beforeEach(() => {
  // now this runs prior to every test
  // across all files no matter what
  cy.resetDb()
})

訳注: Mochaは、Node.js上で動作する機能豊富なJavaScriptテストフレームワークです。

状態のリセットは必要か?

状態(state)のリセットは必要なのでしょうか? Cypressは、各テストの前にステートをクリアすることで、テストの分離を自動的に実施しています。Cypressが既に自動的にクリーンアップしている状態を、あなた自身がクリーンアップしていないことを確認してください。

クリーンアップしようとしている状態がサーバー上にある場合は、ぜひその状態をクリーンアップしてください。この種のルーチンを実行する必要があります!しかし、その状態が現在テスト中のアプリケーションに関連するものであれば、クリアする必要はないでしょう。

状態をクリーンアップする必要があるのは、あるテストが実行した操作が下流の別のテストに影響する場合だけです。そのような場合にのみ、状態のクリーンアップが必要になります。

Real Worldの例

Real World App (RWA) は、beforeEach フックで db:seed という Cypress のカスタムタスクを使ってデータベースをリセットし、シードを再作成します。これにより、各テストはまっさらな状態から開始され、確実な状態になります。例えば:

// cypress/tests/ui/auth.cy.ts

beforeEach(function () {
  cy.task('db:seed')
  // ...
})

参考: cypress/tests/ui/auth.cy.ts

db:seedタスクはプロジェクトのsetupNodeEvents関数内で定義され、この場合、データベースを適切に再シードするために、アプリの専用バックエンドAPIにリクエストを送信します。

const { defineConfig } = require('cypress')

module.exports = defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      on('task', {
        async 'db:seed'() {
          // Send request to backend API to re-seed database with test data
          const { data } = await axios.post(`${testDataApiEndpoint}/seed`)
          return data
        },
        //...
      })
    },
  },
})

参考: cypress/plugins/index.ts

上記と同じやり方は、どんなタイプのデータベース(PostgreSQL、MongoDBなど)にも使えます。この例では、リクエストをバックエンドAPIに送っていますが、直接クエリやカスタムライブラリなどを使ってデータベースと直接やりとりできます。すでにJavaScript以外の方法でデータベースを扱ったり対話したりしている場合は、cy.taskの代わりにcy.execを使ってシステムコマンドやスクリプトを実行できます。

不必要な待ち時間

  • アンチパターン: cy.wait(Number) を使って任意の時間待つ。
  • ベストプラクティス: ルートエイリアスまたはアサーションを使用して、明示的な(explicit)条件が満たされるまで Cypress が処理を続けないようにする。

Cypressでは、cy.wait() を任意の時間使用する必要はほとんどありません。このような場合、もっと簡単な方法があるはずです。

以下の例をみてみましょう:

cy.request() の不要な待ち時間

cy.request() コマンドはサーバーから応答を受け取るまで解決しないので、このコマンドのあとで待つ必要はありません。cy.request() がすでに解決した後に、ここでwaitを追加しても5秒追加されるだけです。

cy.request('http://localhost:8080/db/seed')
cy.wait(5000) // <--- this is unnecessary

cy.visit() の不要な待ち時間 (End-to-End のみ)

cy.visit() は、ページがloadイベントを発生すると解決するので待つ必要はありません。その時までに、javascript、スタイルシート、htmlを含むすべてのアセットがロードされているからです。

cy.visit('http://localhost/8080')
cy.wait(5000) // <--- this is unnecessary

cy.get() のための不要な待ち時間

cy.get() はテーブルのtrの長さが2になるまで自動的にリトライするので、以下の cy.get() の待ち時間は不要です。

コマンドにアサーションがある場合は常に、関連するアサーションがパスするまで解決しません。これによって、アプリケーションの状態を記述できます。

cy.intercept('GET', '/users', [{ name: 'Maggy' }, { name: 'Joan' }])
cy.get('#fetch').click()
cy.wait(4000) // <--- this is unnecessary
cy.get('table tr').should('have.length', 2)

あるいは、この問題に対するより良い解決策は、明示的にエイリアスされたルート(aliased route)を待つです。

cy.intercept('GET', '/users', [{ name: 'Maggy' }, { name: 'Joan' }]).as(
  'getUsers'
)
cy.get('[data-testid="fetch-users"]').click()
cy.wait('@getUsers') // <--- wait explicitly for this route to finish
cy.get('table tr').should('have.length', 2)

訳注: 1つ目のサンプルではTRタグが2になるまで待っている。これは、見出しがあったとしても1行はデータとして表示されている状態と言える。2つ目のサンプルでは取得したユーザデータが表示されるまで待っているため、より明示的な待ち方と言える。

インテリジェントにテストを実行する

テストスイートが大きくなり、実行に時間がかかるようになると、CI のパフォーマンスがボトルネックになる場合があります。すべての Cypress テストが合格するまでマージがブロックされるように、ソース管理システムとテストスイートを統合するのがお勧めです。欠点としては、テストの実行時間が長くなると、ブランチのマージや機能の出荷の速度が遅くなることです。マージ待ちのブランチが依存関係にある場合、この問題はさらに深刻になります。

この問題の解決策の1つが、Cypress Cloudによるスマート・オーケストレーションです。並列化負荷分散自動キャンセルスペック優先順位付けを組み合わせて使用すれば、スマート・オーケストレーションは利用可能なコンピュート・リソースを最大化し、無駄を最小限に抑えます。

ウェブサーバー

  • アンチパターン: cy.exec() または cy.task() を使用して、Cypressスクリプト内からWebサーバーを起動する。
  • ベストプラクティス: Cypressを実行する前にWebサーバーを起動しておく。

Cypress を使ったバックエンドWeb サーバーの起動はお勧めしません

cy.exec() または cy.task() によって実行されたコマンドは、最終的に終了する必要があります。そうしないと、Cypressは他のコマンドを実行し続けられません。

cy.exec()cy.task() からWebサーバーを起動しようとすると、次のような問題が発生します:

  • プロセスをバックグラウンドにする必要がある。
  • ターミナルからアクセスできなくなる。
  • 標準出力やログにアクセスできない。
  • テストが実行されるたびに、すでに実行されているウェブサーバーを起動する複雑さを解決しなければならない。
  • 常にポートの競合が発生する。

afterフックでプロセスをシャットダウンできないのはなぜですか?

after で実行されるコードが、常に実行されるという保証がないからです。

Cypress Test Runner で作業している間、テストの途中でいつでも再起動/リフレッシュ可能です。そのような場合、after のコードは実行されません。

その場合はどうすればよいでしょうか?

Cypressを実行する前にWebサーバーを起動し、完了後に終了させてください。

CIで実行しようとしていませんか?

Webサーバーの起動と停止の方法を示す例もあります。あわせて確認してください。

グローバルな baseUrl の設定

  • アンチパターン: baseUrl を設定せずに cy.visit() を使う。
  • ベストプラクティス: Cypress 設定に baseUrl を設定する。

設定にbaseUrlを追加することで、Cypressは cy.visit()cy.request() のようなコマンドに提供される完全修飾ドメイン名(FQDN)URLではないURLの前に、baseUrlを付加しようとします。

これにより、コマンドに完全修飾ドメイン名(FQDN)URLをハードコーディングする必要がなくなります。例えば、

cy.visit('http://localhost:8080/index.html')

は次のように短縮できます。

cy.visit('index.html')

これによって、http://localhost:8080 上の開発サーバーと、デプロイ済みの本番サーバーのドメインなど、ドメイン間で簡単に切り替えられるテストを作成できるだけでなく、baseUrl を追加することで Cypress テストの初期起動時の時間を短縮できます。

テストの実行を開始するとき、Cypress はテストするアプリの URL を知りません。そのため、Cypress は最初に https://localhost + ランダムなポートで開きます。

baseUrl を設定しないと、Cypressはメインウィンドウをlocalhost + ランダムポートにロードする

cy.visit()に遭遇するとすぐに、Cypressはメインウィンドウのurlをvisitで指定されたurlに切り替えます。これにより、テストが最初に開始されたときに「flash」や「reload」が発生する可能性があります。

baseUrl を設定することで、このリロードを完全に回避できます。Cypress は、テストが開始するとすぐに、指定した baseUrl でメインウィンドウを読み込みます。

Cypressの設定ファイル

const { defineConfig } = require('cypress')

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:8484',
  },
})

baseUrl が設定されると、Cypressはメインウィンドウを baseUrl にロードする

baseUrl を設定れば、指定したbaseUrlcypress open中にサーバーが動作していない場合にエラーが表示されるようになります。

また、cypress の実行中にサーバーが指定された baseUrl で実行されていない場合、何度か再試行してもエラーが表示されます。

baseUrlの使い方

この短いビデオでは、baseUrl の正しい使い方を詳しく説明しています。

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください