test-ui

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Playwright 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
test-ui-qsf
skill alongside this one.
遵循Playwright最佳实践编写和维护浏览器E2E测试。这些规则与项目无关;项目特定的约定(如fixtures、测试ID、配置)存放在项目专属的skill中——对于QSF套件,请遵循
test-ui-qsf
skill并结合本规范。

Locators (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
waitForTimeout()
. Playwright auto-waits for elements.
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 });
切勿使用
waitForTimeout()
。Playwright会自动等待元素就绪。
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
BasePage → SpecializedPage
hierarchy with composed components.
typescript
// 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 → SpecializedPage
的层级结构,并组合组件。
typescript
// 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
    readonly
    properties
    — defined in constructor scope, not in methods
  • Assertions belong in page objects — prefix with
    assert
  • Actions return
    Promise<void>
    — no chaining
  • Compose via child components
    this.navigation
    ,
    this.fileUploadComponent
    , etc.
  • 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
    forEach
    loops over arrays/objects for data-driven parameterization
  • Test names describe the scenario and expected outcome
  • Extract
    beforeEach
    navigation to shared helpers when duplicated 3+ times
  • No
    console.log
    debug output — use
    test.step()
    annotations
  • 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
    ,
    getByTitle
    (not fragile CSS)
  • No
    waitForTimeout()
    calls — auto-waiting assertions only
  • Assertions are web-first (
    expect()
    auto-retrying), meaningful beyond visibility checks
  • Page objects extend the base page, compose components, keep locators as
    readonly
    properties, and own the
    assert*
    methods
  • Tests independent;
    forEach
    parameterization; descriptive names
  • No debug logging; constants imported, not hard-coded
  • 定位器使用
    getByRole
    getByTestId
    getByLabel
    getByTitle
    (不使用脆弱的CSS选择器)
  • waitForTimeout()
    调用 — 仅使用自动等待断言
  • 断言为Web优先(
    expect()
    自动重试),且不局限于可见性检查,具备实际意义
  • 页面对象继承基类、组合组件,定位器为
    readonly
    属性,且拥有
    assert*
    方法
  • 测试相互独立;使用
    forEach
    参数化;名称具有描述性
  • 无调试日志;导入常量而非硬编码字符串