playwright-best-practices by 0xbigboss/claude-code
npx skills add https://github.com/0xbigboss/claude-code --skill playwright-best-practices当从 Claude Code 或任何 CLI 代理运行 Playwright 测试时,始终使用最简化的报告器,以防止冗长的输出消耗上下文窗口。
对于 CLI 测试运行,请使用 --reporter=line 或 --reporter=dot:
# 必需:使用最简化报告器以防止上下文溢出
npx playwright test --reporter=line
npx playwright test --reporter=dot
# 错误:默认报告器会生成数千行输出,淹没上下文
npx playwright test
在 playwright.config.ts 中配置,当设置了 CI 或 CLAUDE 环境变量时,默认使用最简化报告器:
reporter: process.env.CI || process.env.CLAUDE
? [['line'], ['html', { open: 'never' }]]
: 'list',
始终优先使用面向用户的属性:
page.getByRole('button', { name: 'Submit' }) - 无障碍角色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:
# REQUIRED: Use minimal reporter to prevent context overflow
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:
reporter: process.env.CI || process.env.CLAUDE
? [['line'], ['html', { open: 'never' }]]
: 'list',
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
page.getByLabel('Email') - 表单控件标签page.getByPlaceholder('Search...') - 输入框占位符page.getByText('Welcome') - 可见文本(非交互式)page.getByAltText('Logo') - 图片替代文本page.getByTitle('Settings') - 标题属性page.getByTestId('submit-btn') - 明确的测试契约// 错误:脆弱的定位器,与实现紧密耦合
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'],
},
],
});
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 表示录制
});
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/代理上下文中使用最简化报告器以防止上下文溢出
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/ # 认证状态存储(git 忽略)
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') - 使用角色/标签/testid--reporter=line 或 --reporter=dot 以防止上下文溢出await expect() 网页优先匹配器page.waitForTimeout() - 使用自动等待line/dot)每周安装量
141
代码仓库
GitHub 星标数
36
首次出现
2026年1月20日
安全审计
安装于
claude-code113
opencode108
codex106
gemini-cli104
github-copilot98
cursor97
Always prefer user-facing attributes:
page.getByRole('button', { name: 'Submit' }) - accessibility rolespage.getByLabel('Email') - form control labelspage.getByPlaceholder('Search...') - input placeholderspage.getByText('Welcome') - visible text (non-interactive)page.getByAltText('Logo') - image alt textpage.getByTitle('Settings') - title attributespage.getByTestId('submit-btn') - explicit test contracts// 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')
// 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' }));
Locators throw if multiple elements match. Use first(), last(), nth() only when intentional:
// 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();
Use async assertions that auto-wait and retry:
// 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
Encapsulate page interactions. Define locators as readonly properties in constructor.
// 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);
}
}
Prefer fixtures over beforeEach/afterEach. Fixtures encapsulate setup + teardown, run on-demand, and compose with dependencies.
// 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 };
Use for expensive setup shared across tests (database connections, authenticated users):
// 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' }],
});
Run for every test without explicit declaration:
export const test = base.extend<{ autoLog: void }>({
autoLog: [async ({ page }, use) => {
page.on('console', msg => console.log(`[browser] ${msg.text()}`));
await use();
}, { auto: true }],
});
Save authenticated state to reuse. Never log in via UI in every test.
// 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'],
},
],
});
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 });
});
Set up routes before navigation.
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('/');
});
Each test gets fresh browser context. Never share state between tests.
// 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();
});
// 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,
},
});
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.
// 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 # 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
page.waitForTimeout(ms) - use auto-waiting locators insteadpage.locator('.class') - use role/label/testid--reporter=line or --reporter=dot to prevent context overflowawait expect() web-first matcherspage.waitForTimeout() - use auto-waitingline/dot) used in CI/agent contextsWeekly Installs
141
Repository
GitHub Stars
36
First Seen
Jan 20, 2026
Security Audits
Installed on
claude-code113
opencode108
codex106
gemini-cli104
github-copilot98
cursor97
AI新闻播客制作技能:实时新闻转对话式播客脚本与音频生成
1,200 周安装