关于 phoenix-playwright-tests
为 Phoenix 项目的贡献者提供使用 Playwright 编写端到端测试的规范,涵盖选择器、页面交互、断言和稳定性排查。
主要功能
- 01按优先级选择稳定定位器
- 02规范菜单、对话框和表格交互
- 03使用状态信号进行断言与等待
- 04提供 Phoenix 页面与路径参考
- 05说明测试运行和故障排查
适用场景
- 01为 Phoenix 功能编写端到端测试
- 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 |
选择器模式(优先级顺序)
-
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 }); -
Label 选择器:
page.getByLabel("Email"); page.getByLabel("Dataset Name"); page.getByLabel("Description"); -
Text 选择器:
page.getByText("No evaluators added"); page.getByPlaceholder("Search..."); -
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>)。 -
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 | /playground | Prompts 区域、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)——读者能从调用中看出来。仅在后续读者可能会错误修改测试时保留注释,例如,看似多余、实际用于防止竞态的等待。
常见问题
- 对话框未关闭:等待确定的操作后信号(例如,对话框隐藏且成功行可见)
- 存在多个元素:使用
.first()、.last()或.nth(n) - 动态内容:在名称中使用正则表达式:
{ name: /pattern/i } - 不稳定的等待:优先使用
waitForURL,而不是waitForTimeout - 菜单未出现:等待特定菜单状态或元素变为可见
调试不稳定的测试
关键经验
-
不要假设问题出在并行运行
- Phoenix 测试使用 7 个并行工作线程运行,不会出现问题
- 应用能够妥善处理并发登录、数据库操作和会话管理
- 如果测试在并行运行时失败,通常是测试时序问题,而非基础设施问题
- Playwright 的浏览器上下文隔离很可靠——每个工作线程都有独立的 Cookie 和会话
-
waitForTimeout几乎总是错误的选择page.waitForTimeout()是 Phoenix 测试不稳定的首要原因- 任意超时会与渲染和网络速度产生竞态
- 始终替换为基于状态的等待:
// ❌ 错误——不稳定,会与渲染产生竞态 await page.waitForTimeout(500); await element.click(); // ✅ 正确——等待实际状态 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
常见的不稳定模式及修复方法
| 不稳定模式 | 根本原因 | 修复方法 |
|---|---|---|
| 找不到子菜单项 | 使用 getByText() 而不是 getByRole() | 对子菜单项使用 getByRole("menuitem", { name: /pattern/i }) |
| 点击菜单失败 | 菜单尚未完全渲染 | 点击前执行 await menu.waitFor({ state: "visible" }) |
| 对话框断言失败 | 对话框动画尚未完成 | 断言特定的完成信号(对话框已隐藏且出现下一个状态的元素) |
| 导航超时 | 页面仍在加载 | 移除 waitForLoadState("networkidle")——它在 CI 中不稳定 |
| 找不到元素 | 动态内容正在加载 | 等待元素可见,而不是使用任意超时时间 |
| 元素已失效 | 定位元素与点击之间发生了重新渲染 | 保存定位器,而不是元素句柄 |
测试稳定性的最佳实践
-
使用适当的等待方式:
// 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");