Roll With IT

tamakiのIT日記

Cypress から Playwrightに乗り換えた

はじめに

Next.jsとRailsを使ってオリジナルのWebアプリを開発しています。

github.com

当初はE2Eテストを実装するためにCypressを選択しましたが、Google認証が突破できずに導入を断念しました。その後、Playwrightを導入しGoogle認証を突破できたので、今回の記事はその経緯をまとめた内容となります。

Cypressの導入から使用を断念するまでの経緯

Cypress を選択した理由

周りの先輩プログラマが多く採用していること、またテストライブラリでは現時点で一番人気で情報も多いことが選択した理由です。今回フロントエンドのE2Eテストが初めてなのもあり、機能面や各ライブリの特徴、書き心地など拘りはなく、学習コストが低く最速でE2Eテストが書け、かつメンテナンスも定期的に行われており、問題が起きにくいことだけをポイントに選択しました。

Cypressをインストールする

$ npm install cypress --save-dev

動作確認する

各種設定後にサンプルテストで動作確認を行います。

まずはチュートリアル等を参考にサンプルのテストを書いてみました。問題なく動作しテストが書けることを確認しました。

Cypressに持った第一印象は、「Jest、RSpecと同じような形式の構文なのでわかりやすい」でした。Cypress Studioという便利なツールもあり、「超便利!」とテンションが上がり、この時点ではCypress一択で他は検討する余地もないなと思っていました。

ハマった内容

私が開発しているアプリは、next-authを利用しGoogle 認証ログイン機能を実装しています。

shirotamaki.hatenablog.com

そのため、E2Eテストを行う上で、まずはGoogle 認証を行いログインする必要があります。ログインが成功しないとアプリの機能を利用できない仕様になっており、まずは最優先でGoogle認証ログインを突破する必要がありました。

しかし簡単そうに見えたこの問題が予想以上に難しく、Google 認証ログインが突破できずハマってしまいました。

試したこと

E2Eテストの目的は、あくまでアプリの使用フロー(ユーザーが実際に操作する部分)をシミュレーションすることです。next-authが提供しているGoogle認証自体はGoogleが提供しているサービスなのでテストは不要と判断しました。この方針を元にログインすることを目的に解決策を探してみました。

Best Practice: Only test websites that you control. Best Practices | Cypress Documentation

試したこと:その1

まずは公式サイトに掲載されているGoogle Authentication の内容を参考に試しました。

docs.cypress.io

カスタム Cypress コマンドを使用してプログラムで Google で認証する方法について書かれています。Cypressの各テストは、同じオリジンのドメインを訪問するように制限されているため、サードパーティGoogle)のログインページを訪問してテストすることは、サードパーティ(Google) の認証APIとプログラムでやり取りしてユーザーをログインさせることで回避できるようです。しかし、ドキュメントの内容に倣い、Google OAuth 2.0 Playground を使用しリフレッシュトークンを取得し、認証情報の設定など行いましたが動作しません。

タイトルから自分が今抱えている問題に対する解決策だと思われましたが上手くいきませんでした。

試したこと:その2

next-authのドキュメント

next-auth.js.org

cypress-social-logins - npmを使用することで、Cyptessをテストできるとあります。しかしnpmのサイトを見ると「CI環境ではうまく動作しません」とありました。

This plugin doesn't work well in a CI environment, due to the anti-fraud detection mechanisms employed by the likes of Google, GitHub etc.

CI環境で動作しないのは問題ではないか?とモヤモヤしながらもまずは試してみることに。しかしここでも問題は解決できませんでした。

試したこと:その3

自分と同じ問題を抱えているIssueを発見!

github.com

I followed this documentation and it isn't quite enough for me to figure out how to use Cypress with NextAuth: Testing with Cypress | NextAuth.js

