🐥

PlaywrightによるE2Eテスト自動化を導入したので各種構成とtipsをご紹介

2024/01/19に公開

株式会社シャペロンのsrkwです。
シャペロンでは昨年、Playwrightを使ったE2Eテストによる各種機能の検証の自動化を導入しました。

E2Eテスト導入の背景や、チームへの浸透に向けた取り組みと反省等については別の記事で紹介しているので、興味のある方はこちらも併せてご覧ください。

この記事では、シャペロンの日次のE2Eテスト実行を支えるインフラ構成と、PlaywrightによるE2Eテスト導入によって溜まったtipsをご紹介したいと思います。同じようにPlaywrightを使ったE2Eテスト導入を検討している、あるいは導入済みで格闘している方にとって、少しでもお役に立てると嬉しいです。

日次のE2Eテスト実行を支えるインフラ構成

前提:Shaperonのテナント構成

シャペロンは製薬企業向けのコミュニケーション用SaaS、Shaperonを提供しており、Shaperonは利用テナント(製薬企業様)ごとにテーブルおよびサブドメインを準備して利用していただいています。

https://product.shaperon-inc.com/

また、シャペロンでは複数の開発環境・検証用テナントを容易しており、いずれも本番環境と同等のスペックになっています。

  • デフォルトのdev環境・検証用テナント(チーム全体で利用)
  • 個人用のdev環境・検証用テナント(個々人の検証で利用)

E2Eテスト実行環境の構成

E2Eテスト実行時には「前のテストの影響でデータが変わってテストが落ちた」「E2Eテストの実行中にアプリケーションサーバーの負荷が増加し業務に影響が出た」などの状況を避けるため、E2Eテスト実行用のテナントを3つ設置してテストの実行を並列化しつつ、毎日朝3時からテストを実行しています。
3つというテナント数は、トレース情報閲覧用に契約しているLambdaTestというサービス(後述)の同時実行ワーカー上限数に依存しています。

E2Eテスト自体はAWS CodePipelineでスケジューリングされており、以下の流れで実行されます。

  1. seedデータをリセット
  2. E2Eテストを実行
  3. テスト完了後にCodeBuildおよびLambdaTestからSlackに通知を送信

2のE2Eテスト実行では、CodeBuild上のプロセスからWebSocket経由でLambdaTestのブラウザインスタンスに接続してテストを実行しています。テスト実行時には並列化されたプロセスごとにアクセスするテナントを振り分け、各テスト間のデータ更新等が衝突しないようにしています。

3の通知については、CodeBuildのログを見てその日のE2Eテストが成功したかどうかをSlack上で確認し、詳細なトレースログはLambdaTestを確認しにいくという運用フローになっています。

PlaywrightのダッシュボードとしてのLambdaTest

Playwrightには当時テストの実行結果を一覧で閲覧できるダッシュボードがなかったので、テスト結果確認用のダッシュボードとして使えるLambdaTestという外部サービスを導入しました。

https://www.lambdatest.com/

検討当初は同じようなテスト結果閲覧ダッシュボードの機能を提供しているサービスとしてBrowserStackと比較して検討していましたが、ドキュメント類を見る限り導入が比較的容易そうであること、費用面で優位性があったこと、Playwright開発元であるMicrosoftとの協業が発表されていてPlaywrightとの中長期的な相性が良さそうであったことなどから、LambdaTestを導入しました。

LambdaTestでは以下の添付画像のように、テストビルドごとに結果を確認することができ、各E2Eテストの挙動や、どのテストがどのように失敗したのかを動画として閲覧することができます。

ローカルでのE2Eテスト実行

ローカルで検証する際は、ログをチームに共有する必要がないので、LambdaTestは通しません。ただし数件程度の実行でも実行時間が地味に長いので並列実行しつつ、AWSでの日時実行と同じように複数のテストプロセスによるデータ更新の衝突を避けるために、ローカル環境にもE2Eテスト用のテナントを{worker数}件立ててテストを実行しています。

PlaywrightのTips

ここからは1年程度チームでE2Eテストを作成してきた中で発見されたTipsを紹介します。

