mcpField
P
phoenix-playwright-tests

phoenix-playwright-tests

github.com/arize-aiテスト・品質12.0k72 回インストール今日更新
リポジトリ

phoenix-playwright-tests について

Phoenix のコントリビューター向けに、Playwright のエンドツーエンドテスト作成方法をまとめたガイドです。セレクター、画面操作、アサーション、テストの不安定さの調査を扱います。

主な機能

  1. 01安定性に基づくセレクター選択
  2. 02メニュー・ダイアログ・表の操作例
  3. 03状態に基づくアサーションと待機
  4. 04Phoenix のページとパスの参照
  5. 05テスト実行とデバッグの手順

活用シーン

  1. 01Phoenix の機能に対するE2Eテスト作成
  2. 02メニュー・フォーム・タブ・表のテスト
  3. 03断続的な失敗や待機処理の調査
  4. 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@localhostadmin123admin
メンバー[email protected]member123member
閲覧者[email protected]viewer123viewer

セレクターのパターン(優先順位順)

  1. ロールセレクター(最も堅牢):

    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 });
    
  2. ラベルセレクター:

    page.getByLabel("Email");
    page.getByLabel("Dataset Name");
    page.getByLabel("Description");
    
  3. テキストセレクター:

    page.getByText("No evaluators added");
    page.getByPlaceholder("Search...");
    
  4. テスト 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>)。

  5. 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」ボタン、評価器テーブル
プレイグラウンド/playgroundPrompts セクション、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 つあるためスコープを限定)は不要です。呼び出しを見ればわかります。次に読む人がテストを誤って変更してしまう可能性がある場合にのみコメントを残してください。たとえば、冗長に見えても競合状態を防ぐための待機などです。

よくある落とし穴

  1. ダイアログが閉じない:ダイアログが非表示になり、成功行が表示されるなど、アクション後の確実なシグナルを待ちます
  2. 要素が複数ある:.first()、.last()、または .nth(n) を使います
  3. 動的コンテンツ:名前に正規表現を使います:{ name: /pattern/i }
  4. 不安定な待機:waitForTimeout より waitForURL を優先します
  5. メニューが表示されない:特定のメニュー状態や要素の表示を待ちます

不安定なテストのデバッグ

重要な教訓
  1. 並列実行が原因だと決めつけない

    • Phoenix のテストは、問題なく 7 個のワーカーで並列実行されます
    • アプリは同時ログイン、データベース操作、セッション管理を適切に処理します
    • 並列実行でテストが失敗する場合、通常はインフラではなくテストのタイミングが問題です
    • Playwright のブラウザーコンテキスト分離は堅牢で、各ワーカーには分離された Cookie とセッションが割り当てられます
  2. waitForTimeout はほとんどの場合、誤りです

    • page.waitForTimeout() は Phoenix のテストで不安定さを招く最大の原因です
    • 任意のタイムアウトは、レンダリングやネットワーク速度との競合を招きます
    • 必ず状態に基づく待機に置き換えてください:
      // ❌ BAD - 不安定で、レンダリングと競合する
      await page.waitForTimeout(500);
      await element.click();
      
      // ✅ GOOD - 実際の状態を待つ
      await element.waitFor({ state: "visible" });
      await element.click();
      
  3. 修正する前に実際の失敗を調べる

    • 並列実行を有効にしてテストを実行し、実際に何が失敗するか確認します
    • エラーメッセージを確認します。実際の問題を示していることがよくあります
    • 問題でないなら、早まって最適化しないでください(たとえば、認証状態のキャッシュ)
  4. Phoenix のテストインフラは堅牢です

    • インメモリ SQLite は並列テストでも問題なく動作します
    • ワーカーごとのデータベースは不要です
    • 認証状態のキャッシュは不要です
    • テストではデータ分離に randomUUID() を使っており、問題なく機能します
デバッグのワークフロー

テストが不安定な場合:

  1. 並列実行を複数回行い、断続的な失敗を検出します:

    for i in 1 2 3 4 5; do
      pnpm exec playwright test --project=chromium --reporter=dot
    done
    
  2. waitForTimeout の使用箇所を探し、適切な待機に置き換えます:

    grep -r "waitForTimeout" js/app/tests/
    
  3. 要素の操作における競合状態を確認します:

    • 操作の前に要素が表示されるのを待ちます
    • 必要に応じてネットワークがアイドル状態になるのを待ちます:page.waitForLoadState("networkidle")
    • ナビゲーション操作の後に waitForURL を使います
  4. セレクターが安定していることを確認します:

    • DOM 構造に依存する CSS セレクターは避けます
    • ARIA 属性に一致するロール/ラベルセレクターを使います
    • UI が更新されてもセレクターが壊れないことをテストします
  5. 失敗時にトレースを有効にして実行し、何が起きたか確認します:

    pnpm exec playwright test --trace on-first-retry
    
よくある不安定なパターンと修正方法
不安定なパターン根本原因修正方法
サブメニュー項目が見つからないgetByRole() ではなく getByText() を使用しているサブメニュー項目には getByRole("menuitem", { name: /pattern/i }) を使用します
メニューのクリックに失敗するメニューが完全にレンダリングされていないクリックする前に await menu.waitFor({ state: "visible" }) を実行します
ダイアログのアサーションに失敗するダイアログのアニメーションが完了していない完了を示す特定のシグナル(ダイアログが非表示で、次の状態の要素が表示されること)をアサートします
ナビゲーションがタイムアウトするページの読み込みが完了していないCI では不安定になるため、waitForLoadState("networkidle") を削除します
要素が見つからない動的コンテンツの読み込み任意のタイムアウトではなく、要素が表示されるまで待ちます
古い要素特定してからクリックするまでの間に再レンダリングされる要素ハンドルではなく、ロケーターを保存します
テストの安定性に関するベストプラクティス
  1. 適切な待機を使用します:

    // 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")
    
  2. 一意のテストデータを使用します:

    const uniqueName = `test-${randomUUID()}`;
    
  3. ロールセレクターを優先します。より壊れにくいためです:

    page.getByRole("button", { name: "Save" }) // ✅ Good
    page.locator('button.save-btn') // ❌ Brittle
    
  4. アニメーションに逆らわず、完了を待ちます:

    await expect(dialog).not.toBeVisible();
    
  5. ナビゲーション後に URL の変更を確認します:

    await page.waitForURL("**/datasets");
    

コメント