Playwright最佳实践 playwright-best-practices

这是一份关于如何高效使用 Playwright 进行自动化测试的指南,涵盖了从测试模式、定位器选择、页面对象模型、断言方法到网络模拟等多个方面的实践技巧和建议。

测试 0 次安装 0 次浏览 更新于 2/23/2026

name: playwright-best-practices description: 提供 Playwright 测试模式,用于弹性定位器、页面对象模型、固定装置、基于网络的断言和网络模拟。在编写或修改 Playwright 测试(.spec.ts, .test.ts 文件带有 @playwright/test 导入)时必须使用。

Playwright 最佳实践

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

当从 Claude Code 或任何 CLI 代理运行 Playwright 测试时,始终使用最小化报告器以防止冗余输出消耗上下文窗口。

使用 --reporter=line--reporter=dot 进行 CLI 测试运行:

# 必需:使用最小报告器以防止上下文溢出
npx playwright test --reporter=line
npx playwright test --reporter=dot

# 坏做法:默认报告器生成数千行,淹没上下文
npx playwright test

配置 playwright.config.ts 在设置 CICLAUDE 环境变量时默认使用最小化报告器:

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

定位器优先级(最耐用到最不耐用)

始终优先选择面向用户的属性:

  1. page.getByRole('button', { name: 'Submit' }) - 可访问性角色
  2. page.getByLabel('Email') - 表单控件标签
  3. page.getByPlaceholder('Search...') - 输入占位符
  4. page.getByText('Welcome') - 可见文本(非交互式)
  5. page.getByAltText('Logo') - 图像 alt 文本
  6. page.getByTitle('Settings') - 标题属性
  7. page.getByTestId('submit-btn') - 明确的测试合同
  8. CSS/XPath - 最后的选择,避免
// 坏做法:与实现紧密相关的脆弱选择器
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')

链式调用和过滤

// 在区域内限定范围
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' }));

严格性

定位器在多个元素匹配时抛出异常。仅在有意图时使用 first(), last(), nth()

// 如果多个按钮匹配则抛出异常
await page.getByRole('button', { name: 'Delete' }).click();

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

基于网络的断言

使用异步断言,自动等待和重试:

// 坏做法:无自动等待,不稳定
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');
// 测试继续,失败在最后编译

页面对象模型

封装页面交互。在构造函数中将定位器定义为只读属性。

// 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>;
}
// 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);
  }
}

固定装置

优先选择固定装置而不是 beforeEach/afterEach。固定装置封装设置 + 清理,按需运行,并与依赖项组合。

// 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 };

工作范围固定装置

用于跨测试共享的昂贵设置(数据库连接,认证用户):

// 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) => {
    // 昂贵的设置 - 每个工作器运行一次
    const user = await createTestUser();
    const token = await authenticateUser(user);

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

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

自动固定装置

每个测试运行时无需显式声明:

export const test = base.extend<{ autoLog: void }>({
  autoLog: [async ({ page }, use) => {
    page.on('console', msg => console.log(`[browser] ${msg.text()}`));
    await use();
  }, { auto: true }],
});

认证

保存认证状态以重用。不要在每个测试中通过 UI 登录。

// 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 });
});
// 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 认证(更快)

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 });
});

网络模拟

在导航之前设置路由。

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 to record
  });
  await page.goto('/');
});

测试隔离

每个测试获得新的浏览器上下文。永远不要在测试之间共享状态。

// 坏做法:测试相互依赖
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();
});

配置

// 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,
  },
});

项目结构

tests/
  fixtures/           # 自定义固定装置(扩展基础测试)
  pages/              # 页面对象模型
  helpers/            # 工具函数(API 客户端,数据工厂)
  auth.setup.ts       # 认证设置项目
  *.spec.ts           # 测试文件
playwright/
  .auth/              # 认证状态存储(gitignored)
playwright.config.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,
  };
}

调试

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

反模式

  • page.waitForTimeout(ms) - 使用自动等待定位器代替
  • page.locator('.class') - 使用 role/label/testid
  • XPath 选择器 - 脆弱,使用面向用户的属性
  • 测试之间的共享状态 - 每个测试创建自己的数据
  • 每个测试中的 UI 登录 - 使用设置项目 + storageState
  • 手动断言没有 await - 使用基于网络的断言
  • 硬编码等待 - 依赖 Playwright 的自动等待
  • CI/agent 中的默认报告器 - 使用 --reporter=line--reporter=dot 防止上下文溢出

清单

  • [ ] 定位器使用 role/label/testid,而不是 CSS 类或 XPath
  • [ ] 所有断言使用 await expect() 基于网络的匹配器
  • [ ] 页面对象在构造函数中定义定位器
  • [ ] 无 page.waitForTimeout() - 使用自动等待
  • [ ] 测试隔离 - 无共享状态
  • [ ] 通过设置项目重用认证状态
  • [ ] 在导航前设置网络模拟
  • [ ] 每个测试创建测试数据或通过固定装置
  • [ ] 为复杂流程添加调试日志
  • [ ] 在 CI/agent 上下文中使用最小报告器(line/dot)