POMによる実装と振る舞いの分離

E2Eテストは一般に壊れやすいものとして認識されていますが、その要因の一つに「振る舞いと実装が密に結合すると実装修正時にテストが大量に壊れる」という点があります。

userSettingTest.spec.ts
test("ユーザーのロールを変更できること", async ({ page }) => {
  const testRoleName = `test-${new Date().getTime()}`
  await page.goto("/settings/users")
  
  await page.getByTestId("UpdateSettingButton").first().click()
  await page.getByTestId("UpdateSettingModal").getByTestId("UserRoleInput").fill(testRoleName)
  await page.getByTestId("UpdateSettingModal").getByRole("button", { name: "保存" })
  
  await expect(page.getByTestId("UserRow").filter({ hasText: testRoleName })).toBeVisible()
})

上記のテストコードはユーザー一覧画面から1行目のユーザーの設定ボタンを押下してロールをテスト用のものに書き換え、保存すると画面上で変更が反映されていることを確認するテストです。このテストは以下の機会に壊れる可能性があります。

  • URLの変更
  • 各種TestIdの変更
  • ロール指定方法の変更(input -> selectなど)

また、この「ユーザーのロールの変更」を含むテストケースが複数あれば、上記の「壊れる理由」が発生したときに全てのテストコードを修正する必要があります。弊社では、実装と振る舞いを分離するための手段としてPOM(PageObjectModel)を採用しました。

poms/userSettingPage.ts
import { Locator, Page } from "playwright";

export class UserSettingPage {
  readonly page: Page;
  
  readonly userRow: Locator;
  readonly updateSettingButton: Locator;
  readonly updateSettingModal: Locator;
  readonly userRoleInput: Locator;
  readonly saveSettingButton: Locator;
  
  constructor(page: Page) {
    this.page = page;
    
    this.userRow = page.getByTestId("UserRow")
    this.updateSettingButton = page.getByTestId("UpdateSettingButton")
    this.updateSettingModal = page.getByTestId("UpdateSettingModal")
    this.userRoleInput = this.updateSettingModal.getByTestId("UserRoleInput")
    this.saveSettingButton = this.updateSettingModal.getByRole("button", { name: "保存" })
  }
  
  async goto() {
    await this.page.goto("/settings/users")
  }
  
  async updateUserRole(targetIndex: number, newRoleName: string) {
    await this.updateSettingButton.nth(targetIndex).click()
    await this.userRoleInput.fill(newRoleName)
    await this.saveSettingButton.click()
  }
}

このPOMを使ったテストコードは以下の通りです。

userSettingTest.spec.ts
test("ユーザーのロールを変更できること", async ({ page }) => {
  const testRoleName = `test-${new Date().getTime()}`
  const userSettingPage = new UserSettingPage(page)
  await userSettingPage.goto()
  
  await userSettingPage.updateUserRole(0, testRoleName)
  
  await expect(userSettingPage.userRow.filter({ hasText: testRoleName })).toBeVisible()
})

E2Eテストはユーザーが画面を触る際と同じようにテストコードを書くことが望ましく、data-testidやURLについて一般のユーザーが知る必要はありません。改修後のコードではそれらをPOMに隠蔽したことで、具体的な実装内容に依存しない検証ができています。

また、例えばユーザーのロール設定方法を現状の「文字列をフォームに入力する」という形式から「セレクトボックスからロールを選択する」という形式に変わった場合でも、POMへの修正のみでテストの改修が完了します。

poms/userSettingPage.ts
import { Locator, Page } from "playwright";

export class UserSettingPage {
  readonly page: Page;

  ...
-  readonly userRoleInput: Locator;
+  readonly userRoleSelect: Locator;
  readonly saveSettingButton: Locator;
  
  constructor(page: Page) {
    this.page = page;
    
    ...
-    this.userRoleInput = this.updateSettingModal.getByTestId("UserRoleInput")
+    this.userRoleSelect = this.updateSettingModal.getByTestId("UserRoleSelect")
    this.saveSettingButton = this.updateSettingModal.getByRole("button", { name: "保存" })
  }

  ...
  
