test-ui
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePlaywright E2E Testing Standards
Playwright E2E测试规范
Write and maintain browser E2E tests following Playwright best practices. These
rules are project-agnostic; project-specific conventions (fixtures, test IDs,
config) live in the project's own skill — for the QSF suite, follow the
skill alongside this one.
test-ui-qsf遵循Playwright最佳实践编写和维护浏览器E2E测试。这些规则与项目无关;项目特定的约定(如fixtures、测试ID、配置)存放在项目专属的skill中——对于QSF套件,请遵循 skill并结合本规范。
test-ui-qsfLocators (Priority Order)
定位器(优先级顺序)
Use user-facing locators.
typescript
// BEST: Semantic locators
page.getByRole("button", { name: "Submit" });
page.getByRole("tab", { name: "Dashboard" });
page.getByLabel("Email");
page.getByPlaceholder("Search...");
page.getByText("Welcome");
page.getByTitle("Document title");
// GOOD: Test IDs (attribute set by testIdAttribute in playwright.config.ts)
page.getByTestId("delete-row-btn");
// ACCEPTABLE: CSS locators for structural queries
page.locator('input[type="file"][multiple]');
// AVOID: Fragile CSS selectors
page.locator("#submit-btn");
page.locator("div > button.primary");使用面向用户的定位器。
typescript
// 最佳实践:语义化定位器
page.getByRole("button", { name: "Submit" });
page.getByRole("tab", { name: "Dashboard" });
page.getByLabel("Email");
page.getByPlaceholder("Search...");
page.getByText("Welcome");
page.getByTitle("Document title");
// 推荐:测试ID(由playwright.config.ts中的testIdAttribute属性设置)
page.getByTestId("delete-row-btn");
// 可接受:用于结构化查询的CSS定位器
page.locator('input[type="file"][multiple]');
// 避免:脆弱的CSS选择器
page.locator("#submit-btn");
page.locator("div > button.primary");Auto-Waiting
自动等待
Never use . Playwright auto-waits for elements.
waitForTimeout()typescript
// WRONG: Manual timeouts
await page.waitForTimeout(1000);
await button.click();
// CORRECT: Auto-waiting assertions
await expect(button).toBeVisible();
await button.click();
// CORRECT: Poll for async state
await expect
.poll(async () => page.evaluate(() => localStorage.getItem("theme")))
.toBe("dark");
// CORRECT: Wait for specific conditions
await page.waitForLoadState("networkidle");
await page.waitForURL(/\/dashboard/);
// ACCEPTABLE: toPass() for polling complex async operations
await expect(async () => {
await dashboardPage.open();
await dashboardPage.assertPresent();
}).toPass({ timeout: 30_000 });切勿使用。Playwright会自动等待元素就绪。
waitForTimeout()typescript
// 错误:手动设置超时
await page.waitForTimeout(1000);
await button.click();
// 正确:使用自动等待断言
await expect(button).toBeVisible();
await button.click();
// 正确:轮询异步状态
await expect
.poll(async () => page.evaluate(() => localStorage.getItem("theme")))
.toBe("dark");
// 正确:等待特定条件
await page.waitForLoadState("networkidle");
await page.waitForURL(/\/dashboard/);
// 可接受:使用toPass()轮询复杂异步操作
await expect(async () => {
await dashboardPage.open();
await dashboardPage.assertPresent();
}).toPass({ timeout: 30_000 });Web-First Assertions
Web优先断言
Use Playwright's auto-retrying assertions. Keep assertions in page objects where
possible.
typescript
// CORRECT: Web-first (auto-retries)
await expect(page.getByRole("heading")).toHaveText("Dashboard");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(button).toBeEnabled();
// CORRECT: Page object assertion methods
await dashboardPage.assertSelectedCheckboxes(["Odd", "Free"]);
// AVOID: Manual checks (no retry)
const text = await heading.textContent();
expect(text).toBe("Dashboard");使用Playwright的自动重试断言。尽可能将断言放在页面对象中。
typescript
// 正确:Web优先断言(自动重试)
await expect(page.getByRole("heading")).toHaveText("Dashboard");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(button).toBeEnabled();
// 正确:页面对象中的断言方法
await dashboardPage.assertSelectedCheckboxes(["Odd", "Free"]);
// 避免:手动检查(无重试机制)
const text = await heading.textContent();
expect(text).toBe("Dashboard");Page Object Model
页面对象模型
Use a hierarchy with composed components.
BasePage → SpecializedPagetypescript
// pageObjects/BasePage.ts — all pages extend this
export default class BasePage {
public navigation = new Navigation(this.page);
constructor(readonly page: Page) {}
async openTab(tabName: string): Promise<void> {
await this.page.getByRole("tab", { name: tabName }).click();
}
async assertUrl(path: string | RegExp): Promise<void> {
await expect(this.page).toHaveURL(path);
}
}
// pageObjects/DetailPage.ts — specialized page
export default class DetailPage extends BasePage {
public fileUploadComponent = new FileUpload(this.page);
readonly errorLabel: Locator = this.page.getByRole("alert");
async assertError(message?: string): Promise<void> {
await expect(this.errorLabel).toBeVisible();
if (message) await expect(this.errorLabel).toHaveText(message);
}
}使用的层级结构,并组合组件。
BasePage → SpecializedPagetypescript
// pageObjects/BasePage.ts — 所有页面都继承此类
export default class BasePage {
public navigation = new Navigation(this.page);
constructor(readonly page: Page) {}
async openTab(tabName: string): Promise<void> {
await this.page.getByRole("tab", { name: tabName }).click();
}
async assertUrl(path: string | RegExp): Promise<void> {
await expect(this.page).toHaveURL(path);
}
}
// pageObjects/DetailPage.ts — 专属页面
export default class DetailPage extends BasePage {
public fileUploadComponent = new FileUpload(this.page);
readonly errorLabel: Locator = this.page.getByRole("alert");
async assertError(message?: string): Promise<void> {
await expect(this.errorLabel).toBeVisible();
if (message) await expect(this.errorLabel).toHaveText(message);
}
}Rules
规则
- One class per page/component — keep files focused
- Locators as properties — defined in constructor scope, not in methods
readonly - Assertions belong in page objects — prefix with
assert - Actions return — no chaining
Promise<void> - Compose via child components — ,
this.navigation, etc.this.fileUploadComponent - Split large element libraries — keep shared element classes under ~300 lines, one file per element type
- 每个页面/组件对应一个类 — 保持文件聚焦
- 定位器作为属性 — 在构造函数作用域中定义,而非方法内
readonly - 断言属于页面对象 — 以为前缀
assert - 操作返回— 不使用链式调用
Promise<void> - 通过子组件组合 — 例如、
this.navigation等this.fileUploadComponent - 拆分大型元素库 — 共享元素类保持在约300行以内,每种元素类型对应一个文件
Test Design
测试设计
- Tests are independent — no shared mutable state between tests
- Use loops over arrays/objects for data-driven parameterization
forEach - Test names describe the scenario and expected outcome
- Extract navigation to shared helpers when duplicated 3+ times
beforeEach - No debug output — use
console.logannotationstest.step() - Import UI labels, error messages, and identifiers from constants/enums — never hard-code strings in tests
- 测试相互独立 — 测试之间无共享可变状态
- 使用循环遍历数组/对象实现数据驱动参数化
forEach - 测试名称描述场景和预期结果
- 当重复3次以上时,将中的导航逻辑提取到共享工具函数中
beforeEach - 禁止使用输出调试信息 — 使用
console.log注解test.step() - 从常量/枚举中导入UI标签、错误消息和标识符 — 切勿在测试中硬编码字符串
Known Anti-Patterns
已知反模式
1. Mutable global state across tests
1. 测试间存在可变全局状态
typescript
// BAD: shared array mutated across iterations — creates order dependency
const passedTests: string[] = [];
for (const tc of testcases) {
test(`test ${tc}`, async () => {
passedTests.push(tc);
});
}
// GOOD: each test is self-contained
testcases.forEach((tc) => {
test(`test ${tc}`, async ({ page }) => {
// no shared mutable state
});
});typescript
// 错误:共享数组在迭代中被修改 — 产生顺序依赖
const passedTests: string[] = [];
for (const tc of testcases) {
test(`test ${tc}`, async () => {
passedTests.push(tc);
});
}
// 正确:每个测试都是独立的
testcases.forEach((tc) => {
test(`test ${tc}`, async ({ page }) => {
// 无共享可变状态
});
});2. Test name doesn't match behavior
2. 测试名称与实际行为不符
Name the test after what it actually asserts, not the component you started with.
根据测试实际断言的内容命名,而非基于初始组件命名。
3. Visibility-only assertions ("nothing burger" tests)
3. 仅断言可见性的“无意义”测试
typescript
// BAD: only checks the element exists — passes even if broken
await detailPage.assertBadgeVisible("Theme");
// GOOD: verify content and interaction
await detailPage.assertBadgeText("Theme", "Default");
await detailPage.selectBadge("Theme", "Dark");
await detailPage.assertBadgeText("Theme", "Dark");typescript
// 错误:仅检查元素是否存在 — 即使功能异常也会通过
await detailPage.assertBadgeVisible("Theme");
// 正确:验证内容和交互
await detailPage.assertBadgeText("Theme", "Default");
await detailPage.selectBadge("Theme", "Dark");
await detailPage.assertBadgeText("Theme", "Dark");Network Interception
网络拦截
typescript
// Mock API responses
await page.route("**/api/user", (route) => {
route.fulfill({ json: { name: "Test User" } });
});
// Simulate failures
await page.route("**/*.css", (route) => route.abort("failed"));
// Cleanup after test — unroute every mock you registered
await page.unroute("**/api/user");
await page.unroute("**/*.css");typescript
// 模拟API响应
await page.route("**/api/user", (route) => {
route.fulfill({ json: { name: "Test User" } });
});
// 模拟失败场景
await page.route("**/*.css", (route) => route.abort("failed"));
// 测试后清理 — 取消所有已注册的模拟路由
await page.unroute("**/api/user");
await page.unroute("**/*.css");Checklist
检查清单
- Locators use ,
getByRole,getByTestId,getByLabel(not fragile CSS)getByTitle - No calls — auto-waiting assertions only
waitForTimeout() - Assertions are web-first (auto-retrying), meaningful beyond visibility checks
expect() - Page objects extend the base page, compose components, keep locators as
properties, and own the
readonlymethodsassert* - Tests independent; parameterization; descriptive names
forEach - No debug logging; constants imported, not hard-coded
- 定位器使用、
getByRole、getByTestId、getByLabel(不使用脆弱的CSS选择器)getByTitle - 无调用 — 仅使用自动等待断言
waitForTimeout() - 断言为Web优先(自动重试),且不局限于可见性检查,具备实际意义
expect() - 页面对象继承基类、组合组件,定位器为属性,且拥有
readonly方法assert* - 测试相互独立;使用参数化;名称具有描述性
forEach - 无调试日志;导入常量而非硬编码字符串