😸

E2EテストフレームワークのCypressに入門した

2022/01/08に公開

CTOの名人です。

ここ2ヶ月ほどE2EテストフレームワークのCypressに入門しているので、概要や所感を簡単にまとめてみます。

https://www.cypress.io/

E2Eテストとはなにか?

E2Eはend-to-endの略で、直訳すると「端から端まで」という意味になります。E2Eテストとは、他のテスト手法である単体テストといったコードベースのテストとは違い、実際にユーザーが体験する動作をベースにテストコードが書ける技術です。具体的にいうとCypressでは、テストコードを実行するとブラウザが立ち上がり、ブラウザ内で指定した挙動(ページを開く、要素をクリックする)のほとんどを行うことができ、またその結果として表示された文字列や遷移先のページなどをアサートできます。

PHPにおけるPHPUnitや、RubyにおけるRspec、JavaScriptにおけるJest等は、それぞれその言語で実装されたコード内でのテストに(基本的には)とどまったテストを行えますが、Cypressはアプリケーションがどんな言語やフレームワークで実装されたWebアプリケーションかは(基本的には)依存しません。つまり、言語やフレームワークがリプレイスしたり破壊的変更を伴ったとしても生き続けることができます。

Cypress以外にどんなツールがあるか

ザッと調べた範疇では、Playwrightがライバルだと感じました。

https://playwright.dev/docs/intro

playwrightの長所として大きいなと感じたのは複数のページ(タブ)を単一のテストケースでサポートできることです。

https://playwright.dev/docs/pages#multiple-pages

複数タブについてはCypressはアーキテクチャ上、永久にサポート不可能とのことで、aタグにblankが設定されることをアサートしてね、と書いています。

https://docs.cypress.io/guides/references/trade-offs#Multiple-tabs

また、CypressよりPlaywrightのほうが、Microsoft製なのでTypeScriptがout-of-the-boxで対応できるのも少々便利に思います。

逆にCypressで期待できる点はComponent Testingというコンポーネント単位でE2Eできるという衝撃的な新機能です。

https://docs.cypress.io/guides/component-testing/introduction

これまでページ単位でしかテストできないのがE2Eの常識だったと思うのですが、コンポーネント単位のE2Eという発想は、日々の開発スタイルをよりTDDに寄せやすくなるのですごい発想だなと感じています。

また、Cypressはベストプラクティスをドキュメントとして公開しており、これはドキュメントの冒頭にも「我々はドキュメントに”何”より”なぜ”を書きます」といった旨のことが書いてあるので有言実行で魅力的に思いました。

https://docs.cypress.io/guides/references/best-practices

Cypressで実際に書いてみたこと

試しに実運用中のアプリケーションに対してCypressでいくつかテストケースを書いてみたので、そのときに工夫したことを書いてみます。

本格的に書いてみるときは必ず以下のドキュメントを一読することがおすすめです。本記事では印象的なところだけ切り出して説明します。

https://docs.cypress.io/guides/core-concepts/introduction-to-cypress

This is the single most important guide for understanding how to test with Cypress. Read it. Understand it. Ask questions about it so that we can improve it.

基本的な操作

さっそくですが簡単にサンプルコードを以下に示します。トップページにフッターとヘッダーがあり、ヘッダーからログインおよび新規登録ページに遷移できることをテストしています。

import { pagesPath } from '../../client/plugins/$path'

describe('未ログインユーザーがトップページを開くと、', () => {
  beforeEach(() => {
    cy.visit('/')
  })
  it('フッターが確認できる', () => {
    cy.get('[data-cy=global-footer]').should('be.visible')
  })
  it('ログインできることがわかり、ログインページに遷移ができる', () => {
    cy.get('[data-cy=global-header] a[href="/login"]').should('be.visible').click()
    cy.urlPathMatch(pagesPath.login.$url().path)
  })
  it('新規登録できることがわかり、新規登録ページに遷移ができる', () => {
    cy.get('[data-cy=global-header] a[href="/signup"]').click()
    cy.urlPathMatch(pagesPath.signup.$url().path)
  })
})

Cypressの操作コマンドはcy.hogeで呼び出します。

Cypressの特長として、一つひとつのアサートにタイムアウトやリトライがデフォルトで設定されているので、ログインページへのリンクをクリックした後、特にコード上はSleepなどしなくても、即座にURLをアサートしてOKです。