  async updateUserRole(targetIndex: number, newRoleName: string) {
    await this.updateSettingButton.nth(targetIndex).click()
-    await this.userRoleInput.fill(newRoleName)
+    await this.userRoleSelect.selectOption(newRoleName)
    await this.saveSettingButton.click()
  }
}

POMを導入してから、実装変更に伴うE2Eテストの改修工数が激減したので、今後E2Eテストを導入する方には強くお勧めしたいです。

POMはページごとに作成する

複数のページの挙動を1つのPOMに記述すると行数が増え、POM定義ファイルの可読性が低下します。また、以下のように同一POMであるにも関わらず意図しない操作を記述できてしまう点を課題と捉え、シャペロンでは複数ページの挙動を1つの POM に記述しないことを推奨しています。

// bad
class ArticlesPage {
  ...
  
  async goToArticleListPage() {
    await this.page.goto("/articles")
  }
  
  async goToArticleDetailPage(articleId: string) {
    await this.page.goto(`/articles/${articleId}`)
  }
  
  ...
  
  async editArticleName(newName: string) {
    await this.articleEditButton.click()
    await this.articleNameInput.fill(newName)
    await this.articleSaveButton.click()
  }
}

const articlesPage = new ArticlesPage(page);
await articlesPage.goToArticleListPage(); // 記事一覧ページに遷移
await articlesPage.editArticleName("記事A"); // 記事詳細ページ内で実行することを想定しており、期待する要素が無くテストが失敗する

// good
class ArticlesListPage {
  ...
  
  async goto() {
    this.page.goto("/articles")
  }
  
  ...
}

class ArticleDetailPage {
  ...
  
  async goto(articleId: string) {
    this.page.goto(`/articles/${articleId}`)
  }
  
  async editArticleName(new Name: string) {
    await this.articleEditButton.click()
    await this.articleNameInput.fill(newName)
    await this.articleSaveButton.click()
  }
}

const articleDetailPage = new ArticleDetailPage(page);
await articleDetailPage.goto(seeds.articles.id);
await articleDetailPage.editArticleName("資材A");

このtipsはテスト記述中に気づきづらい落とし穴への対応になりますが、例えば複数のページの振る舞いをまとめたPOMのメソッド内で前提条件を満たしていることをassertするなどの別のアプローチでも解決できます。シャペロンでは「そもそもPOMが大きくなりすぎて辛い」という別の課題もあったので、今回のアプローチで解決しました。

class ArticlesPage {
  ...
  
  async editAssetName(newName: string) {
    await expect(page).toHaveUrl(this.detailPageUrlPattern)
    ...

相対セレクタの利用を避ける

相対セレクタは実装の詳細に依存しやすく壊れやすいため、dataTestId属性やaria-label属性、WAI-ARIA ロール(role属性)等を用いた Locator の利用を推奨しています。

// bad
await main.locator("[role=navigation] > div:nth-child(2) > button");

// good
await main.getByRole("Button", { name: "threads" });

test.stepを使ってテストコードの処理内容を把握しやすくする

E2E テストの一般的な課題として、テストコードが冗長で難読になりがちという点があります。比較的長いテストコードを書いた場合は、test.stepを使ってテストの処理内容を記述し、テストコードの可読性・保守性を高く保つよう努めるようにしています。

test("test case", async ({ page }) => {
  await test.step("前準備:xxx", async () => {
    ...
  })

  await test.step("AAAをbbbにする", async () => {
    ...
  })

  await test.step("cccしてZZZであることを確認", async () => {
    ...
  })
})

Playwrightではこのようにstepを定義すると、組み込みのReporterではどこで失敗したのかが少しだけ分かりやすくなります。

step無し

step有り

おわりに

色々書いてみましたが、まだまだPlaywrightの機能を使いきれておらず、これからも引き続き改善していく予定です。個人的には

あたりをチームで検証できるといいなと思っています。

PR

弊社に興味を持っていただけた方がいらっしゃいましたら、ぜひ以下のエントランスBookも見てみてください。

https://shaperon.notion.site/Engineer-Entrance-Book-59977135145f42c09f6fb19e8a3fc83e

Discussion