playwright-best-practices

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Playwright Best Practices

Playwright 最佳实践

CLI Context: Prevent Context Overflow

CLI 上下文:防止上下文溢出

When running Playwright tests from Claude Code or any CLI agent, always use minimal reporters to prevent verbose output from consuming the context window.
Use
--reporter=line
or
--reporter=dot
for CLI test runs:
bash
undefined
从Claude Code或任何CLI Agent运行Playwright测试时,请始终使用极简报告器,以避免冗长输出占用上下文窗口。
CLI测试运行时使用
--reporter=line
--reporter=dot
bash
undefined

REQUIRED: Use minimal reporter to prevent context overflow

必须:使用极简报告器防止上下文溢出

npx playwright test --reporter=line npx playwright test --reporter=dot
npx playwright test --reporter=line npx playwright test --reporter=dot

BAD: Default reporter generates thousands of lines, floods context

不推荐:默认报告器会生成数千行内容,占用上下文

npx playwright test

Configure `playwright.config.ts` to use minimal reporters by default when `CI` or `CLAUDE` env vars are set:

```ts
reporter: process.env.CI || process.env.CLAUDE
  ? [['line'], ['html', { open: 'never' }]]
  : 'list',
npx playwright test

配置`playwright.config.ts`,当设置`CI`或`CLAUDE`环境变量时,默认使用极简报告器:

```ts
reporter: process.env.CI || process.env.CLAUDE
  ? [['line'], ['html', { open: 'never' }]]
  : 'list',

Locator Priority (Most to Least Resilient)

定位器优先级(从最可靠到最不可靠)

Always prefer user-facing attributes:
  1. page.getByRole('button', { name: 'Submit' })
    - accessibility roles
  2. page.getByLabel('Email')
    - form control labels
  3. page.getByPlaceholder('Search...')
    - input placeholders
  4. page.getByText('Welcome')
    - visible text (non-interactive)
  5. page.getByAltText('Logo')
    - image alt text
  6. page.getByTitle('Settings')
    - title attributes
  7. page.getByTestId('submit-btn')
    - explicit test contracts
  8. CSS/XPath - last resort, avoid
ts
// BAD: Brittle selectors tied to implementation
page.locator('button.btn-primary.submit-form')
page.locator('//div[@class="container"]/form/button')
page.locator('#app > div:nth-child(2) > button')

// GOOD: User-facing, resilient locators
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Password')
始终优先使用面向用户的属性:
  1. page.getByRole('button', { name: 'Submit' })
    - 可访问性角色
  2. page.getByLabel('Email')
    - 表单控件标签
  3. page.getByPlaceholder('Search...')
    - 输入框占位符
  4. page.getByText('Welcome')
    - 可见文本(非交互元素)
  5. page.getByAltText('Logo')
    - 图片替代文本
  6. page.getByTitle('Settings')
    - 标题属性
  7. page.getByTestId('submit-btn')
    - 显式测试契约
  8. CSS/XPath - 最后手段,尽量避免
ts
// 不推荐:与实现绑定的脆弱选择器
page.locator('button.btn-primary.submit-form')
page.locator('//div[@class="container"]/form/button')
page.locator('#app > div:nth-child(2) > button')

// 推荐:面向用户的可靠定位器
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Password')

Chaining and Filtering

链式调用与过滤

ts
// Scope within a region
const card = page.getByRole('listitem').filter({ hasText: 'Product A' });
await card.getByRole('button', { name: 'Add to cart' }).click();

// Filter by child locator
const row = page.getByRole('row').filter({
  has: page.getByRole('cell', { name: 'John' })
});

// Combine conditions
const visibleSubmit = page.getByRole('button', { name: 'Submit' }).and(page.locator(':visible'));
const primaryOrSecondary = page.getByRole('button', { name: 'Save' }).or(page.getByRole('button', { name: 'Update' }));
ts
// 在指定区域内定位
const card = page.getByRole('listitem').filter({ hasText: 'Product A' });
await card.getByRole('button', { name: 'Add to cart' }).click();

// 通过子定位器过滤
const row = page.getByRole('row').filter({
  has: page.getByRole('cell', { name: 'John' })
});