Cypress wraps all DOM queries with robust retry-and-timeout logic that better suits how real web apps work. We trade a minor change in how we find DOM elements for a major stability upgrade to all of our tests. Banishing flake for good!

SPAは要素の表示が遅延したりページ遷移も全体のリロードではない形で行われるので、そういった時代に優しい仕様だと思いました。

ここでのurlPathMatchはカスタムコマンドというもので、自分なりに拡張したヘルパを登録することも出来ます。

support/command.tsに以下のように関数を追加します。

Cypress.Commands.add('urlPathMatch', (path: string) => {
  return cy.url().should('contain', path)
})

TypeScriptの型定義は別途追加します。

declare global {
  namespace Cypress {
    interface Chainable<Subject> {
      urlPathMatch(path: string): Chainable<any>
    }
  }
}

また、他の工夫で言うと、アプリケーション上のパスはpathpidaで管理しているので、その管理ライブラリをこっちにも引っ張ってきてURLをアサートしています。

https://github.com/aspida/pathpida

環境ごとにテストで使いたいFixtureを変える

テストする際にユーザーのIDや名前などよく使う値をハードコーディングすると保守性が心配なので、Fixtureという機能で統一するほうがよさそうです。

https://docs.cypress.io/api/commands/fixture

fixtureメソッドでJSONファイルを取得すると、結果としてJavaScriptオブジェクトになった状態で値を扱えるようになります。

ただしローカル環境とステージング環境とで違う値を利用したほうが良い場合に備えて、以下のように実行した環境に応じて値を読み込めるカスタムコマンドを作りました。

Cypress.Commands.add('getFixtureByEnv', (fixtureName: string) => {
  return cy.fixture((Cypress.env('STAGE') === 'local' ? `${fixtureName}.local` : fixtureName) + '.json')
})

ログインをテスト前に行う共通処理を作る

ログインもE2Eなので基本的にはログインのAPIを直接叩くか、ログインページに遷移して規定の情報を入力してSubmitするといった操作が原則必要です。
しかし毎回そのコードをテスト冒頭に書き込むのは面倒ですので、これもカスタムコマンドにしました。

Cypress.Commands.add('loginByFixture', (fixtureName: string) => {
  return cy.getFixtureByEnv(fixtureName).then(loginInfo => {
    cy.loginByCsrf(loginInfo.email, loginInfo.password)
  })
})

たとえば以下のようにbefore節を書くと、複数のテストケースで同じページにログインした状態で入ってテスト開始できます。

  before(() => {
    cy.loginByFixture('user-1').then(_ => {
      cy.getFixtureByEnv('user-1').then(data => {
        cy.visit(`/user/${data.id}`)
      })
    })
  })

スクリーンショットを撮る

スクリーンショットを撮影してビジュアルリグレッションテスト(VRT)もできますし、想定以上に簡単です。

以下の記事を参考にPercyというサービスにトライしてみました。一部うまくスクショできていない画像があったり、差分が一定以上ある時にこちらに通知するといった体制まで組めていない点は課題なのですが、VRTが組み込めるとかなりライブラリなどのアップデートを積極的に行えるようになりそうです。

https://zenn.dev/ryo_kawamata/articles/percy-cypress

所感

所感としては、案外簡単に実装自体はできるし、CIへの組み込みも容易だなと思いました。もっと早くチャレンジすればよかったです。

一方で実際運用する上では難しいなと思う点もいくつかあります。

  • ステージング環境で、POSTを伴ったりテストカードで決済するE2Eを毎日回すと、どんどんゴミデータが増える。どう対策するか?
  • ローカルでTDDするためにはずっとCypressを起動したままにしたいが、Chromeを立ち上げているせいかしばらくするとPCが重くなる気がする
  • できれば個々のテストは互いの実行順に依存しないようにしたいが、特定の条件がそろったユーザーによるテストなどはその条件をそろえるテストを前段に入れたくなり、長くなる。バックエンドのテストと違ってシーダーを直で実行できない。/db:seedといったエンドポイントをステージング環境以前を前提にして用意すべきなのか?

もしよかったら一緒にカジュアル面談の体で相談したり語り合っていただける方を緩募しています!よろしくお願いします〜。Playwrightとの優位性も気になる!

https://meety.net/matches/FwQdTtxAiYKU

マナリンク Tech Blog

Discussion