phoenix-playwright-tests について
Phoenix のコントリビューター向けに、Playwright のエンドツーエンドテスト作成方法をまとめたガイドです。セレクター、画面操作、アサーション、テストの不安定さの調査を扱います。
主な機能
- 01安定性に基づくセレクター選択
- 02メニュー・ダイアログ・表の操作例
- 03状態に基づくアサーションと待機
- 04Phoenix のページとパスの参照
- 05テスト実行とデバッグの手順
活用シーン
- 01Phoenix の機能に対するE2Eテスト作成
- 02メニュー・フォーム・タブ・表のテスト
- 03断続的な失敗や待機処理の調査
- 04記載されたコマンドでのPlaywrightテスト実行
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ arize-ai/ phoenix --skill phoenix-playwright-testsインストール後、Claude Code で/phoenix-playwright-testsを入力して読み込まれたか確認します。
詳細
Phoenix Playwright テストの作成
Playwright を使用して Phoenix のエンドツーエンドテストを作成します。テストは js/app/tests/ に配置し、既存のパターンに従います。
タイムアウトポリシー
js/app/tests内のテストコードでは、タイムアウト引数を渡さないでください。- タイミングは
js/app/playwright.config.ts(グローバルなtimeout、expect.timeout、use.navigationTimeout、webServer.timeout)で一元的に調整します。
クイックスタート
import { expect, test } from "@playwright/test";
import { randomUUID } from "crypto";
test.describe("Feature Name", () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/login`);
await page.getByLabel("Email").fill("admin@localhost");
await page.getByLabel("Password").fill("admin123");
await page.getByRole("button", { name: "Log In", exact: true }).click();
await page.waitForURL("**/projects");
});
test("can do something", async ({ page }) => {
// Test implementation
});
});
テスト用認証情報
| ユーザー | メールアドレス | パスワード | ロール |
|---|---|---|---|
| 管理者 | admin@localhost | admin123 | admin |
| メンバー | [email protected] | member123 | member |
| 閲覧者 | [email protected] | viewer123 | viewer |
セレクターのパターン(優先順位順)
-
ロールセレクター(最も堅牢):
page.getByRole("button", { name: "Save" }); page.getByRole("link", { name: "Datasets" }); page.getByRole("tab", { name: /Evaluators/i }); page.getByRole("menuitem", { name: "Edit" }); page.getByRole("cell", { name: "my-item" }); page.getByRole("heading", { name: "Title" }); page.getByRole("dialog"); page.getByRole("textbox", { name: "Name" }); page.getByRole("combobox", { name: /mapping/i }); -
ラベルセレクター:
page.getByLabel("Email"); page.getByLabel("Dataset Name"); page.getByLabel("Description"); -
テキストセレクター:
page.getByText("No evaluators added"); page.getByPlaceholder("Search..."); -
テスト ID(利用可能な場合):
page.getByTestId("create-dataset-button"); // element with state — select the stable id, filter on the data attribute page.locator('[data-testid="llm-evaluator-form-submit-button"][data-mode="create"]');data-testidはスコープ付きで、完全な名前を使用します(...-buttonのようにし、...-btnは使用しません)。また、状態にかかわらず一定です。状態は兄弟のdata-*属性(data-mode、data-stateなど)で公開されるため、特定のモードでしか存在しない値を基準にgetByTestIdを指定しないでください。まだ存在しないdata-testidが必要な場合は、phoenix-frontendスキルのreferences/test-ids.mdに従って追加してください(パターン:<scope>-<subject>-<role>)。 -
CSS ロケーター(最後の手段):
page.locator('button:has-text("Save")');
よくある UI パターン
ドロップダウンメニュー
// Click button to open dropdown
await page.getByRole("button", { name: "New Dataset" }).click();
// Select menu item
await page.getByRole("menuitem", { name: "New Dataset" }).click();
入れ子のメニュー(サブメニュー)
// Open menu, hover over submenu trigger, click submenu item
await page.getByRole("button", { name: "Add evaluator" }).click();
await page
.getByRole("menuitem", { name: "Use LLM evaluator template" })
.hover();
await page.getByRole("menuitem", { name: /correctness/i }).click();
// IMPORTANT: Always use getByRole("menuitem") for submenu items, not getByText()
// Playwright's auto-waiting handles the submenu appearance timing
// ❌ BAD - flaky in CI:
// await page.getByText("ExactMatch").first().click();
// ✅ GOOD - reliable:
// await page.getByRole("menuitem", { name: /ExactMatch/i }).click();
ダイアログ/モーダル
// Wait for dialog
await expect(page.getByRole("dialog")).toBeVisible();
// Fill form in dialog
await page.getByLabel("Name").fill("test-name");
// Submit
await page.getByRole("button", { name: "Create" }).click();
// Wait for close
await expect(page.getByRole("dialog")).not.toBeVisible();
行アクション付きテーブル
// Find row by cell content
const row = page.getByRole("row").filter({
has: page.getByRole("cell", { name: "item-name" }),
});
// Click action button in row (usually last button)
await row.getByRole("button").last().click();
// Select action from menu
await page.getByRole("menuitem", { name: "Edit" }).click();
タブ
await page.getByRole("tab", { name: /Evaluators/i }).click();
await page.waitForURL("**/evaluators");
await expect(page.getByRole("tab", { name: /Evaluators/i })).toHaveAttribute(
"aria-selected",
"true",
);
セクション内のフォーム入力
// When multiple textboxes exist, scope to section
const systemSection = page.locator('button:has-text("System")');
const systemTextbox = systemSection
.locator("..")
.locator("..")
.getByRole("textbox");
await systemTextbox.fill("content");
シリアルテスト(共有状態)
テストが相互に依存する場合は、test.describe.serial を使用します。
test.describe.serial("Workflow", () => {
const itemName = `item-${randomUUID()}`;
test("step 1: create item", async ({ page }) => {
// Creates itemName
});
test("step 2: edit item", async ({ page }) => {
// Uses itemName from previous test
});
test("step 3: verify edits", async ({ page }) => {
// Verifies itemName was edited
});
});
アサーション
// Visibility
await expect(element).toBeVisible();
await expect(element).not.toBeVisible();
// Text content
await expect(element).toHaveText("expected");
await expect(element).toContainText("partial");
// Attributes
await expect(element).toHaveAttribute("aria-selected", "true");
// Input values
await expect(input).toHaveValue("expected value");
// URL
await page.waitForURL("**/datasets/**/examples");
ナビゲーションパターン
// Direct navigation
await page.goto("/datasets");
await page.waitForURL("**/datasets");
// Click navigation
await page.getByRole("link", { name: "Datasets" }).click();
await page.waitForURL("**/datasets");
// Extract ID from URL
const url = page.url();
const match = url.match(/datasets\/([^/]+)/);
const datasetId = match ? match[1] : "";
// Navigate with query params
await page.goto(`/playground?datasetId=${datasetId}`);
テストの実行
Playwright テストを実行する前にアプリをビルドし、最新のフロントエンド変更を使って E2E テストを実行します。
pnpm run build
# Run specific test file
pnpm exec playwright test tests/server-evaluators.spec.ts --project=chromium
# Run with UI mode
pnpm exec playwright test --ui
# Run specific test by name
pnpm exec playwright test -g "can create"
# Debug mode
pnpm exec playwright test --debug
対話型レポートサーバーを回避する
デフォルトでは、Playwright はテスト完了後に HTML レポートを表示し、Ctrl+C が押されるまで待機します。そのため、コマンドがタイムアウトすることがあります。これを回避するには、次のオプションを使用します。
# Use list reporter (no interactive server)
pnpm exec playwright test tests/example.spec.ts --project=chromium --reporter=list
# Use dot reporter for minimal output
pnpm exec playwright test tests/example.spec.ts --project=chromium --reporter=dot
# Set CI mode to disable interactive features
CI=1 pnpm exec playwright test tests/example.spec.ts --project=chromium
自動化での推奨事項:プログラムからテストを実行する場合は、テスト完了後にコマンドが正常に終了するよう、必ず --reporter=list または CI=1 を使用してください。
Phoenix 固有のページ
| ページ | URL パターン | 主な要素 |
|---|---|---|
| データセット | /datasets | テーブル、「New Dataset」ボタン |
| データセット詳細 | /datasets/{id}/examples | タブ(Experiments、Examples、Evaluators、Versions) |
| データセットの評価器 | /datasets/{id}/evaluators | 「Add evaluator」ボタン、評価器テーブル |
| プレイグラウンド | /playground | Prompts セクション、Experiment セクション |
| プレイグラウンド+データセット | /playground?datasetId={id} | データセットセレクター、Evaluators ボタン |
| プロンプト | /prompts | 「New Prompt」ボタン、プロンプトテーブル |
| 設定 | /settings/general | 「Add User」ボタン、ユーザーテーブル |
UI の探索
セレクターがわからない場合は、利用可能なブラウザーツールを使って http://localhost:6006/datasets の Phoenix UI を探索します。アクセシブルなロールと名前を確認し、対象の要素を操作して、ナビゲーションや DOM の変更後にページを再確認してください。
確認したロールと名前を使って Playwright セレクターを選択します。
| UI 要素 | Playwright セレクター |
|---|---|
| ボタン「Save」 | page.getByRole("button", { name: "Save" }) |
| リンク「Datasets」 | page.getByRole("link", { name: "Datasets" }) |
| テキストボックス「Name」 | page.getByRole("textbox", { name: "Name" }) |
| メニュー項目「Edit」 | page.getByRole("menuitem", { name: "Edit" }) |
| タブ「Evaluators 0」 | page.getByRole("tab", { name: /Evaluators/i }) |
ファイル名
- 機能テスト:
{feature-name}.spec.ts - アクセス制御:
{role}-access.spec.ts - レート制限:
{feature}.rate-limit.spec.ts(最後に実行)
コメント
テスト名に意図を含めます。手順を説明するコメント(// ダイアログを開く)やロケーターの根拠を説明するコメント(// ページにカードが 2 つあるためスコープを限定)は不要です。呼び出しを見ればわかります。次に読む人がテストを誤って変更してしまう可能性がある場合にのみコメントを残してください。たとえば、冗長に見えても競合状態を防ぐための待機などです。
よくある落とし穴
- ダイアログが閉じない:ダイアログが非表示になり、成功行が表示されるなど、アクション後の確実なシグナルを待ちます
- 要素が複数ある:
.first()、.last()、または.nth(n)を使います - 動的コンテンツ:名前に正規表現を使います:
{ name: /pattern/i } - 不安定な待機:
waitForTimeoutよりwaitForURLを優先します - メニューが表示されない:特定のメニュー状態や要素の表示を待ちます
不安定なテストのデバッグ
重要な教訓
-
並列実行が原因だと決めつけない
- Phoenix のテストは、問題なく 7 個のワーカーで並列実行されます
- アプリは同時ログイン、データベース操作、セッション管理を適切に処理します
- 並列実行でテストが失敗する場合、通常はインフラではなくテストのタイミングが問題です
- Playwright のブラウザーコンテキスト分離は堅牢で、各ワーカーには分離された Cookie とセッションが割り当てられます
-
waitForTimeoutはほとんどの場合、誤りですpage.waitForTimeout()は Phoenix のテストで不安定さを招く最大の原因です- 任意のタイムアウトは、レンダリングやネットワーク速度との競合を招きます
- 必ず状態に基づく待機に置き換えてください:
// ❌ BAD - 不安定で、レンダリングと競合する await page.waitForTimeout(500); await element.click(); // ✅ GOOD - 実際の状態を待つ await element.waitFor({ state: "visible" }); await element.click();
-
修正する前に実際の失敗を調べる
- 並列実行を有効にしてテストを実行し、実際に何が失敗するか確認します
- エラーメッセージを確認します。実際の問題を示していることがよくあります
- 問題でないなら、早まって最適化しないでください(たとえば、認証状態のキャッシュ)
-
Phoenix のテストインフラは堅牢です
- インメモリ SQLite は並列テストでも問題なく動作します
- ワーカーごとのデータベースは不要です
- 認証状態のキャッシュは不要です
- テストではデータ分離に
randomUUID()を使っており、問題なく機能します
デバッグのワークフロー
テストが不安定な場合:
-
並列実行を複数回行い、断続的な失敗を検出します:
for i in 1 2 3 4 5; do pnpm exec playwright test --project=chromium --reporter=dot done -
waitForTimeoutの使用箇所を探し、適切な待機に置き換えます:grep -r "waitForTimeout" js/app/tests/ -
要素の操作における競合状態を確認します:
- 操作の前に要素が表示されるのを待ちます
- 必要に応じてネットワークがアイドル状態になるのを待ちます:
page.waitForLoadState("networkidle") - ナビゲーション操作の後に
waitForURLを使います
-
セレクターが安定していることを確認します:
- DOM 構造に依存する CSS セレクターは避けます
- ARIA 属性に一致するロール/ラベルセレクターを使います
- UI が更新されてもセレクターが壊れないことをテストします
-
失敗時にトレースを有効にして実行し、何が起きたか確認します:
pnpm exec playwright test --trace on-first-retry
よくある不安定なパターンと修正方法
| 不安定なパターン | 根本原因 | 修正方法 |
|---|---|---|
| サブメニュー項目が見つからない | getByRole() ではなく getByText() を使用している | サブメニュー項目には getByRole("menuitem", { name: /pattern/i }) を使用します |
| メニューのクリックに失敗する | メニューが完全にレンダリングされていない | クリックする前に await menu.waitFor({ state: "visible" }) を実行します |
| ダイアログのアサーションに失敗する | ダイアログのアニメーションが完了していない | 完了を示す特定のシグナル(ダイアログが非表示で、次の状態の要素が表示されること)をアサートします |
| ナビゲーションがタイムアウトする | ページの読み込みが完了していない | CI では不安定になるため、waitForLoadState("networkidle") を削除します |
| 要素が見つからない | 動的コンテンツの読み込み | 任意のタイムアウトではなく、要素が表示されるまで待ちます |
| 古い要素 | 特定してからクリックするまでの間に再レンダリングされる | 要素ハンドルではなく、ロケーターを保存します |
テストの安定性に関するベストプラクティス
-
適切な待機を使用します:
// Wait for element state await element.waitFor({ state: "visible" | "hidden" | "attached" }) // Wait for network await page.waitForLoadState("networkidle" | "domcontentloaded" | "load") // Wait for URL change await page.waitForURL("**/expected-path") -
一意のテストデータを使用します:
const uniqueName = `test-${randomUUID()}`; -
ロールセレクターを優先します。より壊れにくいためです:
page.getByRole("button", { name: "Save" }) // ✅ Good page.locator('button.save-btn') // ❌ Brittle -
アニメーションに逆らわず、完了を待ちます:
await expect(dialog).not.toBeVisible(); -
ナビゲーション後に URL の変更を確認します:
await page.waitForURL("**/datasets");