「ログインフローをテストするのではなく、認証ウォールの後ろに隠されたページにアクセスできるようにセッションをスタブするだけです」と、回答が付いており、まさに自分が知りたかった内容です。解決策としてサンプルコードも付いており早速試してみました。しかし、version 12.0.0 に関するエラーが頻出し問題を解決することができませんでした。

わかったこと

上記1〜3を試しましたが、Google 認証を突破することはできませんでした。しかし試したことでわかったことがあります。

現在のCypressの最新バージョン は12.17.2。バージョンが10以下、10、10以上とざっくり分けてこの3パターンで大きな違いがあることがわかりました。上記見つけた解決策と自分の環境でピッタリと合うものがないことも、問題が解決しない原因である可能性があることがわかりました。

開発環境を合わせて対応する案も検討しましたが、他で不具合が起こることを懸念し断念することにしました。

またテストコード内で異なるオリジン間へのアクセスがリジェクトされるようで、これも原因かもしれないと思いましたが、version 12で複数のオリジンに対応したようです。

結果として、明確な原因がわからないことがわかりました。

As of Cypress v12.0.0, Cypress has the capability to visit multiple origins in a single test via the cy.origin() command!

代替案を探す

「わからないことがわかりました」と、「無知の知」を気取ってみたものの、結局は何も解決していない状況なのは変わりません。

Cypressを途中まで実装していたので、またやり直しか...とテンションが下がっていましたが、気持ちを切り替え、まずは最初からフラットに考えてみることにしました。

目的はE2Eテストを実装することです。Cypressに拘る理由はありません。

Google 認証 を突破すること、まずは問題を解決するための目的を明確にし(CypressでE2Eテストするではなく、テストライブラリを利用してE2Eテストする)テストライブラリの選定から見直すことにしました。

色々調べた結果、Playwright に勝機が見えたため、Playwrightを選択から導入までの経緯についてもまとめておきたいと思います。

テストライブラリを比較検討する

Playwrightを選択することにはなりましたが、その過程で検討したライブラリの情報も簡単にまとめておきます。

  • Playwright
    • ChromiumFirefoxWebKit(Safari)を同じ API で操作できる
    • 非同期で動 作することによる、テストの高速実行。ブラウザのクラッシュやフリーズに対する強い耐性を持っている
    • MicrosoftOSS として開発が進められ、元 Puppeteer の開発チームのメンバーもいるため、Puppeteer のコードを活 用しながら改良や新機能が追加されている
    • Cypressに次いで人気があり、Cypress との比較で一番最初に名前が上がるライブラリ。
  • Puppeteer
    • Puppeteer とは ChromeChromium(Chrome のベースとなる OSS プロジェクト)を操作するための Node ライブラリ。GoogleChrome DevTools チームが開発している OSS になる。
  • Selenium
    • この中では一番古参のライブラリ。元々は Web アプリケーションの UI テストや JavaScript のテストの目的で開発されたが、テスト以外にもタスクの自動化や Web サイトのクローリングなど様々な用途で利用されている。
    • セットアップが難しい。学習コストが高い。大規模サービス向け? 高機能すぎるので選択肢からは除外。
  • TestCafe
    • WebDriverや他のサードパーティ製のプラグインは不要。
    • インストールとセットアップは容易。
    • パっと見良さそうだが情報が少ない。Microsoftにより開発、メンテナンスされているPlaywrightの方が人気があり息が長そう。

参考

Playwright に乗り換える

Playwright のセットアップ

Playwrightをセットアップします。

インストールする

$ npm init playwright@latest

オプション選択

Choose between TypeScript or JavaScript (default is TypeScript)  TypeScript
Name of your Tests folder (default is tests or e2e if you already have a tests folder in your project) e2e
Add a GitHub Actions workflow to easily run tests on CI  true
Install Playwright browsers (default is true) true

作成されるファイル

  • ./e2e/example.spec.ts - Example end-to-end test
  • ./tests-examples/demo-todo-app.spec.ts - Demo Todo App end-to-end tests
  • ./playwright.config.ts - Playwright Test configuration