// 组合条件
const visibleSubmit = page.getByRole('button', { name: 'Submit' }).and(page.locator(':visible'));
const primaryOrSecondary = page.getByRole('button', { name: 'Save' }).or(page.getByRole('button', { name: 'Update' }));

Strictness

严格性

Locators throw if multiple elements match. Use
first()
,
last()
,
nth()
only when intentional:
ts
// Throws if multiple buttons match
await page.getByRole('button', { name: 'Delete' }).click();

// Explicit selection when needed
await page.getByRole('listitem').first().click();
await page.getByRole('row').nth(2).getByRole('button').click();
如果多个元素匹配,定位器会抛出错误。仅在必要时使用
first()
last()
nth()
ts
// 如果多个按钮匹配,会抛出错误
await page.getByRole('button', { name: 'Delete' }).click();

// 必要时显式选择
await page.getByRole('listitem').first().click();
await page.getByRole('row').nth(2).getByRole('button').click();

Web-First Assertions

Web优先断言

Use async assertions that auto-wait and retry:
ts
// BAD: No auto-wait, flaky
expect(await page.getByText('Success').isVisible()).toBe(true);

// GOOD: Auto-waits up to timeout
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByTestId('status')).toHaveText('Submitted');
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveTitle('Dashboard');

// Collections
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByRole('listitem')).toHaveText(['Item 1', 'Item 2', 'Item 3']);

// Soft assertions (continue on failure, report all)
await expect.soft(locator).toBeVisible();
await expect.soft(locator).toHaveText('Expected');
// Test continues, failures compiled at end
使用支持自动等待和重试的异步断言:
ts
// 不推荐:无自动等待,不稳定
expect(await page.getByText('Success').isVisible()).toBe(true);

// 推荐:自动等待直至超时
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByTestId('status')).toHaveText('Submitted');
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveTitle('Dashboard');

// 集合断言
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByRole('listitem')).toHaveText(['Item 1', 'Item 2', 'Item 3']);

// 软断言(失败后继续执行,最终汇总所有失败)
await expect.soft(locator).toBeVisible();
await expect.soft(locator).toHaveText('Expected');
// 测试继续执行,失败信息在末尾汇总

Page Object Model

Page Object Model

Encapsulate page interactions. Define locators as readonly properties in constructor.
ts
// pages/base.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
import debug from 'debug';

export abstract class BasePage {
  protected readonly log: debug.Debugger;

  constructor(
    protected readonly page: Page,
    protected readonly timeout = 30_000
  ) {
    this.log = debug(`test:page:${this.constructor.name}`);
  }

  protected async safeClick(locator: Locator, description?: string) {
    this.log('clicking: %s', description ?? locator);
    await expect(locator).toBeVisible({ timeout: this.timeout });
    await expect(locator).toBeEnabled({ timeout: this.timeout });
    await locator.click();
  }

  protected async safeFill(locator: Locator, value: string) {
    await expect(locator).toBeVisible({ timeout: this.timeout });
    await locator.fill(value);
  }

  abstract isLoaded(): Promise<void>;
}
ts
// pages/login.page.ts
import { type Locator, type Page, expect } from '@playwright/test';
import { BasePage } from './base.page';

export class LoginPage extends BasePage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    super(page);
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
    await this.isLoaded();
  }

  async isLoaded() {
    await expect(this.emailInput).toBeVisible();
  }

  async login(email: string, password: string) {
    await this.safeFill(this.emailInput, email);
    await this.safeFill(this.passwordInput, password);
    await this.safeClick(this.submitButton, 'Sign in button');
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toHaveText(message);
  }
}
封装页面交互。在构造函数中将定位器定义为只读属性。
ts
// pages/base.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
import debug from 'debug';

export abstract class BasePage {
  protected readonly log: debug.Debugger;

  constructor(
    protected readonly page: Page,
    protected readonly timeout = 30_000
  ) {
    this.log = debug(`test:page:${this.constructor.name}`);
  }

  protected async safeClick(locator: Locator, description?: string) {
    this.log('clicking: %s', description ?? locator);
    await expect(locator).toBeVisible({ timeout: this.timeout });
    await expect(locator).toBeEnabled({ timeout: this.timeout });
    await locator.click();
  }

  protected async safeFill(locator: Locator, value: string) {
    await expect(locator).toBeVisible({ timeout: this.timeout });
    await locator.fill(value);
  }

