- 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 测试通过时","需要视觉回归测试时"]
- metadata
- {"internal":true}
# Playwright E2E 测试方法论
## 适用场景
- Web 项目需要编写端到端测试
- 门禁(Gate 1)要求 E2E 测试通过
- 需要覆盖关键用户流程的自动化验证
- 需要多浏览器/多视口兼容性验证
- 需要视觉回归测试
---
## 1. 项目初始化
### 1.1 安装
```bash
# 新项目初始化(推荐)
npm init playwright@latest
# 已有项目添加
npm install -D @playwright/test
npx playwright install
```
### 1.2 配置文件(`playwright.config.ts`)
```typescript
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` | 自动启动开发服务器 | 必须配置 |
### 1.3 目录结构
```
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)
```
---
## 2. Page Object Model(POM)
### 2.1 核心原则
- **每个页面一个 POM 类**:封装定位器和操作方法
- **不暴露 Locator**:外部只调用语义化方法
- **组件级复用**:导航栏、弹窗等提取为独立组件 POM
### 2.2 基础 POM
```typescript
// 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();
}
}
```
### 2.3 组件 POM
```typescript
// 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();
}
}
```
### 2.4 定位器优先级
选择定位器时遵循以下优先级(可靠性从高到低):
| 优先级 | 方法 | 示例 | 说明 |
|--------|------|------|------|
| 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')` | **尽量避免** |
---
## 3. 认证状态复用
### 3.1 Global Setup 方式
```typescript
// 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;
```
**配置引用**:
```typescript
// 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/,
},
],
});
```
### 3.2 多角色认证
```typescript
// 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();
},
});
```
---
## 4. API Mocking
### 4.1 使用 `page.route` 拦截请求
```typescript
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();
});
```
### 4.2 模拟错误响应
```typescript
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();
});
```
### 4.3 Mock 与真实请求混合
```typescript
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');
// ... 执行支付流程
});
```
### 4.4 何时用 Mock vs 真实 API
| 场景 | 建议 |
|------|------|
| 核心用户路径 | **真实 API**(关键路径证据规则) |
| 第三方服务(支付、邮件) | Mock |
| 错误/边界状态 | Mock |
| 加载状态、空数据 | Mock |
| 数据量大的列表/分页 | Mock + 至少一条真实路径 |
⚠️ **Boss 门禁规则**:核心用户路径只由 Mock 证明的,必须标记为**未验证**,不能作为发布证据。
---
## 5. 关键用户流程测试(必须覆盖)
### 5.1 CRUD 完整流程
```typescript
// 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();
Ver no GitHub