その後ファイル構成を整理し以下の配置に変更しました。

├── playwright
│   ├── config
│   │   └── index.ts
│   ├── global-setup.ts
│   ├── screenshots
│   │   ├── example.png
│   ├── setup
│   │   └── storage-state.json
│   └── tests
│       └── e2e
│           ├── foobar.spec.ts
├── playwright-report
│   └── index.html
├── playwright.config.ts

VSCode拡張機能をインストールする

Playwright Test for VSCode

package.json の scripts に Playwright を追加する

// frontend/package.json

"test:e2e": "playwright test",

playwright.config.ts に baseURL を設定する

// frontend/playwright.config.ts

baseURL: 'http://localhost:4000',

参考

Playwright を使用しGoogle 認証の突破を試みる

まずは公式ドキュメントで案内されている内容に従います。

playwright.dev

Playwright は、BrowserContext と呼ばれる分離された環境でテストが実行されています。この分離されたブラウザの実行環境により再現性が向上し、連鎖的なテストの失敗が防止されています。

テストでは既存の認証済み状態をロードするできます。これにより、各テストで毎回認証する必要がなくなり、テストの実行速度が向上します。

具体的な認証手順は、認証済みのブラウザ状態を生成し、これをplaywright/.authディレクトリのファイルに保存します。その後、テストの度に認証済みのブラウザを再利用し、認証済みの状態でテストをスタートすることができます。

試してみる

必要なディレクトリやファイルを準備し、ドキュメントに倣い設定ファイルを準備します。

問題発生

しかしここで問題発生。

公式で取り扱っている実装例がGitHubの認証パターンになり、Google認証には対応していませんでした。GitHubのユーザー名やパスワードをGoogle に置き換えてみたが上手くいきません。

解決策

その後解決策を探している中でこちらの記事を見つけました。

adequatica.medium.com

自分と同じ問題に対する解決策が記載されており期待できそうなので試してみることにしました。

Google 認証のログインに成功!

見つけた解決策を元に実装を試みました。Google 認証のログインに成功しました!

APIを通じて認証する方がUIを通じて認証するよりも速いようですが非常に複雑で難しいのが難点です。

しかしここで紹介されている認証手順は至ってシンプルです。Googleのサインインフォームを通して、ユーザー名とパスワードで認証する方法になります。

まずGlobal Setup が必要になります。globalSetupに設定を加えることで、すべてのテストを実行する前に設定項目を実行することができます。

Global Setup 関数内で行う手順:

  1. puppeteer-extra-plugin-stealthを使って、playwright-extra経由で新しいブラウザページを開始する。そのためにnpmをインストールし以下設定が必要。
// frontend/playwright/global-setup.ts

import { chromium } from 'playwright-extra'
import StealthPlugin from 'puppeteer-extra-plugin-stealth'

chromium.use(StealthPlugin())
  1. テスト対象のサイトのログインページに移動する。
  2. Googleサインインフォームを開く
  3. ユーザーの認証情報を入力する
  4. テスト済みのサイトを開く(リダイレクトを待つ)
  5. storageStateメソッドでログイン状態を保存する
  6. ブラウザを閉じる

Global Setupのファイルを指定、Google認証情報を保存する場所を指定

テストをスタートする前に実行するファイルを指定します。

// frontend/playwright.config.ts

globalSetup: process.env.SKIP_AUTH ? '' : './playwright/global-setup',

Google 認証情報を保存する場所を指定します。現在のcookies とlocal storage snapshot を保存する場所になります。

// frontend/playwright.config.ts

storageState: './playwright/setup/storage-state.json',

必要なプラグインをインストールする

$ npm install playwright-extra
$ npm install puppeteer-extra-plugin-stealth

Global Setup用のファイルを用意する