  abstract isLoaded(): Promise<void>;
}
ts
// pages/login.page.ts
import { type Locator, type Page, expect } from '@playwright/test';
import { BasePage } from './base.page';

export class LoginPage extends BasePage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    super(page);
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
    await this.isLoaded();
  }

  async isLoaded() {
    await expect(this.emailInput).toBeVisible();
  }

  async login(email: string, password: string) {
    await this.safeFill(this.emailInput, email);
    await this.safeFill(this.passwordInput, password);
    await this.safeClick(this.submitButton, 'Sign in button');
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toHaveText(message);
  }
}

Fixtures

Fixture

Prefer fixtures over beforeEach/afterEach. Fixtures encapsulate setup + teardown, run on-demand, and compose with dependencies.
ts
// fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';

type TestFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<TestFixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await use(loginPage);
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

export { expect };
优先使用Fixture而非beforeEach/afterEach。Fixture封装了初始化+清理逻辑,按需运行,并可与依赖项组合。
ts
// fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';

type TestFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<TestFixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await use(loginPage);
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

export { expect };

Worker-Scoped Fixtures

Worker作用域Fixture

Use for expensive setup shared across tests (database connections, authenticated users):
ts
// fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';

type WorkerFixtures = {
  authenticatedUser: { token: string; userId: string };
};

export const test = base.extend<{}, WorkerFixtures>({
  authenticatedUser: [async ({}, use) => {
    // Expensive setup - runs once per worker
    const user = await createTestUser();
    const token = await authenticateUser(user);

    await use({ token, userId: user.id });

    // Cleanup after all tests in worker
    await deleteTestUser(user.id);
  }, { scope: 'worker' }],
});
用于跨测试共享的昂贵初始化操作(数据库连接、已认证用户):
ts
// fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';

type WorkerFixtures = {
  authenticatedUser: { token: string; userId: string };
};

export const test = base.extend<{}, WorkerFixtures>({
  authenticatedUser: [async ({}, use) => {
    // 昂贵的初始化 - 每个Worker运行一次
    const user = await createTestUser();
    const token = await authenticateUser(user);

    await use({ token, userId: user.id });

    // Worker中所有测试完成后清理
    await deleteTestUser(user.id);
  }, { scope: 'worker' }],
});

Automatic Fixtures

自动Fixture

Run for every test without explicit declaration:
ts
export const test = base.extend<{ autoLog: void }>({
  autoLog: [async ({ page }, use) => {
    page.on('console', msg => console.log(`[browser] ${msg.text()}`));
    await use();
  }, { auto: true }],
});
无需显式声明即可为每个测试运行:
ts
export const test = base.extend<{ autoLog: void }>({
  autoLog: [async ({ page }, use) => {
    page.on('console', msg => console.log(`[browser] ${msg.text()}`));
    await use();
  }, { auto: true }],
});

Authentication

认证

Save authenticated state to reuse. Never log in via UI in every test.
ts
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: authFile });
});
ts
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});
保存认证状态以复用。切勿在每个测试中都通过UI登录。
ts
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: authFile });
});
ts
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

API Authentication (Faster)

API认证(更快)

ts
setup('authenticate via API', async ({ request }) => {
  const response = await request.post('/api/auth/login', {
    data: { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD },
  });
  expect(response.ok()).toBeTruthy();
  await request.storageState({ path: authFile });
});
ts
setup('authenticate via API', async ({ request }) => {
  const response = await request.post('/api/auth/login', {
    data: { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD },
  });
  expect(response.ok()).toBeTruthy();
  await request.storageState({ path: authFile });
});

Network Mocking

网络模拟

Set up routes before navigation.
ts
test('displays mocked data', async ({ page }) => {
  await page.route('**/api/users', route => route.fulfill({
    json: [{ id: 1, name: 'Test User' }],
  }));

  await page.goto('/users');
  await expect(page.getByText('Test User')).toBeVisible();
});

// Modify real response
test('injects item into response', async ({ page }) => {
  await page.route('**/api/items', async route => {
    const response = await route.fetch();
    const json = await response.json();
    json.push({ id: 999, name: 'Injected' });
    await route.fulfill({ response, json });
  });
  await page.goto('/items');
});

