بنقرة واحدة
qa-e2e-playwright
// Playwright E2E 测试完整方法论,涵盖项目初始化、Page Object Model、认证复用、API Mock、视觉回归、多浏览器测试、CI 集成和调试技巧
// Playwright E2E 测试完整方法论,涵盖项目初始化、Page Object Model、认证复用、API Mock、视觉回归、多浏览器测试、CI 集成和调试技巧
BMAD 全自动研发流水线编排器。编排 9 个专业 Agent(PM、架构师、UI 设计师、Tech Lead、Scrum Master、开发者、QA、DevOps)从需求到部署一气呵成。 Triggers: 'boss mode', '/boss', '全自动开发', '从需求到部署', '帮我做一个', 'build this', 'ship it', '全流程', '自动化开发', '一键开发', 'start a project', 'new feature' Does NOT trigger: - 单文件修改或简单 bug 修复(直接编辑即可) - 纯代码阅读或解释(使用 read 工具) - 已有 pipeline 正在运行时的重复启动 Output: 完整项目代码 + PRD/架构/UI/测试/部署文档,写入 .boss/<feature>/ 目录
自动生成 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 更新:排除测试产物目录