بنقرة واحدة
qa-e2e-playwright
Playwright E2E 测试完整方法论,涵盖项目初始化、Page Object Model、认证复用、API Mock、视觉回归、多浏览器测试、CI 集成和调试技巧
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
القائمة
Playwright E2E 测试完整方法论,涵盖项目初始化、Page Object Model、认证复用、API Mock、视觉回归、多浏览器测试、CI 集成和调试技巧
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
استنادا إلى تصنيف SOC المهني
BMAD 全自动研发流水线编排器。编排 9 个专业 Agent(PM、架构师、UI Designer、Tech Lead、Scrum Master、Frontend、Backend、QA、DevOps)从需求到部署。 Triggers: 'boss mode', '/boss', '全自动开发', '从需求到部署', '帮我做一个', 'build this', 'ship it', '全流程', '自动化开发', '一键开发', 'start a project', 'new feature' Does NOT trigger: - 单文件修改或简单 bug 修复(直接编辑即可) - 纯代码阅读或解释(使用 read 工具) - 已有 pipeline 正在运行时的重复启动 - 极小事(预计 <30 分钟人工可完成、不需要 PRD/架构/门禁记录) Output: 完整项目代码 + PRD/架构/UI/测试/部署文档,写入 .boss/<feature>/ 目录
后端测试编写指南,包括单元测试、集成测试和E2E测试的编写方法和最佳实践
自动生成 CHANGELOG,基于 git 提交历史和 pipeline 产物信息,遵循 Conventional Commits 和 Keep a Changelog 规范
从CEO/战略视角进行商业价值评审,评估市场契合度、ROI、竞争优势、风险和战略对齐
设计变体模式,产出2-3个设计方案及 tradeoff 分析,供用户选择后确定最终方案
前端测试编写指南,包括单元测试、集成测试和E2E测试的编写方法和最佳实践
| name | qa/e2e-playwright |
| description | Playwright E2E 测试完整方法论,涵盖项目初始化、Page Object Model、认证复用、API Mock、视觉回归、多浏览器测试、CI 集成和调试技巧 |
| version | 1.0.0 |
| agent | qa |
| type | methodology |
| user-invocable | false |
| agent-invocable | true |
| dependencies | ["shared/tech-stack-detection"] |
| triggers | ["需要编写或执行 E2E 测试时","需要配置 Playwright 测试环境时","门禁要求 E2E 测试通过时","需要视觉回归测试时"] |
# 新项目初始化(推荐)
npm init playwright@latest
# 已有项目添加
npm install -D @playwright/test
npx playwright install
playwright.config.ts)import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
// 测试产物目录
outputDir: './e2e/test-results',
// 全局超时
timeout: 30_000,
expect: { timeout: 5_000 },
// 并行执行
fullyParallel: true,
workers: process.env.CI ? 1 : undefined,
// 失败重试(CI 中重试一次减少 flaky)
retries: process.env.CI ? 1 : 0,
// 报告
reporter: [
['html', { outputFolder: './e2e/playwright-report' }],
['json', { outputFile: './e2e/test-results/results.json' }],
// CI 中额外输出到 stdout
...(process.env.CI ? [['github'] as const] : []),
],
// 全局配置
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
// 失败时自动截图
screenshot: 'only-on-failure',
// 失败时录制 trace
trace: 'on-first-retry',
// 失败时录制视频
video: 'on-first-retry',
},
// 多浏览器 + 移动端视口
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
],
// 开发服务器自动启动
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
关键配置说明:
| 配置项 | 作用 | 建议值 |
|---|---|---|
fullyParallel | 测试文件间并行执行 | true |
workers | 并行 worker 数 | CI 为 1,本地默认 |
retries | 失败重试次数 | CI 为 1,本地为 0 |
trace | 失败时生成可视化时间线 | on-first-retry |
webServer | 自动启动开发服务器 | 必须配置 |
e2e/
├── playwright.config.ts # 配置文件(或放在项目根目录)
├── fixtures/ # 自定义 fixtures
│ ├── base.ts # 扩展 base test
│ └── auth.ts # 认证 fixture
├── pages/ # Page Object Models
│ ├── login.page.ts
│ ├── dashboard.page.ts
│ └── components/ # 可复用组件 POM
│ ├── navbar.component.ts
│ └── modal.component.ts
├── specs/ # 测试用例
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── register.spec.ts
│ ├── dashboard/
│ │ └── dashboard.spec.ts
│ └── crud/
│ └── user-management.spec.ts
├── helpers/ # 测试工具
│ ├── seed.ts # 数据种子
│ └── cleanup.ts # 数据清理
├── test-results/ # 测试产物(gitignore)
└── playwright-report/ # HTML 报告(gitignore)
// e2e/pages/login.page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorMessage: Locator;
constructor(private readonly page: Page) {
this.emailInput = page.getByLabel('邮箱');
this.passwordInput = page.getByLabel('密码');
this.submitButton = page.getByRole('button', { name: '登录' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async getErrorMessage() {
return this.errorMessage.textContent();
}
}
// e2e/pages/components/navbar.component.ts
import { type Page, type Locator } from '@playwright/test';
export class NavbarComponent {
private readonly userMenu: Locator;
private readonly logoutButton: Locator;
constructor(private readonly page: Page) {
this.userMenu = page.getByTestId('user-menu');
this.logoutButton = page.getByRole('menuitem', { name: '退出登录' });
}
async logout() {
await this.userMenu.click();
await this.logoutButton.click();
}
async getUserDisplayName() {
return this.userMenu.textContent();
}
}
选择定位器时遵循以下优先级(可靠性从高到低):
| 优先级 | 方法 | 示例 | 说明 |
|---|---|---|---|
| 1 | getByRole | getByRole('button', { name: '提交' }) | 无障碍语义,最稳定 |
| 2 | getByLabel | getByLabel('邮箱') | 表单元素首选 |
| 3 | getByPlaceholder | getByPlaceholder('请输入邮箱') | 备选 |
| 4 | getByText | getByText('欢迎回来') | 静态文本 |
| 5 | getByTestId | getByTestId('submit-btn') | 无语义标记时的兜底 |
| 6 | CSS/XPath | page.locator('.btn-primary') | 尽量避免 |
// e2e/global-setup.ts
import { chromium, type FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const page = await browser.newPage();
// 执行登录
await page.goto('http://localhost:3000/login');
await page.getByLabel('邮箱').fill('admin@example.com');
await page.getByLabel('密码').fill('password');
await page.getByRole('button', { name: '登录' }).click();
await page.waitForURL('/dashboard');
// 保存认证状态
await page.context().storageState({ path: './e2e/.auth/admin.json' });
await browser.close();
}
export default globalSetup;
配置引用:
// playwright.config.ts
export default defineConfig({
globalSetup: './e2e/global-setup.ts',
projects: [
// 不带认证的测试
{ name: 'public', testMatch: /public\.spec\.ts/ },
// 带认证的测试
{
name: 'authenticated',
use: { storageState: './e2e/.auth/admin.json' },
testIgnore: /public\.spec\.ts/,
},
],
});
// e2e/fixtures/auth.ts
import { test as base } from '@playwright/test';
type AuthFixtures = {
adminPage: Page;
userPage: Page;
};
export const test = base.extend<AuthFixtures>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: './e2e/.auth/admin.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
userPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: './e2e/.auth/user.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
});
page.route 拦截请求test('显示用户列表(API Mock)', async ({ page }) => {
// 拦截 API 请求
await page.route('/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: '张三', email: 'zhang@example.com' },
{ id: 2, name: '李四', email: 'li@example.com' },
]),
});
});
await page.goto('/users');
await expect(page.getByText('张三')).toBeVisible();
await expect(page.getByText('李四')).toBeVisible();
});
test('API 失败时显示错误提示', async ({ page }) => {
await page.route('/api/users', async (route) => {
await route.fulfill({ status: 500, body: 'Internal Server Error' });
});
await page.goto('/users');
await expect(page.getByText('加载失败')).toBeVisible();
await expect(page.getByRole('button', { name: '重试' })).toBeVisible();
});
test('部分 API Mock', async ({ page }) => {
// 只 Mock 第三方支付接口,其余走真实请求
await page.route('**/api/payment/**', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify({ success: true, transactionId: 'mock-tx-001' }),
});
});
await page.goto('/checkout');
// ... 执行支付流程
});
| 场景 | 建议 |
|---|---|
| 核心用户路径 | 真实 API(关键路径证据规则) |
| 第三方服务(支付、邮件) | Mock |
| 错误/边界状态 | Mock |
| 加载状态、空数据 | Mock |
| 数据量大的列表/分页 | Mock + 至少一条真实路径 |
⚠️ Boss 门禁规则:核心用户路径只由 Mock 证明的,必须标记为未验证,不能作为发布证据。
// e2e/specs/crud/user-management.spec.ts
import { test, expect } from '@playwright/test';
import { UserListPage } from '../../pages/user-list.page';
import { UserFormPage } from '../../pages/user-form.page';
test.describe('用户管理 CRUD', () => {
let userList: UserListPage;
let userForm: UserFormPage;
test.beforeEach(async ({ page }) => {
userList = new UserListPage(page);
userForm = new UserFormPage(page);
await userList.goto();
});
test('创建 → 编辑 → 删除完整流程', async ({ page }) => {
// 创建
await userList.clickAddUser();
await userForm.fillName('测试用户');
await userForm.fillEmail('test@example.com');
await userForm.submit();
await expect(page.getByText('测试用户')).toBeVisible();
// 编辑
await userList.editUser('测试用户');
await userForm.fillName('修改后的用户');
await userForm.submit();
await expect(page.getByText('修改后的用户')).toBeVisible();
await expect(page.getByText('测试用户')).not.toBeVisible();
// 删除
await userList.deleteUser('修改后的用户');
await userList.confirmDelete();
await expect(page.getByText('修改后的用户')).not.toBeVisible();
});
test('列表分页展示', async ({ page }) => {
await expect(userList.getTable()).toBeVisible();
await userList.goToNextPage();
await expect(page).toHaveURL(/page=2/);
});
test('空列表提示', async ({ page }) => {
// 使用 route mock 空数据场景
await page.route('/api/users*', (route) =>
route.fulfill({ status: 200, body: JSON.stringify({ data: [], total: 0 }) })
);
await page.reload();
await expect(page.getByText('暂无数据')).toBeVisible();
});
});
// e2e/specs/auth/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../../pages/login.page';
test.describe('登录', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('正确凭据成功登录', async ({ page }) => {
await loginPage.login('admin@example.com', 'password');
await expect(page).toHaveURL('/dashboard');
});
test('错误凭据显示提示', async ({ page }) => {
await loginPage.login('admin@example.com', 'wrong-password');
await expect(page.getByRole('alert')).toContainText('密码错误');
await expect(page).toHaveURL('/login');
});
test('未登录重定向到登录页', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});
});
test.describe('表单验证', () => {
test('必填字段为空时显示错误', async ({ page }) => {
await page.goto('/users/new');
await page.getByRole('button', { name: '提交' }).click();
await expect(page.getByText('姓名不能为空')).toBeVisible();
await expect(page.getByText('邮箱不能为空')).toBeVisible();
});
test('邮箱格式错误时显示提示', async ({ page }) => {
await page.goto('/users/new');
await page.getByLabel('邮箱').fill('invalid-email');
await page.getByLabel('邮箱').blur();
await expect(page.getByText('邮箱格式不正确')).toBeVisible();
});
});
test('首页视觉回归', async ({ page }) => {
await page.goto('/');
// 等待动态内容稳定
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixelRatio: 0.01,
});
});
test('组件视觉回归', async ({ page }) => {
await page.goto('/components/button');
const button = page.getByTestId('primary-button');
await expect(button).toHaveScreenshot('primary-button.png');
});
# 更新所有截图基线
npx playwright test --update-snapshots
# 更新指定测试的截图
npx playwright test homepage.spec.ts --update-snapshots
maxDiffPixelRatio 容忍微小渲染差异(抗锯齿等)// playwright.config.ts 中的 projects 已覆盖
// 可根据项目需要选择性启用:
projects: [
// 桌面浏览器
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
// 移动端视口
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
// 平板
{ name: 'tablet', use: { ...devices['iPad Pro 11'] } },
]
# 只跑 Chromium
npx playwright test --project=chromium
# 只跑移动端
npx playwright test --project=mobile-chrome --project=mobile-safari
test('移动端显示汉堡菜单', async ({ page, isMobile }) => {
await page.goto('/');
if (isMobile) {
await expect(page.getByTestId('hamburger-menu')).toBeVisible();
await expect(page.getByTestId('desktop-nav')).not.toBeVisible();
} else {
await expect(page.getByTestId('desktop-nav')).toBeVisible();
await expect(page.getByTestId('hamburger-menu')).not.toBeVisible();
}
});
// e2e/helpers/seed.ts
import { request } from '@playwright/test';
export async function seedTestData(baseURL: string) {
const api = await request.newContext({ baseURL });
await api.post('/api/test/seed', {
data: {
users: [
{ name: '测试管理员', email: 'admin@test.com', role: 'admin' },
{ name: '测试用户', email: 'user@test.com', role: 'user' },
],
},
});
await api.dispose();
}
// e2e/helpers/cleanup.ts
import { request } from '@playwright/test';
export async function cleanupTestData(baseURL: string) {
const api = await request.newContext({ baseURL });
await api.post('/api/test/cleanup');
await api.dispose();
}
// e2e/global-setup.ts
import { seedTestData } from './helpers/seed';
async function globalSetup() {
await seedTestData('http://localhost:3000');
}
export default globalSetup;
// e2e/global-teardown.ts
import { cleanupTestData } from './helpers/cleanup';
async function globalTeardown() {
await cleanupTestData('http://localhost:3000');
}
export default globalTeardown;
| 原则 | 说明 |
|---|---|
| 测试隔离 | 每个测试独立运行,不依赖其他测试的数据 |
| 可重复 | 多次运行结果一致 |
| 快速清理 | 使用 API 清理而非 UI 操作 |
| 避免硬编码 ID | 使用动态创建的数据,不依赖数据库自增 ID |
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Build application
run: npm run build
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: e2e/playwright-report/
retention-days: 30
- name: Upload trace files
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: e2e/test-results/
retention-days: 7
# e2e/Dockerfile
FROM mcr.microsoft.com/playwright:v1.49.0-noble
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["npx", "playwright", "test"]
| 配置 | CI 值 | 原因 |
|---|---|---|
workers | 1 | CI 资源有限,避免争抢 |
retries | 1 | 减少 flaky 误报 |
reporter | github + html | GitHub PR 行内注释 + HTML 归档 |
trace | on-first-retry | 只在重试时录制,节省空间 |
# 打开可视化调试界面
npx playwright test --ui
# 查看失败测试的 trace
npx playwright show-trace e2e/test-results/specs-auth-login-spec-ts/trace.zip
Trace Viewer 提供:
# 逐步执行,自动暂停
npx playwright test --debug
# 指定测试文件
npx playwright test login.spec.ts --debug
test('调试用', async ({ page }) => {
await page.goto('/login');
await page.pause(); // 打开 Inspector,手动操作
// ...
});
# 打开浏览器录制,自动生成代码
npx playwright codegen http://localhost:3000
| 原因 | 症状 | 修复 |
|---|---|---|
| 动画未完成 | 元素找到但点击无效 | 等待动画完成或禁用动画 |
| 网络请求延迟 | 元素内容未更新 | 使用 waitForResponse |
| 竞态条件 | 间歇性失败 | 使用 expect().toBeVisible() 自动等待 |
| 数据未就绪 | 列表为空 | 使用 waitForLoadState('networkidle') |
| 时间依赖 | 日期/时间相关断言失败 | Mock 时间或使用范围断言 |
Boss 流水线的 Gate 1 门禁要求 E2E 测试满足:
| 检查项 | 要求 | 说明 |
|---|---|---|
| E2E 测试存在 | e2e/ 或 tests/e2e/ 目录非空 | 不能只有单元测试 |
| 关键路径覆盖 | CRUD + 认证 + 核心业务流程 | 至少 5 个核心用户操作 |
| 全部通过 | 无失败用例 | 失败则门禁不通过 |
| 真实 API 证据 | 核心路径不能只有 Mock | Mock-only 标记为未验证 |
# 门禁检查时执行
npx playwright test --reporter=json --output=e2e/test-results
# 仅核心路径(CI 加速)
npx playwright test --grep @critical
# 完整套件
npx playwright test
使用标签标记测试优先级,门禁可按标签选择性执行:
test('@critical 登录流程', async ({ page }) => { /* ... */ });
test('@critical 核心业务流程', async ({ page }) => { /* ... */ });
test('@smoke 首页加载', async ({ page }) => { /* ... */ });
test('@regression 边界情况', async ({ page }) => { /* ... */ });
# Gate 1 只跑 critical
npx playwright test --grep @critical
# 完整回归
npx playwright test
Agent 编写 E2E 测试时,对照以下清单确认完整性:
playwright.config.ts 已配置(baseURL、webServer、projects)getByRole > getByTestId > CSS)storageState).gitignore(test-results/、playwright-report/)使用本方法论后,Agent 应产出:
playwright.config.ts:完整配置e2e/pages/*.page.ts:Page Object Modele2e/specs/**/*.spec.ts:测试用例(覆盖关键路径)storageState 或 global-setup.gitignore 更新:排除测试产物目录