global-setup.ts にGoogle 認証をログインするために必要なコードを記述します。

playwright/setup/storage-state.jsonGoogle 認証情報を保存します。

このファイルに記述した内容がテストをスタートする前に実行されるので、Google 認証ログインした状態でテストを始めることができます。

// frontend/playwright/global-setup.ts

import { chromium } from 'playwright-extra'
import StealthPlugin from 'puppeteer-extra-plugin-stealth'
import { userEmail, userPassword } from 'playwright/config/index'

chromium.use(StealthPlugin())

const baseURL = 'http://localhost:4000'

const authFile = 'playwright/setup/storage-state.json'

async function globalSetup(): Promise<void> {
  const browser = await chromium.launch({ headless: true })

  const page = await browser.newPage()

  // Open log in page on tested site

  await page.goto(baseURL)
  await page.getByText('Googleでログインして始める').click()
  // Click redirects page to Google auth form,
  // parse https://accounts.google.com/ page
  const html = await page.locator('body').innerHTML()

  // Determine type of Google sign in form
  if (html.includes('aria-label="Google"')) {
    // Old Google sign in form
    await page.fill('#Email', userEmail)
    await page.locator('#next').click()
    await page.fill('#password', userPassword)
    await page.locator('#submit').click()
  } else {
    // New Google sign in form
    await page.fill('input[type="email"]', userEmail)
    await page.locator('#identifierNext >> button').click()
    await page.fill('#password >> input[type="password"]', userPassword)
    await page.locator('button >> nth=1').click()
  }

  // Wait for redirect back to tested site after authentication
  await page.waitForURL(baseURL)
  // Save signed in state
  await page.context().storageState({ path: authFile })

  await browser.close()
}

export default globalSetup

テストで使用するブラウザを設定する

chromiumfirefoxを設定します。

デフォルトだとヘッドレスモードでブラウザを実行する設定になっています。起動オプションとしてheadless: falseを設定しておくとよいと思います。

// frontend/playwright.config.ts

  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: './playwright/setup/storage-state.json',
        headless: false,
      },
      dependencies: ['setup'],
    },

    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: './playwright/setup/storage-state.json',
        headless: false,
      },
      dependencies: ['setup'],
    },
  ],

テストを実行してみる

Google 認証ログイン後の状態からスタートできるので、問題なくE2Eテストが実行できます。

// frontend/playwright/tests/example.spec.ts

import { test, expect, Page } from '@playwright/test'

let page: Page

test.beforeAll(async ({ browser }) => {
  page = await browser.newPage()
})

test.afterAll(async () => {
  await page.close()
})

test.describe('Sing in on home page', async () => {
  test.describe.configure({ mode: 'serial' })

  test.beforeAll(async () => {
    await page.goto('/')
  })

  test('example test', async () => {
    await page.goto('/about')
    await page.waitForURL('http://localhost:4000/about')
    const content = await page.textContent('h1')
    expect(content).toContain('アウトドア')

    await page.goto('/mypage')
    await page.waitForURL('http://localhost:4000/mypage')
    const content2 = await page.textContent('li')
    expect(content2).toContain('ユーザ名:テスト太郎')

    await page.screenshot({ path: 'playwright/screenshots/example.png' })
  })
})

認証情報を保存したファイルなどは .gitignore に追加しておく

# frontend/.gitignore

# playwright
/playwright/setup/
/playwright/screenshots/
/test-results/
/playwright-report/

おわりに

フロントエンドの技術は、バックエンド以上に改善、更新されるスピードが速く感じます。今回問題で取り上げたケースでは、CypressからPlaywrightに乗り換えましたが、今後はその逆もあるかと思いますし、またより優れたライブラリの登場でまったく違う景色になっている可能性も十分に考えられます。

今回のケースが多くの方に該当するかはわかりませんが、少しでも同じ問題に直面している方の参考になれば幸いです。

最後までお読みいただきありがとうございました!