// HAR recording
test('uses recorded responses', async ({ page }) => {
  await page.routeFromHAR('./fixtures/api.har', {
    url: '**/api/**',
    update: false, // true to record
  });
  await page.goto('/');
});
在导航前设置路由。
ts
test('displays mocked data', async ({ page }) => {
  await page.route('**/api/users', route => route.fulfill({
    json: [{ id: 1, name: 'Test User' }],
  }));

  await page.goto('/users');
  await expect(page.getByText('Test User')).toBeVisible();
});

// 修改真实响应
test('injects item into response', async ({ page }) => {
  await page.route('**/api/items', async route => {
    const response = await route.fetch();
    const json = await response.json();
    json.push({ id: 999, name: 'Injected' });
    await route.fulfill({ response, json });
  });
  await page.goto('/items');
});

// HAR录制
test('uses recorded responses', async ({ page }) => {
  await page.routeFromHAR('./fixtures/api.har', {
    url: '**/api/**',
    update: false, // true表示录制
  });
  await page.goto('/');
});

Test Isolation

测试隔离

Each test gets fresh browser context. Never share state between tests.
ts
// BAD: Tests depend on each other
let userId: string;
test('create user', async ({ request }) => {
  userId = (await (await request.post('/api/users', { data: { name: 'Test' } })).json()).id;
});
test('delete user', async ({ request }) => {
  await request.delete(`/api/users/${userId}`); // Depends on previous!
});

// GOOD: Each test creates its own data
test('can delete created user', async ({ request }) => {
  const { id } = await (await request.post('/api/users', { data: { name: 'Test' } })).json();
  const deleteResponse = await request.delete(`/api/users/${id}`);
  expect(deleteResponse.ok()).toBeTruthy();
});
每个测试都获得全新的浏览器上下文。切勿在测试之间共享状态。
ts
// 不推荐:测试之间相互依赖
let userId: string;
test('create user', async ({ request }) => {
  userId = (await (await request.post('/api/users', { data: { name: 'Test' } })).json()).id;
});
test('delete user', async ({ request }) => {
  await request.delete(`/api/users/${userId}`); // 依赖前一个测试!
});

// 推荐:每个测试创建自己的测试数据
test('can delete created user', async ({ request }) => {
  const { id } = await (await request.post('/api/users', { data: { name: 'Test' } })).json();
  const deleteResponse = await request.delete(`/api/users/${id}`);
  expect(deleteResponse.ok()).toBeTruthy();
});

Configuration

配置

ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  // Use minimal reporter in CI/agent contexts to prevent context overflow
  reporter: process.env.CI || process.env.CLAUDE
    ? [['line'], ['html', { open: 'never' }]]
    : 'list',

  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
  },

  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
      dependencies: ['setup'],
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
      dependencies: ['setup'],
    },
  ],

  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  // 在CI/Agent环境中使用极简报告器防止上下文溢出
  reporter: process.env.CI || process.env.CLAUDE
    ? [['line'], ['html', { open: 'never' }]]
    : 'list',

  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
  },

  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
      dependencies: ['setup'],
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
      dependencies: ['setup'],
    },
  ],

  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Project Structure

项目结构

tests/
  fixtures/           # Custom fixtures (extend base test)
  pages/              # Page Object Models
  helpers/            # Utility functions (API clients, data factories)
  auth.setup.ts       # Authentication setup project
  *.spec.ts           # Test files
playwright/
  .auth/              # Auth state storage (gitignored)
playwright.config.ts
Organize tests by feature or user journey. Colocate page objects with tests when possible.
tests/
  fixtures/           # 自定义Fixture(扩展基础测试)
  pages/              # Page Object Model
  helpers/            # 工具函数(API客户端、数据工厂)
  auth.setup.ts       # 认证初始化项目
  *.spec.ts           # 测试文件
playwright/
  .auth/              # 认证状态存储(已加入git忽略)
playwright.config.ts
按功能或用户旅程组织测试。尽可能将Page Object与测试放在同一目录下。

Helpers (Separate from Pages)

工具函数(与页面分离)

ts
// helpers/user.helper.ts
import type { Page } from '@playwright/test';
import debug from 'debug';

const log = debug('test:helper:user');

