こんにちは、SmartHRでキャリア台帳の開発を担当しているプロダクトエンジニアのhosoyaです。 今日は、私たちがどのようにPlaywrightを使ってキャリア台帳のE2Eテストを実装しているかについてお話しします。
なぜPlaywright?
E2Eの導入・運用の検討を始めた当時、SmartHRで運用されているE2Eは、Rspec x Selenium x Capybaraが主流でした。
キャリア台帳は新規プロダクトという事もあり、新しいツールの選定をしてもよいのではないかということでチーム内での検討がはじまりました。
採用理由に関しては以前紹介された「E2Eテストを Playwright で作り直して開発プロセスに組み込む話」とほぼ被ってしまうのですが以下のような理由になります。
- PlaywrightはMicrosoftから公開されているE2Eテストフレームワーク
- 定期的な更新と新機能の追加が行われており、最新のブラウザ技術に対応している。これにより、長期的に安心して使用できるツールであると考えられる
- Auto-waiting 機能を持っており、処理が完了するのを待ってから、actionに移れる
- Flakyなテストを減らせ、安定したテストの実行ができる
- レコーディング機能
- ブラウザ操作からテストコードを生成できる機能を持っているため、チームでPlaywrightをはじめて使うメンバーでも、テストをどう表現するか分からない場合でも生成されたテストコードを元に実装イメージをつけやすい
- 環境構築が容易である
- 実行速度と立ち上がりの早さ
- 実行時間の短縮でデプロイ時間が長くなってしまう懸念の解消になる
- フロントエンドの技術スタックとマッチする
- SmartHRの多くのプロダクトではフロントエンドはTypeScriptで開発しており、PlaywrightはTypeScriptにネイティブ対応している。型定義を活用して安全で効率的なテストコードを記述できる
キャリア台帳での書き方
テストコードの再利用性と保守性の向上、はじめてE2Eテストを書く方でも、簡単に効果的なテストを実装しやすくする工夫をしています!
テスト実行前にログイン状態にする
テスト実行前にglobalSetupを利用してログイン状態にしています。
playwright.config.ts
でglobalSetup
のプロパティを設定することで、テストケースの実行前に一度だけ実行できるようになります。
globalSetup
で実行するセットアップスクリプトになります。ブラウザを起動し、指定されたURLにログインし、セッション情報を保存します。
// globalSetup.ts async function globalSetup(config: FullConfig) { const browser = await chromium.launch() const context = await browser.newContext() const page = await context.newPage() const baseUrl = config.projects[0].use.baseURL await page.goto(`${baseUrl}/login`) await page.getByLabel('メールアドレス').fill("id") await page.getByLabel('パスワード').fill('xxxx') await page.getByRole('button', { name: 'ログイン' }).click() await page.waitForURL(`${baseUrl}/*`) await page.context().storageState({ path: 'playwright/.auth/state.json' }) await browser.close() } export default globalSetup
playwright.config.ts
のglobalSetup
に上記スクリプトを指定、projects
にストレージ状態を読み込むように設定します。
// playwright.config.ts export default defineConfig({ globalSetup: './globalSetup.ts', testDir: './tests', // ... projects: [ { name: 'app tests', testDir: './tests/app', use: { ...devices['Desktop Chrome'], storageState: './playwright/.auth/state.json' }, }, ], })
リリース当初は機能も少なくこのログイン処理で問題はなかったのですが、 現状では「管理者」「機能管理者」「担当者」などテストごとに使うユーザーを分けたいため、Multiple signed in rolesへの変更を検討しています。
Page Object Models
Page Object Models(以下、POM)は、テストコードの再利用性と可読性を向上させるためのデザインパターンです。POMでは、各ページをオブジェクトとして表現し、そのページの要素、操作を定義します。specに直接Locatorを定義するより、UIの変更が発生した際にも、テストコードの修正箇所を最小限に抑えることができます。
公式ドキュメントのガイドでも紹介されてます。
たとえば、一覧ページで「検索機能」と「詳細ページへのリンク」がある場合、以下のようになります。
// ListPage.ts import { Locator, Page } from '@playwright/test' export class ListPage { readonly page: Page readonly searchInput: Locator readonly searchButton: Locator readonly items: Locator constructor(page: Page) { this.page = page this.searchInput = page.getByRole('textbox', { name: '名前' }) this.searchButton = page.getByRole('button', { name: '検索' }) this.items = page.getByRole('listitem') } async navigate() { await this.page.goto('https://example.com/list') } async searchForItem(itemName: string) { await this.searchInput.fill(itemName) await this.searchButton.click() } async getItemNames(): Promise<string[]> { return this.items.allTextContents() } async clickLinkInItem(itemName: string) { const item = this.items.filter({ hasText: itemName }) const link = item.getByRole('link') await link.click() } }
// DetailPage.ts import { Locator, Page } from '@playwright/test' export class DetailPage { readonly page: Page readonly title: Locator constructor(page: Page) { this.page = page this.title = page.getByRole('heading', { level: 1 }) } async navigate(detailId: number) { await this.page.goto(`https://example.com/details/${detailId}`) } async getTitle(): Promise<string> { const titleText = await this.title.textContent() return titleText ?? '' } }
一覧ページでアイテムを検索し、そのアイテム内のリンクをクリックした後、詳細ページの遷移ができることを検証しています。
// listPage.spec.ts import { expect, test } from '@playwright/test' import { DetailPage } from '../pages/detailPage' import { ListPage } from '../pages/listPage' test.describe('一覧ページのテスト', () => { let listPage: ListPage test.beforeEach(async ({ page }) => { listPage = new ListPage(page) await listPage.navigate() }) test('アイテムを検索できること', async ({ page }) => { await listPage.searchForItem('アイテム1') const items = await listPage.getItemNames() expect(items).toContain('アイテム1') }) test('指定したアイテム内のリンクをクリックできること', async ({ page }) => { const detailPage = new DetailPage(page) await listPage.searchForItem('アイテム1') await listPage.clickLinkInItem('アイテム1') await expect(page).toHaveURL('https://example.com/details/1') await expect(detailPage.title).toBeVisible() }) })
POMを使用したテストの基本的な概念を示すための簡易的なサンプルですが、実際のプロダクトでも同様の方法でテストを実装しています。
POMで実装する上でいくつかルールを設けています
- 要素の定義はすべてPOMにする
- POMにアサーションを書かない
- XPathやCSSセレクターを用いた要素取得ではなく、アクセシビリティに基づくセレクターを使用する
- アクセシビリティに基づくセレクターを使用することで、実際のユーザーが操作する方法に近い形でテストを行うことができる
- アクセシビリティ属性(roleなど)は、UIの見た目が変わっても比較的一貫しているため、UIのデザイン変更によるテストの失敗を防ぐことにも繋がる
- SmartHRのプロダクトはアクセシビリティ改善に日々取り組んでいるため、大部分の要素特定が可能になっている。また、アクセシビリティに基づくテストを行うことで、自然とアクセシブルなUIの実装がすることができ、ユーザーに対して使いやすいインターフェイスを提供することができる
// Bad page.locator('.btn.btn-primary') // Good page.getByRole('button', { name: '検索' })
- テストを実装する上でdiv要素などroleを持たない要素を取得したい場合はTestIdを付与して取得する
getByText
getByLabel
などがあるため最終手段としているものの、とくに利用を禁止しているわけではない
page.getByTestId('search-container')
beforeEachなどで事前にテストデータを作る場合はAPI経由で作成する
テストデータを作成する際にはAPIRequestContextを利用してAPI経由でデータを作成しています。
test.beforeEach(async ({ page }) => { // テストデータ作成用のAPIリクエスト const response = await page.request.post('/api/create_data', { data: { item: 'Test Item', // dataプロパティには、APIに渡すデータを指定 }, headers, // 必要なヘッダー情報 }) // レスポンスのステータスコードを確認 expect(response.ok()).toBeTruthy() })
同じPage Contextを利用すれば、すでに認証情報が設定されているため、APIリクエストを行う際には認証情報を再度設定する必要がありません。
APIを直接叩いてテストデータを作成する場合の利点はいくつかあると考えています。
- パフォーマンスの向上
- UI経由でデータを入力する場合に比べて、APIを通じてデータを直接作成する方がはるかに高速です。これにより、テスト実行時間の短縮やFlakyなテストを減らすことができる
- 信頼性の向上
- UIの変更に依存せず、データが一貫して生成されるため、テストの結果が安定する
- メンテナンス性の向上
- APIを使用することで、UIの変更がテストに与える影響を最小限に抑え、テストの管理が容易になる
考慮点として、すべてのデータをAPI経由で作成すると、本来のE2Eテストの思想から少し外れてしまいます。E2Eテストは、ユーザーの操作をエンドツーエンドで検証することが目的です。API経由でのデータ作成は、テストの前提条件を効率的に整えるための手段として使用し、主要な機能やフローの検証は別のテストで担保されている必要があります。
ディレクトリ構造について
基本的には生成された構造をそのまま使用していますが、POMを採用しているため、以下のようにファイルを整理しています。
- testsにspecファイルを格納する
- helperにspecを横断して共通で実行できるメソッドを配置する
- APIなどの処理もここに配置する
- pagesにpageごとの要素を定義する
├── globalSetup.ts # グローバルセットアップスクリプト ├── helper # ヘルパースクリプトを格納 │ ├── waitForFetch.ts │ ├── ... │ └── ... ├── pages # ページオブジェクトモデルクラスを格納 │ ├── ListPage.ts │ ├── DetailPage.ts │ └── ... └── tests # テストスクリプトを格納 ├── app │ ├── listPage.spec.ts │ ├── detailPage.spec.ts │ └── ... └── externalApp └── ...
まとめ
本記事では、Playwrightを使ってE2Eテストを書くときの事例を紹介しました。Playwrightを使うときの参考にしてみてください。
We Are Hiring!
SmartHR では一緒に SmartHR を作りあげていく仲間を募集中です!
少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!