主题
E2E 测试
端到端测试(End-to-End Testing)是前端质量保障体系中置信度最高的测试手段。它站在真实用户的视角,启动完整的浏览器环境,模拟点击、输入、导航等操作,验证从前端 UI 到后端 API 再到数据库的完整业务链路是否正常工作。
本文将从核心概念 → Playwright 深度剖析 → Cypress 对比 → 实战案例 → 最佳实践 → 面试高频题六个维度全面拆解 E2E 测试。
一、E2E 测试概念
1.1 什么是 E2E 测试
E2E 测试模拟真实用户在浏览器中的操作行为,端到端验证完整功能流程。它不关心内部实现细节,只关心:用户操作后,界面是否呈现了正确的结果。
E2E 测试的"端到端"含义:
用户操作端 服务端
| |
| ┌─────────────────────────────────────────────┐ |
| │ E2E 测试覆盖范围 │ |
| │ │ |
| │ 浏览器 UI → 前端逻辑 → HTTP 请求 → API │ |
| │ ↑ | │ |
| │ └──────── 响应渲染 ←── 数据库查询 ←──┘ │ |
| │ │ |
| └─────────────────────────────────────────────┘ |
| |
与单元测试的本质区别:
- 单元测试:隔离测试一个函数/组件,Mock 掉外部依赖
- E2E 测试:不 Mock 任何东西(或只 Mock 第三方服务),真实走通全流程1.2 测试金字塔中的位置
测试金字塔(Test Pyramid)由 Mike Cohn 在《Succeeding with Agile》中提出,是测试策略的经典模型:
/\
/ \
/ E2E \ 数量:少(5~15%)
/ Tests \ 速度:慢(秒~分钟级)
/──────────\ 成本:高(环境搭建、维护)
/ \ 信心:最高 ✓
/ Integration \ 数量:适中(20~30%)
/ Tests \ 速度:中等(百毫秒~秒级)
/──────────────────\ 成本:中等
/ \
/ Unit Tests \ 数量:多(60~70%)
/ \ 速度:极快(毫秒级)
/──────────────────────────\成本:低核心原则:越往上,测试数量越少,但每个测试提供的信心越大。
不同层级测试发现的问题类型:
┌──────────────────────────────────────────────────────────────┐
│ │
│ E2E 测试能发现的问题: │
│ ✓ 页面间导航是否正确 │
│ ✓ 表单提交后数据是否真正保存 │
│ ✓ 登录后 Token 是否正确携带 │
│ ✓ 多步骤流程(购物车 → 结算 → 支付)是否走通 │
│ ✓ 前后端数据格式是否一致 │
│ ✓ 跨浏览器兼容性问题 │
│ │
│ E2E 测试不适合发现的问题: │
│ ✗ 某个工具函数的边界值处理 │
│ ✗ 组件 Props 的各种排列组合 │
│ ✗ CSS 样式的精确像素值 │
│ │
└──────────────────────────────────────────────────────────────┘1.3 何时需要 E2E 测试
并非所有功能都需要 E2E 测试。E2E 测试成本高、运行慢,应聚焦在关键用户路径(Critical User Journeys)上:
| 场景 | 是否需要 E2E | 原因 |
|---|---|---|
| 注册 / 登录流程 | ✅ 强烈推荐 | 核心入口,出错影响所有用户 |
| 支付 / 结算流程 | ✅ 强烈推荐 | 涉及金钱,不可出错 |
| 核心业务 CRUD | ✅ 推荐 | 产品核心价值,必须保证可用 |
| 权限控制流程 | ✅ 推荐 | 安全相关,出错后果严重 |
| 多步骤表单向导 | ✅ 推荐 | 步骤间状态传递容易出问题 |
| 组件库内部交互 | ❌ 不推荐 | 用组件测试(Testing Library)更合适 |
| 工具函数逻辑 | ❌ 不推荐 | 用单元测试更高效 |
| 纯样式验证 | ⚠️ 视情况 | 可用视觉回归测试替代 |
1.4 E2E 测试的挑战
E2E 测试面临的核心挑战:
┌─────────────────────────────────────────────────┐
│ │
│ 1. 速度慢 │
│ 真实浏览器启动 + 页面渲染 + 网络请求 │
│ 一个 E2E 用例可能需要 5~30 秒 │
│ │
│ 2. 不稳定(Flaky) │
│ 网络波动、动画延迟、异步渲染 │
│ 同一用例偶尔通过偶尔失败 │
│ │
│ 3. 环境依赖 │
│ 需要后端服务、数据库、第三方 API │
│ 环境不一致导致测试失败 │
│ │
│ 4. 维护成本高 │
│ UI 改版 → 定位器失效 → 大量用例需要修改 │
│ │
│ 5. 调试困难 │
│ 失败原因可能在前端、后端、网络的任何环节 │
│ │
└─────────────────────────────────────────────────┘二、Playwright 深度剖析
2.1 Playwright 是什么
Playwright 是微软开源的浏览器自动化测试框架,于 2020 年发布。它的核心团队成员大多来自 Google 的 Puppeteer 团队,因此 Playwright 可以看作是 Puppeteer 的"进化版"。
Playwright 架构:
┌─────────────────────────────────────────────────────────┐
│ Playwright Test Runner │
│ (Node.js 进程) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Test File │ │ Test File │ │ Test File │ ... │
│ │ spec.ts │ │ spec.ts │ │ spec.ts │ │
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │ │
│ └───────────────┼───────────────┘ │
│ ↓ │
│ ┌────────────────────────┐ │
│ │ Playwright Core API │ │
│ │ (自动等待、定位器...) │ │
│ └──────────┬─────────────┘ │
│ │ WebSocket (CDP / 自有协议) │
│ ┌───────────────────┼──────────────────────────────┐ │
│ │ ↓ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Chromium │ │ Firefox │ │ WebKit │ │ │
│ │ │ (Chrome) │ │ │ │ (Safari) │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ 浏览器进程池 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘2.2 核心优势
| 特性 | 说明 |
|---|---|
| 多浏览器支持 | Chromium、Firefox、WebKit 三大引擎,一套代码跑三个浏览器 |
| 自动等待 | 操作前自动等待元素可见、可点击、可编辑,无需手动 sleep |
| 网络拦截 | 内置 page.route() 拦截请求,可 Mock API 返回值 |
| 代码生成器 | npx playwright codegen 录制操作自动生成测试代码 |
| Trace Viewer | 时间旅行调试器,可逐步回放测试执行过程 |
| 并行执行 | 原生支持多 Worker 并行执行测试,大幅缩短 CI 时间 |
| 多语言支持 | TypeScript/JavaScript、Python、Java、.NET |
| 移动端模拟 | 内置设备列表,可模拟 iPhone、Pixel 等设备的视口和 UA |
2.3 安装与配置
bash
npm init playwright@latest执行后会自动创建项目结构:
project/
├── tests/
│ └── example.spec.ts
├── tests-results/
├── playwright.config.ts
└── package.jsonplaywright.config.ts 核心配置:
typescript
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,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
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 12'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})配置项详解:
playwright.config.ts 核心配置项:
┌──────────────────────────────────────────────────────────┐
│ testDir 测试文件目录 │
│ fullyParallel 是否完全并行执行(文件级 + 用例级) │
│ forbidOnly CI 环境禁止 .only(防止遗漏测试) │
│ retries 失败重试次数(CI 中推荐 1~2 次) │
│ workers 并行 Worker 数量 │
│ reporter 报告格式(html / json / list / dot) │
│ │
│ use: │
│ baseURL 所有 page.goto() 的基准 URL │
│ trace Trace 收集策略 │
│ screenshot 截图策略 │
│ video 录像策略 │
│ │
│ projects 多浏览器/多设备配置 │
│ webServer 测试前自动启动开发服务器 │
└──────────────────────────────────────────────────────────┘2.4 核心 API
2.4.1 页面导航
typescript
import { test, expect } from '@playwright/test'
test('页面导航', async ({ page }) => {
await page.goto('/')
await page.goto('/about')
await page.goBack()
await page.goForward()
await page.reload()
})2.4.2 元素交互
typescript
test('元素交互', async ({ page }) => {
await page.goto('/form')
await page.click('button#submit')
await page.dblclick('.item')
await page.fill('input[name="username"]', 'testuser')
await page.type('input[name="search"]', 'playwright', { delay: 100 })
await page.check('input[type="checkbox"]')
await page.uncheck('input[type="checkbox"]')
await page.selectOption('select#country', 'china')
await page.hover('.tooltip-trigger')
await page.focus('input[name="email"]')
})2.4.3 键盘与鼠标
typescript
test('键盘操作', async ({ page }) => {
await page.goto('/editor')
await page.keyboard.press('Enter')
await page.keyboard.press('Control+A')
await page.keyboard.press('Control+C')
await page.keyboard.press('Control+V')
await page.keyboard.type('Hello World')
await page.keyboard.down('Shift')
await page.keyboard.press('ArrowRight')
await page.keyboard.press('ArrowRight')
await page.keyboard.up('Shift')
})
test('鼠标操作', async ({ page }) => {
await page.mouse.move(100, 200)
await page.mouse.click(100, 200)
await page.mouse.dblclick(100, 200)
await page.mouse.down()
await page.mouse.move(300, 400)
await page.mouse.up()
})2.5 定位器(Locator)
定位器是 Playwright 最核心的概念之一。Locator 代表一种在页面上查找元素的方式,它是惰性的——创建 Locator 不会立即查找元素,只有在执行操作时才会查找。
Locator 的生命周期:
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│ 创建 Locator │ ──→ │ 执行操作/断言 │ ──→ │ 查找元素 │
│ (不查找元素) │ │ click/fill/.. │ │ 自动等待 │
│ │ │ │ │ 自动重试 │
└─────────────┘ └───────────────┘ └──────────────┘
│
┌──────┴──────┐
│ 找到 → 执行 │
│ 超时 → 报错 │
└─────────────┘2.5.1 推荐的定位方式(按优先级)
typescript
test('定位器优先级', async ({ page }) => {
await page.goto('/dashboard')
await page.getByRole('button', { name: '提交' }).click()
await page.getByLabel('用户名').fill('testuser')
await page.getByPlaceholder('请输入搜索关键词').fill('playwright')
await page.getByText('欢迎回来').isVisible()
await page.getByAltText('用户头像').click()
await page.getByTitle('设置').click()
await page.getByTestId('submit-btn').click()
})定位器选择优先级:
| 优先级 | 定位方式 | 说明 | 稳定性 |
|---|---|---|---|
| 1 | getByRole | 基于 ARIA 角色,最接近用户感知 | ⭐⭐⭐⭐⭐ |
| 2 | getByLabel | 基于 label 关联,适合表单元素 | ⭐⭐⭐⭐⭐ |
| 3 | getByPlaceholder | 基于 placeholder 文本 | ⭐⭐⭐⭐ |
| 4 | getByText | 基于可见文本 | ⭐⭐⭐⭐ |
| 5 | getByTestId | 基于 data-testid 属性 | ⭐⭐⭐⭐ |
| 6 | locator('css') | CSS 选择器 | ⭐⭐⭐ |
| 7 | locator('xpath') | XPath 表达式 | ⭐⭐ |
为什么 getByRole 是首选?
用户视角: "我要点击「提交」按钮"
getByRole: page.getByRole('button', { name: '提交' }) ← 最接近用户意图
getByTestId:page.getByTestId('submit-btn') ← 需要额外属性
CSS 选择器: page.locator('.form > .actions > button.primary') ← 依赖 DOM 结构
当 UI 重构(改 class、调整 DOM 层级)时:
- getByRole → 不受影响(只要还是按钮且文字不变)✓
- getByTestId → 不受影响(只要 data-testid 不变)✓
- CSS 选择器 → 很可能失效 ✗2.5.2 Locator 链式过滤
typescript
test('Locator 链式过滤', async ({ page }) => {
await page.goto('/products')
const productCard = page.locator('.product-card').filter({
hasText: 'MacBook Pro'
})
await productCard.getByRole('button', { name: '加入购物车' }).click()
const row = page.getByRole('row').filter({
has: page.getByText('Alice')
})
await row.getByRole('button', { name: '编辑' }).click()
const items = page.locator('.list-item')
const thirdItem = items.nth(2)
const firstItem = items.first()
const lastItem = items.last()
})2.6 断言系统
Playwright 的断言基于 expect 并内置自动重试机制——断言会持续重试直到条件满足或超时。
普通断言 vs Playwright 自动重试断言:
普通断言(立即判断):
expect(value).toBe(5) ← 失败就立即报错
Playwright Web 断言(自动重试):
await expect(locator).toBeVisible()
│ │
│ ┌────────────────────┐ │
│ │ 检查元素是否可见 │ │
│ │ ↓ │ │
│ │ 不可见?等 100ms │ │
│ │ ↓ │ │
│ │ 再检查... │ │
│ │ ↓ │ │
│ │ 循环直到可见或超时 │ │
│ └────────────────────┘ │
│ │
默认超时:5 秒2.6.1 常用断言
typescript
test('断言示例', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByText('欢迎')).toBeVisible()
await expect(page.getByText('已删除的内容')).toBeHidden()
await expect(page.getByRole('button', { name: '提交' })).toBeEnabled()
await expect(page.getByRole('button', { name: '请稍候' })).toBeDisabled()
await expect(page.getByLabel('记住我')).toBeChecked()
await expect(page.getByRole('heading')).toHaveText('仪表盘')
await expect(page.getByRole('heading')).toContainText('仪表')
await expect(page.locator('.item')).toHaveCount(5)
await expect(page.locator('.active')).toHaveClass(/selected/)
await expect(page.locator('.box')).toHaveCSS('color', 'rgb(255, 0, 0)')
await expect(page.locator('input')).toHaveValue('test@example.com')
await expect(page.locator('.list > li')).toHaveText([
'Apple',
'Banana',
'Cherry',
])
await expect(page).toHaveURL(/\/dashboard/)
await expect(page).toHaveTitle('Dashboard - MyApp')
})2.6.2 取反断言与软断言
typescript
test('取反断言', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByText('错误')).not.toBeVisible()
await expect(page.locator('.item')).not.toHaveCount(0)
})
test('软断言(不中断测试)', async ({ page }) => {
await page.goto('/dashboard')
await expect.soft(page.getByText('标题1')).toBeVisible()
await expect.soft(page.getByText('标题2')).toBeVisible()
await expect.soft(page.getByText('标题3')).toBeVisible()
})2.7 等待机制
Playwright 的自动等待是其最强大的特性之一。在执行任何操作前,Playwright 会自动等待元素满足可操作性检查(Actionability Checks)。
page.click(selector) 的自动等待流程:
┌─────────────────────────────────────────────────┐
│ click() 自动等待检查链 │
│ │
│ 1. 元素是否已附着到 DOM? │
│ └── 否 → 等待元素出现 │
│ └── 是 ↓ │
│ │
│ 2. 元素是否可见? │
│ └── 否 → 等待元素变为可见 │
│ └── 是 ↓ │
│ │
│ 3. 元素是否稳定(无动画进行中)? │
│ └── 否 → 等待动画结束 │
│ └── 是 ↓ │
│ │
│ 4. 元素是否可接收事件(未被遮挡)? │
│ └── 否 → 等待遮挡移除 │
│ └── 是 ↓ │
│ │
│ 5. 元素是否已启用(非 disabled)? │
│ └── 否 → 等待元素启用 │
│ └── 是 ↓ │
│ │
│ ✓ 所有检查通过 → 执行 click │
│ │
│ ✗ 超时(默认 30s)→ 抛出 TimeoutError │
└─────────────────────────────────────────────────┘不同操作的等待检查项:
| 操作 | Attached | Visible | Stable | Enabled | Receives Events |
|---|---|---|---|---|---|
| click | ✓ | ✓ | ✓ | ✓ | ✓ |
| fill | ✓ | ✓ | - | ✓ | - |
| check | ✓ | ✓ | ✓ | ✓ | ✓ |
| selectOption | ✓ | ✓ | - | ✓ | - |
| hover | ✓ | ✓ | ✓ | - | ✓ |
| screenshot | ✓ | ✓ | ✓ | - | - |
2.7.1 显式等待
虽然大多数场景自动等待就够了,但有时仍需要显式等待:
typescript
test('显式等待', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForSelector('.data-table', { state: 'visible' })
await page.waitForURL('**/dashboard')
const response = await page.waitForResponse(
resp => resp.url().includes('/api/data') && resp.status() === 200
)
await page.waitForLoadState('networkidle')
await page.waitForFunction(() => {
return document.querySelectorAll('.item').length >= 10
})
await page.waitForTimeout(1000)
})2.8 网络拦截
page.route() 允许拦截和修改网络请求,这对于 API Mock 和测试边界条件非常有用。
page.route() 工作原理:
┌──────────────────────────────────────────────────────────┐
│ 浏览器发起请求 │
│ │ │
│ ┌─────┴─────┐ │
│ │ 匹配路由? │ │
│ └─────┬─────┘ │
│ / \ │
│ 是 / \ 否 │
│ / \ │
│ ┌────────┴────────┐ ┌───┴───────────┐ │
│ │ 执行路由处理函数 │ │ 正常发送到服务器 │ │
│ │ │ │ │ │
│ │ 可选操作: │ └───────────────┘ │
│ │ • fulfill 直接返回│ │
│ │ • abort 中止请求 │ │
│ │ • continue 修改后│ │
│ │ 继续发送 │ │
│ └─────────────────┘ │
└──────────────────────────────────────────────────────────┘typescript
test('API Mock', async ({ page }) => {
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Alice', email: 'alice@test.com' },
{ id: 2, name: 'Bob', email: 'bob@test.com' },
]),
})
})
await page.goto('/users')
await expect(page.getByText('Alice')).toBeVisible()
await expect(page.getByText('Bob')).toBeVisible()
})
test('模拟网络错误', async ({ page }) => {
await page.route('**/api/data', async route => {
await route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal Server Error' }),
})
})
await page.goto('/dashboard')
await expect(page.getByText('加载失败')).toBeVisible()
})
test('模拟网络离线', async ({ page }) => {
await page.route('**/api/**', route => route.abort())
await page.goto('/dashboard')
await expect(page.getByText('网络连接失败')).toBeVisible()
})
test('修改请求并继续', async ({ page }) => {
await page.route('**/api/users', async route => {
const headers = {
...route.request().headers(),
'X-Custom-Header': 'test-value',
}
await route.continue({ headers })
})
await page.goto('/users')
})
test('拦截响应并修改', async ({ page }) => {
await page.route('**/api/config', async route => {
const response = await route.fetch()
const json = await response.json()
json.featureFlag = true
await route.fulfill({
response,
body: JSON.stringify(json),
})
})
await page.goto('/settings')
})2.9 截图与录像
typescript
test('截图', async ({ page }) => {
await page.goto('/dashboard')
await page.screenshot({ path: 'screenshots/full-page.png', fullPage: true })
await page.locator('.chart-container').screenshot({
path: 'screenshots/chart.png',
})
})
test('视觉回归测试', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixelRatio: 0.01,
})
await expect(page.locator('.sidebar')).toHaveScreenshot('sidebar.png')
})录像配置(在 playwright.config.ts 中):
typescript
export default defineConfig({
use: {
video: 'on-first-retry',
},
})video 选项:
| 选项 | 说明 |
|---|---|
'off' | 不录像 |
'on' | 每个测试都录像 |
'retain-on-failure' | 全部录像,但只保留失败的 |
'on-first-retry' | 仅第一次重试时录像 |
2.10 并行执行与分片
Playwright 并行执行模型:
┌───────────────────────────────────────────────────────┐
│ Playwright Test Runner │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 文件级并行分配 │ │
│ │ │ │
│ │ spec-1.ts spec-2.ts spec-3.ts spec-4.ts │ │
│ │ │ │ │ │ │ │
│ │ ↓ ↓ ↓ ↓ │ │
│ │ Worker 1 Worker 2 Worker 3 Worker 4 │ │
│ │ (进程 1) (进程 2) (进程 3) (进程 4) │ │
│ │ │ │ │ │ │ │
│ │ ↓ ↓ ↓ ↓ │ │
│ │ Browser Browser Browser Browser │ │
│ │ Context Context Context Context │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 每个 Worker 拥有独立的浏览器上下文,彼此完全隔离 │
└───────────────────────────────────────────────────────┘typescript
export default defineConfig({
workers: process.env.CI ? 4 : undefined,
fullyParallel: true,
})CI 中的分片(Sharding)——将测试分散到多台机器执行:
bash
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4分片执行示意图(4 台 CI 机器):
Machine 1 (shard 1/4): [spec-01, spec-02, spec-03] ──→ 结果
Machine 2 (shard 2/4): [spec-04, spec-05, spec-06] ──→ 结果
Machine 3 (shard 3/4): [spec-07, spec-08, spec-09] ──→ 结果
Machine 4 (shard 4/4): [spec-10, spec-11, spec-12] ──→ 结果
│
← ── ── 合并报告 ── ── ── ── ┘2.11 浏览器上下文与多标签页
typescript
test('多标签页操作', async ({ context, page }) => {
await page.goto('/dashboard')
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.click('a[target="_blank"]'),
])
await newPage.waitForLoadState()
await expect(newPage).toHaveTitle(/详情/)
await newPage.close()
})
test('隔离的浏览器上下文', async ({ browser }) => {
const userContext = await browser.newContext({
storageState: 'auth/user.json',
})
const adminContext = await browser.newContext({
storageState: 'auth/admin.json',
})
const userPage = await userContext.newPage()
const adminPage = await adminContext.newPage()
await userPage.goto('/dashboard')
await adminPage.goto('/admin')
await expect(userPage.getByText('管理后台')).not.toBeVisible()
await expect(adminPage.getByText('管理后台')).toBeVisible()
await userContext.close()
await adminContext.close()
})2.12 文件上传与下载
typescript
test('文件上传', async ({ page }) => {
await page.goto('/upload')
await page.setInputFiles('input[type="file"]', 'test-data/photo.png')
await page.setInputFiles('input[type="file"]', [
'test-data/photo1.png',
'test-data/photo2.png',
])
await page.setInputFiles('input[type="file"]', [])
})
test('文件下载', async ({ page }) => {
await page.goto('/reports')
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('button#export-csv'),
])
const suggestedFilename = download.suggestedFilename()
await download.saveAs(`downloads/${suggestedFilename}`)
})2.13 拖放操作
typescript
test('拖放操作', async ({ page }) => {
await page.goto('/kanban')
await page.locator('.task-card').first().dragTo(
page.locator('.column-done')
)
await page.locator('.task-card').first().hover()
await page.mouse.down()
await page.locator('.column-done').hover()
await page.mouse.up()
})三、Cypress 对比了解
3.1 Cypress 核心特性
Cypress 是另一个流行的 E2E 测试框架,诞生于 2014 年,比 Playwright 更早。它的核心理念是让测试编写变得简单、调试变得直观。
Cypress 独特的运行架构:
┌─────────────────────────────────────────────────────┐
│ 浏览器进程 │
│ │
│ ┌───────────────────┐ ┌───────────────────────┐ │
│ │ 应用 iframe │ │ Cypress 命令 iframe │ │
│ │ │ │ │ │
│ │ http://localhost │ │ cy.get('.btn') │ │
│ │ :3000 │ │ cy.click() │ │
│ │ │ │ cy.type('hello') │ │
│ │ ┌──────────────┐ │ │ │ │
│ │ │ Your App │ │ │ Cypress 与你的应用 │ │
│ │ │ │ │ │ 运行在同一个浏览器中 │ │
│ │ └──────────────┘ │ │ 可以直接访问 DOM │ │
│ └───────────────────┘ └───────────────────────┘ │
│ │
│ ← 同源,直接 DOM 操作 → │
└─────────────────────────────────────────────────────┘
Playwright 架构对比:
┌──────────────────┐ ┌──────────────────┐
│ Node.js 进程 │ ──CDP──→ │ 浏览器进程 │
│ │ WebSocket│ │
│ 测试代码运行在 │ ←────── │ 应用运行在 │
│ Node.js 中 │ │ 浏览器中 │
└──────────────────┘ └──────────────────┘
进程外控制 被控制端Cypress 的这种架构带来了独特优势,但也有明显局限。
3.2 Cypress 基本用法
typescript
describe('登录流程', () => {
beforeEach(() => {
cy.visit('/login')
})
it('成功登录', () => {
cy.get('[name="email"]').type('alice@test.com')
cy.get('[name="password"]').type('password123')
cy.get('button[type="submit"]').click()
cy.url().should('include', '/dashboard')
cy.contains('Welcome, Alice').should('be.visible')
})
it('登录失败显示错误', () => {
cy.get('[name="email"]').type('wrong@test.com')
cy.get('[name="password"]').type('wrong')
cy.get('button[type="submit"]').click()
cy.contains('邮箱或密码错误').should('be.visible')
})
})3.3 Playwright vs Cypress 全面对比
| 维度 | Playwright | Cypress |
|---|---|---|
| 浏览器支持 | Chromium + Firefox + WebKit | Chromium + Firefox + WebKit(v13+) |
| 运行架构 | Node.js 进程外控制浏览器 | 运行在浏览器内部 |
| 多标签页 | ✅ 原生支持 | ❌ 不支持 |
| 多域名 | ✅ 原生支持 | ⚠️ cy.origin() 部分支持 |
| iframe | ✅ 原生支持 | ⚠️ 需要插件 |
| 语言支持 | JS/TS、Python、Java、.NET | 仅 JS/TS |
| 并行执行 | ✅ 内置 Workers | ⚠️ 需要 Cypress Cloud(付费) |
| 自动等待 | ✅ 操作级自动等待 | ✅ 命令级自动重试 |
| 网络拦截 | ✅ page.route() | ✅ cy.intercept() |
| API 风格 | async/await | 链式调用(类 jQuery) |
| 调试体验 | Trace Viewer(时间旅行) | 时间旅行 + 实时 DOM 快照 |
| 代码生成 | ✅ codegen | ⚠️ Cypress Studio(实验性) |
| 组件测试 | ⚠️ 实验性 | ✅ 原生支持 |
| CI 集成 | 免费,内置报告 | 基础免费,高级功能需付费 |
| 执行速度 | ⚡ 快(进程外+并行) | 🔶 中等 |
| 社区生态 | 快速增长 | 成熟,插件丰富 |
| 学习曲线 | 中等(需理解 async/await) | 低(链式 API 直觉化) |
3.4 如何选择
选择 Playwright 的场景:
✓ 需要跨浏览器测试(尤其是 Safari/WebKit)
✓ 涉及多标签页、多域名、iframe 场景
✓ 团队使用 Python/Java/.NET 等非 JS 语言
✓ 需要强大的并行能力降低 CI 时间
✓ 项目处于起步阶段,追求长期技术投资
选择 Cypress 的场景:
✓ 团队前端经验为主,偏好直觉化 API
✓ 需要强大的组件测试能力
✓ 重视实时开发调试体验
✓ 项目已有 Cypress 基础设施
✓ 不涉及多标签页/多域名等复杂场景四、E2E 测试实战
4.1 登录流程测试
typescript
import { test, expect } from '@playwright/test'
test.describe('登录流程', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login')
})
test('使用有效凭据登录成功', async ({ page }) => {
await page.getByLabel('邮箱').fill('alice@example.com')
await page.getByLabel('密码').fill('password123')
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL('/dashboard')
await expect(page.getByText('欢迎回来, Alice')).toBeVisible()
})
test('使用无效凭据显示错误信息', async ({ page }) => {
await page.getByLabel('邮箱').fill('wrong@example.com')
await page.getByLabel('密码').fill('wrongpassword')
await page.getByRole('button', { name: '登录' }).click()
await expect(page.getByText('邮箱或密码错误')).toBeVisible()
await expect(page).toHaveURL('/login')
})
test('空表单提交显示验证错误', async ({ page }) => {
await page.getByRole('button', { name: '登录' }).click()
await expect(page.getByText('请输入邮箱')).toBeVisible()
await expect(page.getByText('请输入密码')).toBeVisible()
})
test('记住我功能', async ({ page, context }) => {
await page.getByLabel('邮箱').fill('alice@example.com')
await page.getByLabel('密码').fill('password123')
await page.getByLabel('记住我').check()
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL('/dashboard')
const cookies = await context.cookies()
const rememberCookie = cookies.find(c => c.name === 'remember_token')
expect(rememberCookie).toBeTruthy()
expect(rememberCookie!.expires).toBeGreaterThan(Date.now() / 1000)
})
test('登录后重定向到原始页面', async ({ page }) => {
await page.goto('/profile')
await expect(page).toHaveURL(/\/login\?redirect=/)
await page.getByLabel('邮箱').fill('alice@example.com')
await page.getByLabel('密码').fill('password123')
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL('/profile')
})
})4.2 表单提交测试
typescript
test.describe('用户注册表单', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/register')
})
test('成功注册新用户', async ({ page }) => {
await page.getByLabel('用户名').fill('newuser')
await page.getByLabel('邮箱').fill('newuser@example.com')
await page.getByLabel('密码').fill('StrongPass123!')
await page.getByLabel('确认密码').fill('StrongPass123!')
await page.getByLabel('我已阅读并同意服务条款').check()
await page.getByRole('button', { name: '注册' }).click()
await expect(page).toHaveURL('/welcome')
await expect(page.getByText('注册成功')).toBeVisible()
})
test('密码强度验证', async ({ page }) => {
const passwordInput = page.getByLabel('密码')
const strengthIndicator = page.locator('.password-strength')
await passwordInput.fill('123')
await expect(strengthIndicator).toHaveText('弱')
await passwordInput.fill('abc12345')
await expect(strengthIndicator).toHaveText('中')
await passwordInput.fill('Str0ng!Pass#2024')
await expect(strengthIndicator).toHaveText('强')
})
test('邮箱唯一性检查', async ({ page }) => {
await page.route('**/api/check-email', async route => {
const body = route.request().postDataJSON()
await route.fulfill({
status: 200,
body: JSON.stringify({
available: body.email !== 'taken@example.com',
}),
})
})
await page.getByLabel('邮箱').fill('taken@example.com')
await page.getByLabel('邮箱').blur()
await expect(page.getByText('该邮箱已被注册')).toBeVisible()
await page.getByLabel('邮箱').fill('fresh@example.com')
await page.getByLabel('邮箱').blur()
await expect(page.getByText('该邮箱已被注册')).not.toBeVisible()
})
})4.3 列表操作测试(CRUD)
typescript
test.describe('任务管理 CRUD', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/tasks', async route => {
if (route.request().method() === 'GET') {
await route.fulfill({
body: JSON.stringify([
{ id: 1, title: '完成设计稿', done: false },
{ id: 2, title: '编写测试', done: true },
{ id: 3, title: '代码评审', done: false },
]),
})
}
})
await page.goto('/tasks')
})
test('展示任务列表', async ({ page }) => {
await expect(page.getByRole('listitem')).toHaveCount(3)
await expect(page.getByText('完成设计稿')).toBeVisible()
await expect(page.getByText('编写测试')).toBeVisible()
await expect(page.getByText('代码评审')).toBeVisible()
})
test('创建新任务', async ({ page }) => {
await page.route('**/api/tasks', async route => {
if (route.request().method() === 'POST') {
const body = route.request().postDataJSON()
await route.fulfill({
status: 201,
body: JSON.stringify({ id: 4, title: body.title, done: false }),
})
} else {
await route.continue()
}
})
await page.getByPlaceholder('添加新任务').fill('部署上线')
await page.getByRole('button', { name: '添加' }).click()
await expect(page.getByText('部署上线')).toBeVisible()
})
test('编辑任务', async ({ page }) => {
await page.route('**/api/tasks/1', async route => {
if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON()
await route.fulfill({
body: JSON.stringify({ ...body, id: 1 }),
})
}
})
const firstTask = page.getByRole('listitem').filter({ hasText: '完成设计稿' })
await firstTask.getByRole('button', { name: '编辑' }).click()
const editInput = firstTask.getByRole('textbox')
await editInput.clear()
await editInput.fill('完成设计稿 v2')
await firstTask.getByRole('button', { name: '保存' }).click()
await expect(page.getByText('完成设计稿 v2')).toBeVisible()
})
test('删除任务', async ({ page }) => {
await page.route('**/api/tasks/1', async route => {
if (route.request().method() === 'DELETE') {
await route.fulfill({ status: 204 })
}
})
const firstTask = page.getByRole('listitem').filter({ hasText: '完成设计稿' })
await firstTask.getByRole('button', { name: '删除' }).click()
await page.getByRole('button', { name: '确认删除' }).click()
await expect(page.getByText('完成设计稿')).not.toBeVisible()
await expect(page.getByRole('listitem')).toHaveCount(2)
})
test('切换任务完成状态', async ({ page }) => {
await page.route('**/api/tasks/1', async route => {
if (route.request().method() === 'PATCH') {
await route.fulfill({
body: JSON.stringify({ id: 1, title: '完成设计稿', done: true }),
})
}
})
const firstTask = page.getByRole('listitem').filter({ hasText: '完成设计稿' })
await firstTask.getByRole('checkbox').check()
await expect(firstTask).toHaveClass(/completed/)
})
})4.4 API Mock 进阶
typescript
test.describe('API Mock 进阶技巧', () => {
test('监听请求并验证请求体', async ({ page }) => {
let capturedRequest: any = null
await page.route('**/api/orders', async route => {
capturedRequest = route.request().postDataJSON()
await route.fulfill({
status: 201,
body: JSON.stringify({ id: 'order-001', status: 'created' }),
})
})
await page.goto('/checkout')
await page.getByRole('button', { name: '提交订单' }).click()
expect(capturedRequest).toEqual(
expect.objectContaining({
items: expect.any(Array),
totalAmount: expect.any(Number),
})
)
})
test('模拟加载状态', async ({ page }) => {
await page.route('**/api/data', async route => {
await new Promise(resolve => setTimeout(resolve, 3000))
await route.fulfill({
body: JSON.stringify({ items: [] }),
})
})
await page.goto('/dashboard')
await expect(page.getByText('加载中...')).toBeVisible()
await expect(page.getByText('加载中...')).not.toBeVisible({ timeout: 5000 })
})
test('模拟分页接口', async ({ page }) => {
await page.route('**/api/products*', async route => {
const url = new URL(route.request().url())
const pageNum = parseInt(url.searchParams.get('page') || '1')
const pageSize = 10
const items = Array.from({ length: pageSize }, (_, i) => ({
id: (pageNum - 1) * pageSize + i + 1,
name: `商品 ${(pageNum - 1) * pageSize + i + 1}`,
}))
await route.fulfill({
body: JSON.stringify({
items,
total: 50,
page: pageNum,
pageSize,
}),
})
})
await page.goto('/products')
await expect(page.getByText('商品 1')).toBeVisible()
await page.getByRole('button', { name: '下一页' }).click()
await expect(page.getByText('商品 11')).toBeVisible()
})
})4.5 视觉回归测试
视觉回归测试工作原理:
第一次运行(生成基准截图):
页面渲染 ──→ 截图 ──→ 保存为基准图(Golden Image)
screenshots/dashboard.png
后续运行(对比截图):
页面渲染 ──→ 截图 ──→ 与基准图对比
│
┌─────────┴─────────┐
│ │
差异 < 阈值 差异 > 阈值
│ │
✓ 通过 ✗ 失败
│
生成差异图(diff image)
标记出像素差异区域typescript
test.describe('视觉回归测试', () => {
test('首页整体截图', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixelRatio: 0.01,
})
})
test('深色模式截图对比', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveScreenshot('homepage-light.png')
await page.getByRole('button', { name: '切换主题' }).click()
await expect(page).toHaveScreenshot('homepage-dark.png')
})
test('响应式布局截图', async ({ page }) => {
await page.goto('/dashboard')
await page.setViewportSize({ width: 1920, height: 1080 })
await expect(page).toHaveScreenshot('dashboard-desktop.png')
await page.setViewportSize({ width: 768, height: 1024 })
await expect(page).toHaveScreenshot('dashboard-tablet.png')
await page.setViewportSize({ width: 375, height: 667 })
await expect(page).toHaveScreenshot('dashboard-mobile.png')
})
test('忽略动态区域', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveScreenshot('dashboard-stable.png', {
mask: [
page.locator('.timestamp'),
page.locator('.random-avatar'),
],
})
})
})五、E2E 测试最佳实践
5.1 Page Object Model(POM)模式
POM 是 E2E 测试中最经典的设计模式,它将页面的定位逻辑和操作逻辑封装到独立的类中,测试用例只调用高层语义方法。
不使用 POM vs 使用 POM:
不使用 POM(定位逻辑散落在测试中):
test('登录', async ({ page }) => {
await page.goto('/login')
await page.fill('#email', 'alice@test.com') ← 定位逻辑
await page.fill('#password', 'pass123') ← 定位逻辑
await page.click('button.submit-btn') ← 定位逻辑
await expect(page).toHaveURL('/dashboard')
})
test('修改密码', async ({ page }) => {
await page.goto('/login')
await page.fill('#email', 'alice@test.com') ← 重复!
await page.fill('#password', 'pass123') ← 重复!
await page.click('button.submit-btn') ← 重复!
...
})
使用 POM(封装页面操作):
test('登录', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('alice@test.com', 'pass123') ← 语义化
await loginPage.expectSuccess()
})
UI 改版时:只需修改 LoginPage 类,所有用例自动生效完整的 POM 实现:
typescript
import { type Page, type Locator, expect } from '@playwright/test'
class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly rememberCheckbox: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.getByLabel('邮箱')
this.passwordInput = page.getByLabel('密码')
this.submitButton = page.getByRole('button', { name: '登录' })
this.rememberCheckbox = page.getByLabel('记住我')
this.errorMessage = page.locator('.error-message')
}
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 loginWithRemember(email: string, password: string) {
await this.login(email, password)
await this.rememberCheckbox.check()
await this.submitButton.click()
}
async expectSuccess() {
await expect(this.page).toHaveURL('/dashboard')
}
async expectError(message: string) {
await expect(this.errorMessage).toHaveText(message)
}
}
class DashboardPage {
readonly page: Page
readonly welcomeText: Locator
readonly logoutButton: Locator
readonly sidebar: Locator
constructor(page: Page) {
this.page = page
this.welcomeText = page.locator('.welcome-text')
this.logoutButton = page.getByRole('button', { name: '退出登录' })
this.sidebar = page.locator('.sidebar')
}
async goto() {
await this.page.goto('/dashboard')
}
async logout() {
await this.logoutButton.click()
}
async expectWelcome(name: string) {
await expect(this.welcomeText).toContainText(name)
}
}在测试中使用 POM:
typescript
import { test, expect } from '@playwright/test'
test.describe('用户认证流程', () => {
test('登录 → 查看仪表盘 → 退出', async ({ page }) => {
const loginPage = new LoginPage(page)
const dashboard = new DashboardPage(page)
await loginPage.goto()
await loginPage.login('alice@example.com', 'password123')
await loginPage.expectSuccess()
await dashboard.expectWelcome('Alice')
await dashboard.logout()
await expect(page).toHaveURL('/login')
})
})使用 Playwright Fixture 注入 POM:
typescript
import { test as base } from '@playwright/test'
type Pages = {
loginPage: LoginPage
dashboardPage: DashboardPage
}
const test = base.extend<Pages>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page))
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page))
},
})
test('登录流程', async ({ loginPage, dashboardPage }) => {
await loginPage.goto()
await loginPage.login('alice@example.com', 'password123')
await loginPage.expectSuccess()
await dashboardPage.expectWelcome('Alice')
})5.2 测试数据管理
测试数据策略:
┌──────────────────────────────────────────────────────────────┐
│ │
│ 策略 1:API Mock(推荐用于大部分场景) │
│ ┌──────────────────────────────────────────┐ │
│ │ page.route('**/api/users', ...) │ │
│ │ • 最稳定:不依赖后端 │ │
│ │ • 最快:无真实网络请求 │ │
│ │ • 缺点:无法验证真实 API 行为 │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 策略 2:Seed Data(测试前通过 API 创建数据) │
│ ┌──────────────────────────────────────────┐ │
│ │ beforeAll: │ │
│ │ POST /api/test/seed │ │
│ │ afterAll: │ │
│ │ POST /api/test/cleanup │ │
│ │ • 真实数据流 │ │
│ │ • 需要后端提供测试 API │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 策略 3:数据库快照(CI 环境推荐) │
│ ┌──────────────────────────────────────────┐ │
│ │ 每次测试前恢复数据库到已知状态 │ │
│ │ • 完全可控 │ │
│ │ • 需要 Docker 或类似工具支持 │ │
│ └──────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘typescript
import { test as setup } from '@playwright/test'
setup('创建测试数据', async ({ request }) => {
await request.post('/api/test/seed', {
data: {
users: [
{ email: 'alice@test.com', password: 'pass123', role: 'admin' },
{ email: 'bob@test.com', password: 'pass123', role: 'user' },
],
tasks: [
{ title: 'Task 1', assignee: 'alice@test.com' },
{ title: 'Task 2', assignee: 'bob@test.com' },
],
},
})
})
setup('清理测试数据', async ({ request }) => {
await request.post('/api/test/cleanup')
})5.3 认证状态复用
每个测试都重新走一遍登录流程太慢了。Playwright 支持保存和复用认证状态:
typescript
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('邮箱').fill('alice@example.com')
await page.getByLabel('密码').fill('password123')
await page.getByRole('button', { name: '登录' }).click()
await page.waitForURL('/dashboard')
await page.context().storageState({ path: authFile })
})在配置文件中使用:
typescript
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
},
],
})认证状态复用流程:
┌──────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Setup 项目 │ │ 保存 storageState│ │ 测试项目使用 │
│ │ │ │ │ │
│ 执行登录流程 │ ──→ │ cookies + storage│ ──→ │ 跳过登录,直接 │
│ (仅一次) │ │ → auth.json │ │ 进入已登录状态 │
└──────────────┘ └─────────────────┘ └──────────────────┘
时间节省:
20 个测试 × 3 秒登录 = 60 秒 → 只需 3 秒(一次登录)5.4 测试环境隔离
typescript
test.describe.configure({ mode: 'serial' })
test.describe('订单流程(串行执行)', () => {
test('步骤1:添加商品到购物车', async ({ page }) => {
await page.goto('/products')
await page.getByText('MacBook Pro').click()
await page.getByRole('button', { name: '加入购物车' }).click()
await expect(page.locator('.cart-count')).toHaveText('1')
})
test('步骤2:进入结算页', async ({ page }) => {
await page.goto('/cart')
await page.getByRole('button', { name: '去结算' }).click()
await expect(page).toHaveURL('/checkout')
})
})5.5 CI 集成(GitHub Actions)
yaml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e-tests:
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
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: Run E2E tests
run: npx playwright test --shard=${{ matrix.shard }}
- name: Upload test results
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ strategy.job-index }}
path: playwright-report/
retention-days: 30
- name: Upload blob report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: blob-report-${{ strategy.job-index }}
path: blob-report/
retention-days: 1
merge-reports:
if: ${{ !cancelled() }}
needs: e2e-tests
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: Download blob reports
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge reports
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload merged report
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30CI 分片执行流程:
┌─────────────────────────────────────────────────────────┐
│ GitHub Actions │
│ │
│ Push / PR 触发 │
│ │ │
│ ↓ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Shard 1/4│ │ Shard 2/4│ │ Shard 3/4│ │ Shard 4/4│ │
│ │ │ │ │ │ │ │ │ │
│ │ 25% 用例 │ │ 25% 用例 │ │ 25% 用例 │ │ 25% 用例 │ │
│ │ │ │ │ │ │ │ │ │
│ │ blob │ │ blob │ │ blob │ │ blob │ │
│ │ report │ │ report │ │ report │ │ report │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └────────────┴──────┬─────┴────────────┘ │
│ ↓ │
│ ┌────────────────┐ │
│ │ 合并测试报告 │ │
│ │ HTML Report │ │
│ └────────────────┘ │
└─────────────────────────────────────────────────────────┘5.6 避免 Flaky Tests
Flaky Test 是 E2E 测试最大的敌人——同一个测试有时通过有时失败,严重侵蚀团队对测试的信任。
Flaky Test 的常见原因与解决方案:
原因 解决方案
─────────────────────────────────────────────────────────────
硬编码 sleep/timeout 使用 Playwright 自动等待
↓ ↓
await page.waitForTimeout(3000) await expect(loc).toBeVisible()
依赖动画完成 禁用动画
↓ ↓
动画中点击位置偏移 page.addStyleTag({
content: '*, *::before, *::after {
animation: none !important;
transition: none !important;
}'
})
依赖外部 API Mock 外部依赖
↓ ↓
第三方 API 不稳定 page.route('**/external-api/**',
route => route.fulfill({...}))
依赖执行顺序 测试间完全隔离
↓ ↓
测试 A 创建的数据被测试 B 使用 每个测试独立准备数据
元素定位不稳定 使用稳定定位器
↓ ↓
page.locator('.btn:nth-child(3)') page.getByRole('button',
{ name: '提交' })防抖实践示例:
typescript
test.describe('稳定的测试写法', () => {
test('等待数据加载完成后再断言', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.locator('.skeleton')).toHaveCount(0, { timeout: 10000 })
await expect(page.getByText('数据已加载')).toBeVisible()
})
test('等待网络请求完成', async ({ page }) => {
await page.goto('/dashboard')
const responsePromise = page.waitForResponse('**/api/data')
await page.getByRole('button', { name: '刷新' }).click()
await responsePromise
await expect(page.locator('.data-table')).toBeVisible()
})
test('使用 retry 处理偶发不稳定', async ({ page }) => {
await page.goto('/notifications')
await expect(async () => {
await page.getByRole('button', { name: '刷新' }).click()
await expect(page.locator('.notification')).toHaveCount(3)
}).toPass({
timeout: 10000,
intervals: [1000, 2000, 3000],
})
})
})Playwright 配置级别的防抖策略:
typescript
export default defineConfig({
retries: process.env.CI ? 2 : 0,
expect: {
timeout: 10000,
},
use: {
actionTimeout: 15000,
navigationTimeout: 30000,
},
})六、面试高频问题
Q1:E2E 测试与单元测试、集成测试的区别?如何确定测试比例?
回答思路:
从测试金字塔出发,三者在测试对象、隔离程度、运行速度、维护成本、置信度五个维度上各有侧重。单元测试覆盖最广、运行最快但置信度最低;E2E 测试覆盖关键路径、运行最慢但置信度最高。推荐比例遵循 70/20/10 原则(单元/集成/E2E),但需根据项目类型调整——ToC 产品的核心流程应加大 E2E 比例,而工具库/SDK 则更侧重单元测试。关键是 E2E 只覆盖关键用户路径(Critical User Journeys),不要试图用 E2E 覆盖所有边界情况。
Q2:Playwright 的自动等待机制是如何实现的?为什么说它比手动 sleep 更好?
回答思路:
Playwright 在每个操作(click、fill 等)执行前会自动执行一系列可操作性检查(Actionability Checks):元素是否 attached 到 DOM、是否 visible、是否 stable(无进行中的动画)、是否 enabled、是否可接收事件(不被遮挡)。这些检查以轮询方式进行,直到全部通过或超时。
与 sleep(3000) 相比:sleep 是固定等待——如果元素 1 秒就出现了还要白等 2 秒,如果 4 秒才出现就会测试失败。自动等待是精确等待——元素一就绪立刻操作,既快又稳。这也是 Playwright 测试比传统 Selenium 测试更稳定的核心原因之一。
Q3:Playwright 的 Locator 和直接用 CSS 选择器有什么区别?为什么推荐 getByRole?
回答思路:
Locator 是惰性的(lazy)——创建时不查找元素,操作时才查找,并且每次操作都会重新查找。这意味着即使 DOM 动态变化,Locator 也能正确定位最新元素。
推荐 getByRole 有三个原因:(1)最接近用户感知——用户看到的是"一个叫提交的按钮"而非".btn-primary";(2)天然具备可访问性验证——如果 getByRole('button') 找不到,说明你的 HTML 可能缺少正确的 ARIA 语义;(3)对 UI 重构最稳定——改 class 名、调 DOM 结构都不影响 Role 定位。
Q4:如何解决 E2E 测试中的 Flaky Test 问题?
回答思路:
Flaky Test 根因通常有五类:(1)竞态条件——操作时元素未就绪,解决方案是使用 Playwright 自动等待而非 sleep;(2)动画干扰——元素在动画过程中点击位置偏移,解决方案是全局禁用动画;(3)外部依赖——第三方 API 不稳定,解决方案是 Mock;(4)测试间耦合——测试 A 产生的数据影响测试 B,解决方案是每个测试独立管理数据;(5)定位器脆弱——依赖 DOM 结构的 CSS 选择器,解决方案是使用 getByRole/getByTestId。
系统性治理:配置合理的 retries、使用 Playwright 的 --last-failed 重跑失败用例、在 CI 中标记 Flaky 测试并限期修复。
Q5:Page Object Model(POM)模式有什么优缺点?在什么场景下适合使用?
回答思路:
POM 将页面的定位逻辑和操作逻辑封装为独立类,测试用例只调用语义化方法(如 loginPage.login())。优点是:(1)可维护性——UI 改版只需修改 Page Object,所有用例自动适应;(2)可读性——测试读起来像用户故事;(3)复用性——登录操作封装一次,所有测试复用。
缺点是:(1)引入额外抽象层,增加了代码量;(2)过度封装会导致 Page Object 臃肿。适用场景:测试用例超过 20 个的中大型项目。小项目(<10 个用例)直接写反而更简单。Playwright 官方推荐使用 Fixture 机制注入 Page Object,比传统的手动实例化更优雅。
Q6:如何在 CI/CD 中高效运行 E2E 测试?
回答思路:
四个核心策略:(1)并行 + 分片——利用 Playwright 的 workers 在单机并行执行,利用 --shard 在多台 CI 机器间分片执行;(2)认证状态复用——通过 storageState 保存登录态,避免每个测试重复登录;(3)选择性执行——PR 只运行与变更相关的测试(通过 tag 或目录划分),全量测试放到 nightly build;(4)容器化环境——使用 Docker 保证 CI 环境一致性,Playwright 提供官方 Docker 镜像 mcr.microsoft.com/playwright。
实测数据:100 个 E2E 用例,单机串行约 30 分钟,4 workers 并行约 8 分钟,4 台机器分片约 2 分钟。
Q7:Playwright 和 Cypress 的核心架构差异是什么?对测试能力有什么影响?
回答思路:
最本质的差异在于运行位置:Cypress 的测试代码运行在浏览器内部(与被测应用同一个浏览器上下文),而 Playwright 的测试代码运行在 Node.js 进程中,通过 CDP/WebSocket 协议控制浏览器。
这导致了能力差异:Cypress 因为运行在浏览器内,可以直接访问 DOM、window 对象,调试体验直观(实时 DOM 快照),但无法操作多标签页、无法跨域、受浏览器沙箱限制;Playwright 因为是进程外控制,天然支持多标签页、多浏览器上下文、iframe、跨域操作,且可以做到真正的多浏览器(Chromium/Firefox/WebKit)。
Q8:如何设计一个前端项目的完整测试策略?
回答思路:
分四层设计:
第一层——静态分析(TypeScript + ESLint):零运行成本,捕获类型错误和代码规范问题。
第二层——单元测试(Vitest):覆盖工具函数、Hooks、纯逻辑组件。关注边界值、异常处理。目标覆盖率 80%+。
第三层——集成测试(Vitest + Testing Library + MSW):覆盖组件交互、页面级功能。用 MSW Mock API,测试用户视角的功能行为。
第四层——E2E 测试(Playwright):仅覆盖关键用户路径——注册、登录、核心业务流程、支付。用 API Mock 保证稳定性,在 CI 中分片并行执行。
关键原则:每一层测试只测该层该测的东西,不要在 E2E 中测边界值(那是单元测试的事),也不要在单元测试中测页面导航(那是 E2E 的事)。