Playwright E2E 自动化测试接入指南
基于 OAuth2 Admin 项目实践总结,面向其他前端项目的 Playwright E2E 测试接入参考。
目录
1. 核心原理
1.1 为什么选择请求拦截而不是真实后端
| 方案 | 优点 | 缺点 |
|---|---|---|
| 请求拦截 Mock | 无需后端依赖、执行快(<5s)、稳定不 flaky、可自由控制响应 | 不验证前后端集成 |
| 真实后端 | 验证端到端集成 | 需要数据库/缓存/服务、慢(分钟级)、环境依赖多、数据隔离难 |
| MSW (Mock Service Worker) | 浏览器层拦截、更贴近真实 | 配置复杂、需要 Service Worker 支持 |
本项目选择 Playwright 原生 page.route() 请求拦截,理由:
- 零额外依赖(Playwright 内置)
- API 简洁直观
- 拦截发生在网络层之前,性能最好
- 支持精确匹配 URL 模式和 HTTP 方法
- 支持在单个测试中覆盖全局 Mock
1.2 请求拦截工作流程
┌──────────────┐ HTTP 请求 ┌──────────────────┐
│ 前端应用代码 │ ───────────────────→ │ page.route() │
│ (浏览器) │ │ URL 模式匹配 │
│ │ ←─────────────────── │ route.fulfill() │
└──────────────┘ Mock 响应 └──────────────────┘
↑ 不经过网络层
(无真实 HTTP 连接)
Playwright 的 page.route() 在浏览器网络层拦截请求,请求不会离开浏览器进程。这意味着:
- 不需要启动后端服务
- 不需要网络连接
- 响应是即时的,无延迟
- 测试完全确定性,无网络 flaky
1.3 三层架构
tests/e2e/
├── helpers/
│ └── mock-api.ts ← Layer 1: Mock 数据 + 拦截器
├── auth.spec.ts ← Layer 2: 测试用例
├── applications.spec.ts
└── ...
playwright.config.ts ← Layer 3: Playwright 配置
| 层次 | 职责 | 修改频率 |
|---|---|---|
| Mock 层 | 定义 Mock 数据常量 + setupAuthenticatedMocks() 全局拦截函数 | 后端 API 变更时 |
| 测试层 | 编写具体测试用例,调用 Mock 层提供的函数 | 新功能/新页面时 |
| 配置层 | Playwright 运行参数、浏览器、webServer | 项目初始化时 |
2. 项目初始化
2.1 安装依赖
npm install -D @playwright/test
npx playwright install chromium
仅需 Chromium,无需安装 WebKit/Firefox。E2E 测试的目标是验证功能逻辑,不是跨浏览器兼容性。
2.2 Playwright 配置
创建 playwright.config.ts:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e', // 测试文件目录
fullyParallel: true, // 全并行执行(测试间无依赖时开启)
forbidOnly: !!process.env.CI, // CI 禁止 test.only(防止误提交)
retries: process.env.CI ? 2 : 0, // CI 重试 2 次(抗 flaky)
workers: process.env.CI ? 1 : undefined, // CI 单 worker(避免资源竞争)
reporter: 'html', // HTML 测试报告
use: {
baseURL: 'http://localhost:5174', // 应用基础 URL(配合 page.goto() 使用)
trace: 'on-first-retry', // 失败重试时记录 trace(调试用)
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev', // 自动启动 dev server
url: 'http://localhost:5174/', // 等待此 URL 可访问
reuseExistingServer: !process.env.CI, // 本地复用已运行的 server
timeout: 30000, // 启动超时 30s
},
})
关键配置说明:
| 配置项 | 作用 | 推荐值 |
|---|---|---|
baseURL | page.goto('/path') 时自动拼接此前 缀 | 开发服务器地址 |
webServer | 自动启动/复用 dev server | 开发模式 + CI 模式区分 |
trace | 失败时生成 trace 文件,可用 npx playwright show-trace 调试 | on-first-retry |
fullyParallel | 多个 test 文件并行执行 | true(Mock 模式下安全) |
retries | 失败重试次数 | CI: 2, 本地: 0 |
2.3 package.json 脚本
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
}
}
2.4 目录结构
your-project/
├── playwright.config.ts
├── package.json
├── src/ ← 应用源码
└── tests/
└── e2e/
├── helpers/
│ └── mock-api.ts ← Mock 数据 + 拦截函数
├── auth.spec.ts ← 认证相关测试
├── page-a.spec.ts ← 页面 A 测试
└── page-b.spec.ts ← 页面 B 测试
3. Mock API 层设计
Mock API 层是整个测试体系的核心。设计好这一层,编写测试用例会非常简单。
3.1 文件结构
tests/e2e/helpers/mock-api.ts 分为三个部分:
Part 1: Mock 数据常量 → 定义所有 API 的假数据
Part 2: setupAuthenticatedMocks() → 注册全局路由拦截
Part 3: 辅助函数 → loginAsAdmin() 等常用操作
3.2 Mock 数据常量
原则:数据尽量真实,字段与后端 API 响应一致。
// ✅ 好的设计:字段名、类型与真实 API 一致
export const MOCK_USERS = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
username: 'admin',
email_verified: true, // boolean,不是字符串
mfa_enabled: true,
},
{
id: '660e8400-e29b-41d4-a716-446655440001',
username: 'testuser',
email_verified: false,
mfa_enabled: false,
},
]
// ❌ 不好的设计:字段名随意、数据不真实
export const users = [
{ uid: 1, name: 'a', mail: 'a@b' }, // 字段名不匹配真实 API
]
为什么需要多组数据?
Mock 数据至少准备两种状态,覆盖不同的 UI 表现:
email_verified: true+false→ 测试"已验证"和"待验证"Badgemfa_enabled: true+false→ 测试"已开启"和"未开启"Badge
3.3 路由拦截:setupAuthenticatedMocks()
这是最关键的函数,负责拦截前端发出的所有 API 请求。
import { Page } from '@playwright/test'
export async function setupAuthenticatedMocks(page: Page) {
// 拦截规则:** 是通配符,匹配任何 origin
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ users: MOCK_USERS }),
})
})
}
URL 匹配模式说明:
| 模式 | 匹配范围 | 示例 |
|---|---|---|
**/api/users | 任何 origin + 路径精确匹配 | http://localhost:5174/api/users ✅ |
**/api/admin/logs** | 路径前缀匹配(含查询参数) | /api/admin/logs?page=2 ✅ |
**/api/admin/clients/* | 路径 + 单段通配 | /api/admin/clients/vue-client ✅ |
**/api/admin/clients/*/reset-secret | 多段路径混合 | /api/admin/clients/vue-client/reset-secret ✅ |
同一个 URL,不同 HTTP 方法:
await page.route('**/api/admin/clients', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, body: JSON.stringify({ clients: MOCK_CLIENTS }) })
} else if (route.request().method() === 'POST') {
await route.fulfill({ status: 201, body: JSON.stringify({ client_id: 'new-123' }) })
} else {
// 未预期的方法,交给下一个 handler 或真实网络
await route.continue()
}
})
子资源路由优先级:
Playwright 路由匹配按注册顺序,更具体的路由应先注册:
// ✅ 正确:更具体的路由先注册
await page.route('**/api/admin/clients/*/reset-secret', ...) // 先匹配
await page.route('**/api/admin/clients/*', ...) // 后匹配(兜底)
// 实际上 Playwright 的通配符匹配有隐式优先级
// 但在 handler 内主动判断更安全:
await page.route('**/api/admin/clients/*', async (route) => {
const url = route.request().url()
if (url.includes('/scopes') || url.includes('/reset-secret')) {
await route.continue() // 跳过,让更具体的 handler 处理
return
}
// ... 处理 DELETE / GET / PUT
})
3.4 辅助函数:loginAsAdmin()
export async function loginAsAdmin(page: Page) {
await page.goto('/login')
await page.fill('input[type="text"]', 'admin')
await page.fill('input[type="password"]', 'admin')
await page.click('button[type="submit"]')
await page.waitForURL('**/dashboard') // 等待登录成功跳转
}
设计要点:
- 通过 UI 操作完成登录(模拟真实用户行为)
- 依赖
setupAuthenticatedMocks()已拦截认证 API waitForURL确保登录完成,后续测试处于已认证状态