export class UserHelper {
  constructor(private page: Page) {}

  async createUser(data: { name: string; email: string }) {
    log('creating user: %s', data.email);
    const response = await this.page.request.post('/api/users', { data });
    return response.json();
  }

  async deleteUser(id: string) {
    log('deleting user: %s', id);
    await this.page.request.delete(`/api/users/${id}`);
  }
}

// helpers/data.factory.ts
export function createTestUser(overrides: Partial<User> = {}): User {
  return {
    id: crypto.randomUUID(),
    email: `test-${Date.now()}@example.com`,
    name: 'Test User',
    ...overrides,
  };
}
ts
// helpers/user.helper.ts
import type { Page } from '@playwright/test';
import debug from 'debug';

const log = debug('test:helper:user');

export class UserHelper {
  constructor(private page: Page) {}

  async createUser(data: { name: string; email: string }) {
    log('creating user: %s', data.email);
    const response = await this.page.request.post('/api/users', { data });
    return response.json();
  }

  async deleteUser(id: string) {
    log('deleting user: %s', id);
    await this.page.request.delete(`/api/users/${id}`);
  }
}

// helpers/data.factory.ts
export function createTestUser(overrides: Partial<User> = {}): User {
  return {
    id: crypto.randomUUID(),
    email: `test-${Date.now()}@example.com`,
    name: 'Test User',
    ...overrides,
  };
}

Debugging

调试

bash
npx playwright test --debug          # Step through with inspector
npx playwright test --trace on       # Record trace for all tests
npx playwright test --ui             # Interactive UI mode
npx playwright codegen localhost:3000 # Generate locators interactively
npx playwright show-report           # View HTML report
Enable debug logs:
DEBUG=test:* npx playwright test
bash
npx playwright test --debug          # 使用检查器逐步调试
npx playwright test --trace on       # 为所有测试录制追踪信息
npx playwright test --ui             # 交互式UI模式
npx playwright codegen localhost:3000 # 交互式生成定位器
npx playwright show-report           # 查看HTML报告
启用调试日志:
DEBUG=test:* npx playwright test

Anti-Patterns

反模式

  • page.waitForTimeout(ms)
    - use auto-waiting locators instead
  • page.locator('.class')
    - use role/label/testid
  • XPath selectors - fragile, use user-facing attributes
  • Shared state between tests - each test creates own data
  • UI login in every test - use setup project + storageState
  • Manual assertions without await - use web-first assertions
  • Hardcoded waits - rely on Playwright's auto-waiting
  • Default reporter in CI/agent - use
    --reporter=line
    or
    --reporter=dot
    to prevent context overflow
  • page.waitForTimeout(ms)
    - 改用支持自动等待的定位器
  • page.locator('.class')
    - 使用role/label/testid
  • XPath选择器 - 脆弱,使用面向用户的属性
  • 测试之间共享状态 - 每个测试创建自己的数据
  • 每个测试都通过UI登录 - 使用初始化项目+storageState
  • 不使用await的手动断言 - 使用Web优先断言
  • 硬编码等待 - 依赖Playwright的自动等待机制
  • 在CI/Agent中使用默认报告器 - 使用
    --reporter=line
    --reporter=dot
    防止上下文溢出

Checklist

检查清单

  • Locators use role/label/testid, not CSS classes or XPath
  • All assertions use
    await expect()
    web-first matchers
  • Page objects define locators in constructor
  • No
    page.waitForTimeout()
    - use auto-waiting
  • Tests isolated - no shared state
  • Auth state reused via setup project
  • Network mocks set up before navigation
  • Test data created per-test or via fixtures
  • Debug logging added for complex flows
  • Minimal reporter (
    line
    /
    dot
    ) used in CI/agent contexts
  • 定位器使用role/label/testid,而非CSS类或XPath
  • 所有断言使用
    await expect()
    Web优先匹配器
  • Page Object在构造函数中定义定位器
  • 不使用
    page.waitForTimeout()
    - 改用自动等待
  • 测试隔离 - 无共享状态
  • 通过初始化项目复用认证状态
  • 网络模拟在导航前设置
  • 测试数据按测试创建或通过Fixture提供
  • 为复杂流程添加调试日志
  • 在CI/Agent环境中使用极简报告器(
    line
    /
    dot