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. 04提供 Phoenix 页面与路径参考
  5. 05说明测试运行和故障排查

适用场景

  1. 01为 Phoenix 功能编写端到端测试
  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. Role 选择器(最稳健):

    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. Label 选择器:

    page.getByLabel("Email");
    page.getByLabel("Dataset Name");
    page.getByLabel("Description");
    
  3. Text 选择器:

    page.getByText("No evaluators added");
    page.getByPlaceholder("Search...");
    
  4. Test 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标签页(实验、示例、评估器、版本)
数据集评估器/datasets/{id}/evaluators“Add evaluator”按钮、评估器表格
Playground/playgroundPrompts 区域、Experiment 区域
Playground + 数据集/playground?datasetId={id}数据集选择器、Evaluators 按钮
Prompts/prompts“New Prompt”按钮、prompts 表格
设置/settings/general“Add User”按钮、用户表格

UI 探索

当选择器不明确时,使用可用的浏览器工具探索 Phoenix UI:http://localhost:6006/datasets。检查可访问的角色和名称,与目标元素交互,并在导航或 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(最后运行)

注释

测试名称应体现测试意图。不要叙述步骤(// open the dialog),也不要解释定位器的选择理由(// scoped to the card because the page has two)——读者能从调用中看出来。仅在后续读者可能会错误修改测试时保留注释,例如,看似多余、实际用于防止竞态的等待。

常见问题

  1. 对话框未关闭:等待确定的操作后信号(例如,对话框隐藏且成功行可见)
  2. 存在多个元素:使用 .first()、.last() 或 .nth(n)
  3. 动态内容:在名称中使用正则表达式:{ name: /pattern/i }
  4. 不稳定的等待:优先使用 waitForURL,而不是 waitForTimeout
  5. 菜单未出现:等待特定菜单状态或元素变为可见

调试不稳定的测试

关键经验
  1. 不要假设问题出在并行运行

    • Phoenix 测试使用 7 个并行工作线程运行,不会出现问题
    • 应用能够妥善处理并发登录、数据库操作和会话管理
    • 如果测试在并行运行时失败,通常是测试时序问题,而非基础设施问题
    • Playwright 的浏览器上下文隔离很可靠——每个工作线程都有独立的 Cookie 和会话
  2. waitForTimeout 几乎总是错误的选择

    • page.waitForTimeout() 是 Phoenix 测试不稳定的首要原因
    • 任意超时会与渲染和网络速度产生竞态
    • 始终替换为基于状态的等待:
      // ❌ 错误——不稳定,会与渲染产生竞态
      await page.waitForTimeout(500);
      await element.click();
      
      // ✅ 正确——等待实际状态
      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
    
常见的不稳定模式及修复方法
不稳定模式根本原因修复方法
找不到子菜单项使用 getByText() 而不是 getByRole()对子菜单项使用 getByRole("menuitem", { name: /pattern/i })
点击菜单失败菜单尚未完全渲染点击前执行 await menu.waitFor({ state: "visible" })
对话框断言失败对话框动画尚未完成断言特定的完成信号(对话框已隐藏且出现下一个状态的元素)
导航超时页面仍在加载移除 waitForLoadState("networkidle")——它在 CI 中不稳定
找不到元素动态内容正在加载等待元素可见,而不是使用任意超时时间
元素已失效定位元素与点击之间发生了重新渲染保存定位器,而不是元素句柄
测试稳定性的最佳实践
  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");
    

评论

支持 Markdown