E2E 测试
介绍
什么是 E2E 测试
端到端(End-to-End,E2E)测试是一种软件测试方法,从用户的角度出发,模拟真实用户场景,验证整个应用从前端到后端的完整流程。它测试的是完整的系统行为,包括用户界面、网络请求、数据库操作等所有组件的集成。
code
用户操作 → 浏览器交互 → API 请求 → 数据库操作 → 响应渲染
↓ ↓ ↓ ↓ ↓
完整的端到端流程验证测试金字塔
code
/\
/E2E\ 数量少,速度慢,成本高
/------\
/ 集成测试 \ 数量适中,速度适中
/----------\
/ 单元测试 \ 数量多,速度快,成本低
/--------------\| 测试类型 | 测试范围 | 执行速度 | 维护成本 | 数量占比 | 覆盖重点 |
|---|---|---|---|---|---|
| 单元测试 | 单个函数/组件 | 毫秒级 | 低 | 70% | 业务逻辑 |
| 集成测试 | 多模块协作 | 秒级 | 中 | 20% | 接口集成 |
| E2E 测试 | 完整业务流程 | 分钟级 | 高 | 10% | 用户场景 |
E2E 测试的价值
优势
- 真实模拟用户行为:最接近真实用户体验的测试方式
- 发现集成问题:检测前端、后端、数据库之间的协作问题
- 验证完整流程:确保关键业务流程能够正常完成
- 提高发布信心:上线前的最后一道防线
- 文档化用户场景:测试用例即用户使用文档
限制
- 执行速度慢:需要启动浏览器、等待网络请求
- 维护成本高:UI 变化会导致测试失败
- 调试困难:错误可能发生在任何环节
- 环境依赖:需要完整的应用环境
- 不稳定:网络延迟、异步操作可能导致间歇性失败
适用场景
适合 E2E 测试的场景:
- ✅ 关键业务流程(登录注册、购物流程、支付流程)
- ✅ 多步骤用户场景(表单填写、数据提交)
- ✅ 跨页面交互(导航流程、状态流转)
- ✅ 第三方集成(支付网关、社交登录)
- ✅ 回归测试(确保核心功能不受影响)
不适合 E2E 测试的场景:
- ❌ 单个组件逻辑(应使用单元测试)
- ❌ 算法实现(应使用单元测试)
- ❌ API 接口(应使用集成测试)
- ❌ 所有 UI 细节(应选择关键路径)
工具选型
主流工具对比
| 特性 | Playwright | Cypress | Puppeteer | Selenium |
|---|---|---|---|---|
| 浏览器支持 | Chrome, Firefox, Safari, Edge | Chrome, Firefox, Edge | Chrome, Chromium | 所有主流浏览器 |
| 并行执行 | ✅ 原生支持 | 需付费版 | ✅ | ✅ |
| 跨域支持 | ✅ 完全支持 | ⚠️ 有限支持 | ✅ | ✅ |
| 执行速度 | ⚡ 快 | 🚗 中等 | ⚡ 快 | 🐌 慢 |
| 调试体验 | 😊 好 | 🤩 优秀 | 😐 一般 | 😐 一般 |
| 学习曲线 | 📈 中等 | 📉 低 | 📈 中等 | 📈 高 |
| API Mock | ✅ | ✅ | ✅ | ❌ |
| 自动等待 | ✅ | ✅ | ❌ | ❌ |
| 网络拦截 | ✅ | ✅ | ✅ | ⚠️ 有限 |
| 视频录制 | ✅ | ✅ | ✅ | ⚠️ 需配置 |
| 快照对比 | ✅ | ⚠️ 需插件 | ⚠️ 需配置 | ❌ |
| 移动端支持 | ✅ 设备模拟 | ✅ 设备模拟 | ✅ 设备模拟 | ✅ 真机 |
| 语言支持 | JS/TS, Python, Java, .NET | JS/TS | JS/TS | 多语言 |
| 维护状态 | 🟢 活跃 | 🟢 活跃 | 🟡 稳定 | 🟢 活跃 |
| 开源免费 | ✅ MIT | ✅ MIT + 付费 | ✅ Apache | ✅ Apache |
选型建议
选择 Playwright 如果:
- 需要跨浏览器测试(特别是 Safari)
- 需要原生并行执行
- 项目使用 TypeScript
- 需要高级网络拦截和 Mock 功能
- 团队有编程经验,不介意学习曲线
选择 Cypress 如果:
- 团队重视调试体验和可视化
- 主要测试 Chrome 浏览器
- 项目规模中小型
- 团队经验较少,希望快速上手
- 需要优秀的文档和社区支持
选择 Puppeteer 如果:
- 只需要测试 Chrome 浏览器
- 项目已有 Puppeteer 相关代码
- 需要更底层的浏览器控制
选择 Selenium 如果:
- 需要测试老旧浏览器(如 IE)
- 团队已熟悉 Selenium
- 需要使用非 JS 语言(Java, Python, C#)
Playwright
核心特性
Playwright 是 Microsoft 开发的现代化 E2E 测试框架,具有以下特性:
1. 多浏览器支持
typescript
// 在配置中定义多个浏览器项目
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } }
]2. 自动等待
Playwright 自动等待元素可见、可点击、可交互,无需手动添加等待:
typescript
// Playwright 会自动等待元素可点击
await page.click('button') // 自动等待按钮可点击
await page.fill('input', 'text') // 自动等待输入框可见3. 强大的选择器
typescript
// 多种选择器策略
await page.locator('button').click() // CSS
await page.getByText('Submit').click() // 文本
await page.getByRole('button', { name: 'Submit' }).click() // 语义化
await page.getByTestId('submit-btn').click() // 测试 ID
await page.getByLabel('Email').fill('test@example.com') // 标签4. 网络拦截
typescript
// 拦截 API 请求
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ users: [] })
})
})安装与初始化
安装
bash
# 使用 npm
npm init playwright@latest
# 使用 pnpm
pnpm create playwright
# 使用 yarn
yarn create playwright安装过程中会询问:
- TypeScript 还是 JavaScript
- 测试文件夹名称
- 是否安装 GitHub Actions 工作流
- 是否安装 Playwright 浏览器
项目结构
code
project/
├── e2e/ # 测试文件目录
│ ├── login.spec.ts
│ ├── dashboard.spec.ts
│ └── api.spec.ts
├── pages/ # Page Object
│ ├── LoginPage.ts
│ └── DashboardPage.ts
├── fixtures/ # 测试数据
│ └── testData.ts
├── playwright.config.ts # Playwright 配置
└── package.json配置详解
完整配置示例
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
// 测试目录
testDir: './e2e',
// 测试文件匹配模式
testMatch: '**/*.spec.ts',
// 全局测试超时(毫秒)
timeout: 30000,
// 单个断言超时
expect: {
timeout: 5000
},
// 完全并行运行测试
fullyParallel: true,
// CI 环境禁止 .only
forbidOnly: !!process.env.CI,
// CI 环境重试次数
retries: process.env.CI ? 2 : 0,
// CI 环境限制 worker 数量
workers: process.env.CI ? 1 : undefined,
// 报告器
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'test-results.json' }],
['junit', { outputFile: 'junit-results.xml' }]
],
// 全局设置
use: {
// 基础 URL
baseURL: 'http://localhost:3000',
// 追踪设置
trace: 'on-first-retry', // 首次重试时记录
// trace: 'on', // 总是记录
// trace: 'off', // 不记录
// 截图设置
screenshot: 'only-on-failure', // 失败时截图
// screenshot: 'on', // 总是截图
// screenshot: 'off', // 不截图
// 视频设置
video: 'retain-on-failure', // 失败时保留
// video: 'on', // 总是录制
// video: 'off', // 不录制
// 浏览器上下文选项
headless: true, // 无头模式
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
// 导航超时
navigationTimeout: 30000,
actionTimeout: 15000,
// 存储认证状态
storageState: 'auth.json'
},
// 浏览器项目配置
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,
timeout: 120000,
stdout: 'pipe',
stderr: 'pipe'
}
})配置参数详解
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
testDir | string | './' | 测试文件目录 |
timeout | number | 30000 | 全局测试超时(毫秒) |
retries | number | 0 | 失败重试次数 |
workers | number | CPU 核心数 | 并行 worker 数量 |
reporter | array | ['list'] | 测试报告器 |
use.baseURL | string | - | 基础 URL |
use.trace | string | 'off' | 追踪记录策略 |
use.screenshot | string | 'off' | 截图策略 |
use.video | string | 'off' | 视频录制策略 |
use.headless | boolean | true | 无头模式 |
use.viewport | object | - | 视口大小 |
use.storageState | string | - | 认证状态文件路径 |
基础测试
第一个测试
typescript
// e2e/home.spec.ts
import { test, expect } from '@playwright/test'
test.describe('首页', () => {
test('应该显示欢迎信息', async ({ page }) => {
await page.goto('/')
await expect(page.locator('h1')).toContainText('欢迎')
})
test('应该导航到登录页', async ({ page }) => {
await page.goto('/')
await page.click('text=登录')
await expect(page).toHaveURL('/login')
})
})测试结构
typescript
test.describe('测试组名称', () => {
// 测试前钩子
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
// 测试后钩子
test.afterEach(async ({ page }) => {
// 清理操作
})
// 测试用例
test('测试名称', async ({ page }) => {
// 测试步骤
})
// 跳过测试
test.skip('跳过的测试', async ({ page }) => {
// ...
})
// 只运行此测试
test.only('只运行此测试', async ({ page }) => {
// ...
})
})参数化测试
typescript
const users = [
{ email: 'user1@example.com', password: 'pass1' },
{ email: 'user2@example.com', password: 'pass2' },
{ email: 'user3@example.com', password: 'pass3' }
]
for (const user of users) {
test(`登录测试: ${user.email}`, async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', user.email)
await page.fill('[name="password"]', user.password)
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
})
}选择器策略
推荐的选择器优先级
code
1. 语义化选择器(getByRole, getByLabel, getByText)
2. 测试 ID(getByTestId)
3. CSS 选择器(仅用于样式相关)
4. XPath(尽量避免使用)语义化选择器
typescript
// getByRole - 基于可访问性角色
await page.getByRole('button', { name: '提交' }).click()
await page.getByRole('link', { name: '首页' }).click()
await page.getByRole('textbox', { name: '邮箱' }).fill('test@example.com')
await page.getByRole('checkbox', { name: '记住我' }).check()
await page.getByRole('alert').isVisible()
// getByLabel - 基于表单标签
await page.getByLabel('邮箱地址').fill('test@example.com')
await page.getByLabel('密码').fill('password123')
// getByPlaceholder - 基于占位符
await page.getByPlaceholder('请输入邮箱').fill('test@example.com')
await page.getByPlaceholder('搜索...').fill('关键词')
// getByText - 基于文本内容
await page.getByText('欢迎回来').isVisible()
await page.getByText('确认', { exact: true }).click() // 精确匹配
await page.getByText(/欢迎|Hello/).isVisible() // 正则匹配
// getByAltText - 基于图片 alt 属性
await page.getByAltText('用户头像').isVisible()
// getByTitle - 基于 title 属性
await page.getByTitle('关闭').click()测试 ID 选择器
typescript
// HTML 中添加 data-testid
// <button data-testid="submit-btn">提交</button>
await page.getByTestId('submit-btn').click()
await page.getByTestId('user-name').textContent()CSS 选择器
typescript
// ID 选择器
await page.locator('#submit').click()
// 类选择器
await page.locator('.button').click()
// 属性选择器
await page.locator('[name="email"]').fill('test@example.com')
await page.locator('[data-status="active"]').isVisible()
// 组合选择器
await page.locator('form .submit-button').click()
await page.locator('button.primary[type="submit"]').click()链式选择器
typescript
// 在特定区域内查找
const form = page.locator('form#login')
await form.getByLabel('邮箱').fill('test@example.com')
await form.getByLabel('密码').fill('password123')
// 过滤选择器
await page.locator('.item').filter({ hasText: '重要' }).click()
await page.locator('.list-item').filter({ has: page.locator('.active') }).click()
// Nth 选择器
await page.locator('.item').first().click()
await page.locator('.item').last().click()
await page.locator('.item').nth(2).click() // 第3个等待策略
自动等待
Playwright 在执行操作前会自动等待:
- 元素附加到 DOM
- 元素可见
- 元素稳定(不在动画中)
- 元素可接收事件
- 元素启用
typescript
// 这些操作都会自动等待
await page.click('button')
await page.fill('input', 'text')
await page.check('checkbox')显式等待
typescript
// 等待元素出现
await page.waitForSelector('.loaded')
// 等待元素消失
await page.waitForSelector('.loading', { state: 'hidden' })
// 等待元素可点击
await page.waitForSelector('button', { state: 'visible' })
// 等待导航完成
await page.waitForURL('/dashboard')
// 等待页面加载状态
await page.waitForLoadState('load') // 等待 load 事件
await page.waitForLoadState('domcontentloaded') // 等待 DOMContentLoaded
await page.waitForLoadState('networkidle') // 等待网络空闲
// 等待特定时间
await page.waitForTimeout(1000) // 不推荐,尽量使用其他等待方式
// 等待函数
await page.waitForFunction(() => {
return document.querySelector('.count').textContent === '10'
})等待网络请求
typescript
// 等待特定请求完成
const responsePromise = page.waitForResponse('**/api/users')
await page.click('button')
const response = await responsePromise
// 等待请求并验证
const response = await page.waitForResponse(
resp => resp.url().includes('/api/users') && resp.status() === 200
)
// 等待多个请求
const [response1, response2] = await Promise.all([
page.waitForResponse('**/api/user'),
page.waitForResponse('**/api/profile'),
page.click('button')
])断言库
页面断言
typescript
// URL 断言
await expect(page).toHaveURL('/dashboard')
await expect(page).toHaveURL(/.*dashboard/)
await expect(page).not.toHaveURL('/login')
// 标题断言
await expect(page).toHaveTitle('Dashboard - MyApp')
await expect(page).toHaveTitle(/Dashboard/)元素断言
typescript
const button = page.locator('button')
// 可见性
await expect(button).toBeVisible()
await expect(button).toBeHidden()
await expect(button).not.toBeVisible()
// 启用状态
await expect(button).toBeEnabled()
await expect(button).toBeDisabled()
// 文本内容
await expect(button).toHaveText('提交')
await expect(button).toContainText('提交')
await expect(button).toHaveText(/提交|Submit/)
// 属性值
await expect(button).toHaveAttribute('type', 'submit')
await expect(button).toHaveClass(/primary/)
// CSS 属性
await expect(button).toHaveCSS('color', 'rgb(255, 0, 0)')
// 数量
const items = page.locator('.item')
await expect(items).toHaveCount(5)
// 值(输入框)
const input = page.locator('input')
await expect(input).toHaveValue('test@example.com')
await expect(input).toBeEmpty()
await expect(input).toBeEditable()
// 焦点
await expect(input).toBeFocused()
// 复选框/单选框
const checkbox = page.locator('input[type="checkbox"]')
await expect(checkbox).toBeChecked()
await expect(checkbox).not.toBeChecked()
// 截图对比
await expect(button).toHaveScreenshot('button.png')
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100
})快照断言
typescript
// 文本快照
await expect(page.locator('.result')).toMatchSnapshot()
// 内联快照
await expect(page.locator('.content')).toMatchInlineSnapshot(`
<div class="content">
Hello World
</div>
`)高级特性
网络拦截与 Mock
typescript
import { test, expect } from '@playwright/test'
test.describe('API Mock', () => {
test('应该显示用户列表', async ({ page }) => {
// 拦截并 Mock API 响应
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
})
})
await page.goto('/users')
await expect(page.locator('.user-item')).toHaveCount(2)
})
test('应该处理 API 错误', async ({ page }) => {
await page.route('**/api/users', route => {
route.fulfill({
status: 500,
body: 'Internal Server Error'
})
})
await page.goto('/users')
await expect(page.locator('.error-message')).toBeVisible()
})
test('应该修改请求', async ({ page }) => {
await page.route('**/api/users', route => {
const request = route.request()
const headers = {
...request.headers(),
'Authorization': 'Bearer test-token'
}
route.continue({ headers })
})
await page.goto('/users')
})
test('应该中止请求', async ({ page }) => {
await page.route('**/api/analytics', route => {
route.abort()
})
await page.goto('/')
// analytics 请求不会发送
})
})文件上传下载
typescript
import { test, expect } from '@playwright/test'
import path from 'path'
test.describe('文件操作', () => {
test('应该上传文件', async ({ page }) => {
await page.goto('/upload')
// 设置文件输入
await page.setInputFiles('input[type="file"]', path.join(__dirname, 'test-file.txt'))
// 或者使用 buffer
await page.setInputFiles('input[type="file"]', {
name: 'test.txt',
mimeType: 'text/plain',
buffer: Buffer.from('test content')
})
await page.click('button[type="submit"]')
await expect(page.locator('.success-message')).toBeVisible()
})
test('应该下载文件', async ({ page }) => {
await page.goto('/download')
// 等待下载事件
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('text=下载文件')
])
// 验证下载
expect(download.suggestedFilename()).toBe('document.pdf')
// 保存文件
const path = await download.path()
console.log('文件保存在:', path)
// 保存到指定位置
await download.saveAs('/path/to/save/file.pdf')
})
})多标签页与窗口
typescript
test('应该处理新标签页', async ({ page, context }) => {
await page.goto('/')
// 监听新页面事件
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.click('text=在新窗口打开')
])
// 在新页面中操作
await newPage.waitForLoadState()
await expect(newPage).toHaveURL(/.*new-page/)
// 在两个页面之间切换
await page.bringToFront()
await expect(page.locator('h1')).toContainText('首页')
})
test('应该处理弹出窗口', async ({ page }) => {
await page.goto('/')
// 处理 alert
page.on('dialog', async dialog => {
console.log(dialog.message())
await dialog.accept() // 点击确定
// await dialog.dismiss() // 点击取消
// await dialog.accept('输入文本') // prompt 时输入文本
})
await page.click('button.show-alert')
})iframe 操作
typescript
test('应该操作 iframe', async ({ page }) => {
await page.goto('/iframe-demo')
// 获取 iframe
const frame = page.frameLocator('iframe[name="myframe"]')
// 在 iframe 内操作
await frame.locator('input[name="email"]').fill('test@example.com')
await frame.locator('button').click()
// 验证 iframe 内容
await expect(frame.locator('.result')).toContainText('成功')
})认证状态持久化
typescript
// e2e/auth.setup.ts
import { test as setup } from '@playwright/test'
const authFile = 'playwright/.auth/user.json'
setup('认证', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'password123')
await page.click('button[type="submit"]')
// 等待登录完成
await page.waitForURL('/dashboard')
// 保存认证状态
await page.context().storageState({ path: authFile })
})typescript
// playwright.config.ts
export default defineConfig({
projects: [
// 认证设置项目
{
name: 'setup',
testMatch: /.*\.setup\.ts/
},
// 依赖认证的测试
{
name: 'chromium',
use: {
storageState: 'playwright/.auth/user.json'
},
dependencies: ['setup']
}
]
})测试组织
测试夹具
typescript
// e2e/fixtures.ts
import { test as base } from '@playwright/test'
// 定义自定义夹具
type MyFixtures = {
loginPage: LoginPage
dashboardPage: DashboardPage
}
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await use(loginPage)
},
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page)
await use(dashboardPage)
}
})
export { expect } from '@playwright/test'typescript
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures'
test('仪表板测试', async ({ loginPage, dashboardPage }) => {
await loginPage.goto()
await loginPage.login('test@example.com', 'password123')
await expect(dashboardPage.welcomeMessage).toBeVisible()
})Cypress
核心特性
Cypress 是一个现代化的前端测试框架,具有以下特性:
1. 时间旅行
Cypress 会在测试运行时拍摄快照,可以回放每一步:
typescript
// 在 Cypress Test Runner 中可以看到每一步的快照
cy.get('.button').click() // 可以查看点击前的状态2. 实时重载
修改测试代码后,Cypress 会自动重新运行测试:
bash
# 启动交互式测试运行器
npx cypress open3. 自动等待
Cypress 自动等待命令和断言:
typescript
// 自动等待元素可见
cy.get('.button').click()
// 自动等待断言通过
cy.get('.status').should('contain', '成功')安装与初始化
安装
bash
# 使用 npm
npm install cypress --save-dev
# 使用 pnpm
pnpm add cypress -D
# 使用 yarn
yarn add cypress --dev初始化
bash
# 首次运行会创建必要的文件和文件夹
npx cypress open项目结构
code
project/
├── cypress/
│ ├── e2e/ # 测试文件
│ │ ├── login.cy.js
│ │ └── dashboard.cy.js
│ ├── fixtures/ # 测试数据
│ │ └── users.json
│ ├── support/ # 支持文件
│ │ ├── commands.js # 自定义命令
│ │ └── e2e.js # 配置文件
│ ├── plugins/ # 插件
│ │ └── index.js
│ └── screenshots/ # 截图
├── cypress.config.js # Cypress 配置
└── package.json配置详解
完整配置示例
javascript
// cypress.config.js
const { defineConfig } = require('cypress')
module.exports = defineConfig({
// E2E 测试配置
e2e: {
// 基础 URL
baseUrl: 'http://localhost:3000',
// 测试文件匹配模式
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
// 支持文件
supportFile: 'cypress/support/e2e.js',
// 视口大小
viewportWidth: 1280,
viewportHeight: 720,
// 视频录制
video: true,
videoCompression: 32,
// 截图
screenshotOnRunFailure: true,
// 浏览器
browsers: [
{ name: 'chrome', family: 'chromium' },
{ name: 'firefox', family: 'firefox' }
],
// 响应超时
responseTimeout: 30000,
// 默认命令超时
defaultCommandTimeout: 4000,
// 页面加载超时
pageLoadTimeout: 60000,
// 重试配置
retries: {
runMode: 2,
openMode: 0
},
// 环境变量
env: {
apiUrl: 'https://api.example.com'
},
// 测试前置钩子
setupNodeEvents(on, config) {
// 插件配置
on('task', {
log(message) {
console.log(message)
return null
}
})
return config
}
},
// 全局配置
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
trashAssetsBeforeRuns: true,
// 时间配置
defaultCommandTimeout: 4000,
requestTimeout: 5000,
responseTimeout: 30000,
pageLoadTimeout: 60000,
// 重试
retries: {
runMode: 2,
openMode: 0
}
})配置参数详解
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
baseUrl | string | - | 基础 URL |
viewportWidth | number | 1000 | 视口宽度 |
viewportHeight | number | 660 | 视口高度 |
video | boolean | true | 是否录制视频 |
screenshotOnRunFailure | boolean | true | 失败时截图 |
defaultCommandTimeout | number | 4000 | 命令超时(毫秒) |
pageLoadTimeout | number | 60000 | 页面加载超时 |
retries | object | - | 重试配置 |
基础测试
第一个测试
javascript
// cypress/e2e/home.cy.js
describe('首页', () => {
beforeEach(() => {
cy.visit('/')
})
it('应该显示欢迎信息', () => {
cy.get('h1').should('contain', '欢迎')
})
it('应该导航到登录页', () => {
cy.contains('登录').click()
cy.url().should('include', '/login')
})
})测试结构
javascript
describe('测试组名称', () => {
// 测试前钩子
beforeEach(() => {
cy.visit('/')
})
afterEach(() => {
// 清理操作
})
// 测试用例
it('测试名称', () => {
// 测试步骤
})
// 跳过测试
it.skip('跳过的测试', () => {
// ...
})
// 只运行此测试
it.only('只运行此测试', () => {
// ...
})
})命令与断言
查询命令
javascript
// 查找元素
cy.get('.button') // CSS 选择器
cy.contains('Submit') // 文本选择器
cy.get('form').find('input') // 后代选择器
cy.get('ul').children('li') // 子元素
cy.get('li').first() // 第一个
cy.get('li').last() // 最后一个
cy.get('li').eq(2) // 第三个
cy.get('li').filter('.active') // 过滤
cy.get('li').not('.disabled') // 排除
// 表单元素
cy.get('input[name="email"]') // 属性选择器
cy.get('#submit') // ID 选择器
cy.get('.form-group input') // 组合选择器操作命令
javascript
// 页面导航
cy.visit('/dashboard')
cy.go('back')
cy.go('forward')
cy.reload()
// 输入操作
cy.get('input').type('Hello World')
cy.get('input').clear()
cy.get('input').type('{enter}') // 特殊按键
// 点击操作
cy.get('button').click()
cy.get('button').click({ force: true }) // 强制点击
cy.get('button').dblclick()
cy.get('button').rightclick()
// 选择操作
cy.get('select').select('option1')
cy.get('select').select(['opt1', 'opt2']) // 多选
// 复选框/单选框
cy.get('input[type="checkbox"]').check()
cy.get('input[type="checkbox"]').uncheck()
cy.get('input[type="radio"]').check('value1')
// 文件上传
cy.get('input[type="file"]').attachFile('test.pdf')
// 滚动
cy.get('.element').scrollIntoView()
cy.scrollTo(0, 500)
// 等待
cy.wait(1000) // 固定等待
cy.wait('@apiRequest') // 等待请求断言
javascript
// 可见性
cy.get('.element').should('be.visible')
cy.get('.element').should('not.be.visible')
// 存在性
cy.get('.element').should('exist')
cy.get('.element').should('not.exist')
// 文本内容
cy.get('.title').should('contain', 'Hello')
cy.get('.title').should('have.text', 'Hello World')
cy.get('.title').should('include.text', 'Hello')
// 属性
cy.get('input').should('have.value', 'test')
cy.get('a').should('have.attr', 'href', '/dashboard')
cy.get('button').should('have.class', 'active')
cy.get('input').should('be.disabled')
cy.get('input').should('be.enabled')
// 数量
cy.get('li').should('have.length', 5)
// CSS
cy.get('.element').should('have.css', 'color', 'rgb(255, 0, 0)')
// URL
cy.url().should('include', '/dashboard')
cy.url().should('eq', 'http://localhost:3000/dashboard')
// 标题
cy.title().should('include', 'Dashboard')
// 链式断言
cy.get('.user')
.should('have.class', 'active')
.and('contain', 'John')
.and('be.visible')API Mock
拦截请求
javascript
// 简单拦截
cy.intercept('GET', '/api/users', {
fixture: 'users.json'
}).as('getUsers')
// 动态响应
cy.intercept('POST', '/api/users', (req) => {
req.reply({
statusCode: 201,
body: {
id: 1,
name: req.body.name
}
})
})
// 延迟响应
cy.intercept('GET', '/api/users', {
delay: 1000,
fixture: 'users.json'
})
// 错误响应
cy.intercept('GET', '/api/users', {
statusCode: 500,
body: { error: 'Server Error' }
})
// 等待请求
cy.wait('@getUsers').then((interception) => {
expect(interception.response.statusCode).to.equal(200)
})使用 Fixture
javascript
// cypress/fixtures/users.json
[
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
]
// 在测试中使用
cy.intercept('GET', '/api/users', { fixture: 'users.json' })
// 动态修改 fixture
cy.fixture('users.json').then((users) => {
users.push({ id: 3, name: 'Charlie' })
cy.intercept('GET', '/api/users', users)
})自定义命令
创建自定义命令
javascript
// cypress/support/commands.js
// 简单命令
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login')
cy.get('[name="email"]').type(email)
cy.get('[name="password"]').type(password)
cy.get('button[type="submit"]').click()
})
// 带选项的命令
Cypress.Commands.add('login', (options = {}) => {
const { email = 'test@example.com', password = 'password123' } = options
cy.visit('/login')
cy.get('[name="email"]').type(email)
cy.get('[name="password"]').type(password)
cy.get('button[type="submit"]').click()
})
// 返回值的命令
Cypress.Commands.add('getUser', (id) => {
return cy.request(`/api/users/${id}`).then((response) => {
return response.body
})
})
// 数据库操作命令(需要插件支持)
Cypress.Commands.add('resetDatabase', () => {
cy.request('POST', '/api/test/reset-database')
})使用自定义命令
javascript
// 使用简单命令
cy.login('user@example.com', 'password123')
// 使用带选项的命令
cy.login({ email: 'admin@example.com', password: 'admin123' })
// 使用返回值的命令
cy.getUser(1).then((user) => {
expect(user.name).to.equal('Alice')
})
// 在钩子中使用
beforeEach(() => {
cy.login()
cy.resetDatabase()
})插件扩展
常用插件
bash
# 安装常用插件
npm install --save-dev \
@testing-library/cypress \
cypress-file-upload \
cypress-axe \
cypress-real-events配置插件
javascript
// cypress/support/e2e.js
// Testing Library
import '@testing-library/cypress/add-commands'
// File Upload
import 'cypress-file-upload'
// Accessibility
import 'cypress-axe'
// Real Events
import 'cypress-real-events/support'使用插件
javascript
// Testing Library
cy.findByRole('button', { name: /submit/i }).click()
cy.findByLabelText('Email').type('test@example.com')
// File Upload
cy.get('input[type="file"]').attachFile('test.pdf')
// Accessibility
cy.checkA11y()
// Real Events(真实浏览器事件)
cy.get('button').realHover()
cy.get('input').realPress('Enter')测试模式
Page Object 模式
Page Object 模式将页面元素和操作封装到独立的类中,提高代码复用性和可维护性。
Playwright 实现
typescript
// pages/BasePage.ts
import { Page, Locator } from '@playwright/test'
export class BasePage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
async goto(path: string) {
await this.page.goto(path)
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle')
}
}typescript
// pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test'
import { BasePage } from './BasePage'
export class LoginPage extends BasePage {
// 元素定位器
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
super(page)
// 初始化元素定位器
this.emailInput = page.getByLabel('邮箱')
this.passwordInput = page.getByLabel('密码')
this.submitButton = page.getByRole('button', { name: '登录' })
this.errorMessage = page.locator('.error-message')
}
async goto() {
await super.goto('/login')
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
async expectErrorMessage(message: string) {
await expect(this.errorMessage).toContainText(message)
}
async expectToBeOnLoginPage() {
await expect(this.page).toHaveURL('/login')
}
}typescript
// pages/DashboardPage.ts
import { Page, Locator, expect } from '@playwright/test'
import { BasePage } from './BasePage'
export class DashboardPage extends BasePage {
readonly welcomeMessage: Locator
readonly userMenu: Locator
readonly logoutButton: Locator
constructor(page: Page) {
super(page)
this.welcomeMessage = page.locator('.welcome-message')
this.userMenu = page.locator('.user-menu')
this.logoutButton = page.getByRole('button', { name: '退出登录' })
}
async expectToBeOnDashboard() {
await expect(this.page).toHaveURL('/dashboard')
await expect(this.welcomeMessage).toBeVisible()
}
async logout() {
await this.userMenu.click()
await this.logoutButton.click()
}
}typescript
// e2e/login.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/LoginPage'
import { DashboardPage } from '../pages/DashboardPage'
test.describe('登录流程', () => {
let loginPage: LoginPage
let dashboardPage: DashboardPage
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page)
dashboardPage = new DashboardPage(page)
await loginPage.goto()
})
test('应该成功登录', async ({ page }) => {
await loginPage.login('user@example.com', 'password123')
await dashboardPage.expectToBeOnDashboard()
await expect(dashboardPage.welcomeMessage).toContainText('欢迎')
})
test('应该显示错误信息', async ({ page }) => {
await loginPage.login('wrong@example.com', 'wrongpassword')
await loginPage.expectErrorMessage('邮箱或密码错误')
await loginPage.expectToBeOnLoginPage()
})
})Cypress 实现
javascript
// cypress/support/pages/LoginPage.js
class LoginPage {
visit() {
cy.visit('/login')
}
fillEmail(email) {
cy.get('[name="email"]').clear().type(email)
}
fillPassword(password) {
cy.get('[name="password"]').clear().type(password)
}
submit() {
cy.get('button[type="submit"]').click()
}
login(email, password) {
this.fillEmail(email)
this.fillPassword(password)
this.submit()
}
expectErrorMessage(message) {
cy.get('.error-message').should('contain', message)
}
expectToBeOnLoginPage() {
cy.url().should('include', '/login')
}
}
export default new LoginPage()javascript
// cypress/e2e/login.cy.js
import loginPage from '../support/pages/LoginPage'
describe('登录流程', () => {
beforeEach(() => {
loginPage.visit()
})
it('应该成功登录', () => {
loginPage.login('user@example.com', 'password123')
cy.url().should('include', '/dashboard')
cy.get('.welcome-message').should('contain', '欢迎')
})
it('应该显示错误信息', () => {
loginPage.login('wrong@example.com', 'wrongpassword')
loginPage.expectErrorMessage('邮箱或密码错误')
loginPage.expectToBeOnLoginPage()
})
})测试数据管理
使用 Fixtures
typescript
// fixtures/testData.ts
export const users = {
validUser: {
email: 'user@example.com',
password: 'password123'
},
adminUser: {
email: 'admin@example.com',
password: 'admin123'
},
invalidUser: {
email: 'invalid@example.com',
password: 'wrongpassword'
}
}
export const products = [
{ id: 1, name: 'Product 1', price: 100 },
{ id: 2, name: 'Product 2', price: 200 }
]typescript
// e2e/login.spec.ts
import { users } from '../fixtures/testData'
test('应该使用测试数据登录', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', users.validUser.email)
await page.fill('[name="password"]', users.validUser.password)
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
})数据生成器
typescript
// helpers/dataGenerator.ts
import { faker } from '@faker-js/faker'
export function generateUser(overrides = {}) {
return {
name: faker.person.fullName(),
email: faker.internet.email(),
password: faker.internet.password({ length: 12 }),
phone: faker.phone.number(),
...overrides
}
}
export function generateProduct(overrides = {}) {
return {
name: faker.commerce.productName(),
price: parseFloat(faker.commerce.price()),
description: faker.commerce.productDescription(),
...overrides
}
}typescript
// e2e/register.spec.ts
import { generateUser } from '../helpers/dataGenerator'
test('应该注册新用户', async ({ page }) => {
const newUser = generateUser()
await page.goto('/register')
await page.fill('[name="name"]', newUser.name)
await page.fill('[name="email"]', newUser.email)
await page.fill('[name="password"]', newUser.password)
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
})环境配置管理
多环境配置
typescript
// config/index.ts
const config = {
development: {
baseUrl: 'http://localhost:3000',
apiUrl: 'http://localhost:3001/api'
},
staging: {
baseUrl: 'https://staging.example.com',
apiUrl: 'https://api.staging.example.com'
},
production: {
baseUrl: 'https://example.com',
apiUrl: 'https://api.example.com'
}
}
export function getConfig() {
const env = process.env.TEST_ENV || 'development'
return config[env]
}typescript
// e2e/config.spec.ts
import { getConfig } from '../config'
const { baseUrl, apiUrl } = getConfig()
test('应该访问正确的环境', async ({ page }) => {
await page.goto(baseUrl)
// ...
})测试夹具
Playwright 夹具
typescript
// e2e/fixtures.ts
import { test as base, expect } from '@playwright/test'
import { LoginPage } from '../pages/LoginPage'
type MyFixtures = {
loginPage: LoginPage
authenticatedPage: Page
}
export const test = base.extend<MyFixtures>({
// 注入 Page Object
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await use(loginPage)
},
// 注入已认证的页面
authenticatedPage: async ({ page }, use) => {
// 执行登录
await page.goto('/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'password123')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
await use(page)
}
})
export { expect }typescript
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures'
test('已登录用户可以查看仪表板', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
await expect(authenticatedPage.locator('.welcome')).toBeVisible()
})高级场景
认证流程测试
typescript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('认证流程', () => {
test('完整登录流程', async ({ page }) => {
// 1. 访问登录页
await page.goto('/login')
// 2. 验证页面元素
await expect(page.locator('h1')).toContainText('登录')
await expect(page.getByLabel('邮箱')).toBeVisible()
await expect(page.getByLabel('密码')).toBeVisible()
// 3. 填写表单
await page.getByLabel('邮箱').fill('user@example.com')
await page.getByLabel('密码').fill('password123')
// 4. 提交表单
await page.getByRole('button', { name: '登录' }).click()
// 5. 验证跳转
await expect(page).toHaveURL('/dashboard')
// 6. 验证登录状态
await expect(page.locator('.user-name')).toContainText('User')
})
test('记住我功能', async ({ page, context }) => {
await page.goto('/login')
await page.getByLabel('邮箱').fill('user@example.com')
await page.getByLabel('密码').fill('password123')
await page.getByLabel('记住我').check()
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL('/dashboard')
// 关闭浏览器
await context.close()
// 重新打开浏览器
const newContext = await page.context().browser()!.newContext()
const newPage = await newContext.newPage()
// 验证仍然登录
await newPage.goto('/')
await expect(newPage).toHaveURL('/dashboard')
})
test('密码重置流程', async ({ page }) => {
await page.goto('/login')
await page.click('text=忘记密码')
await expect(page).toHaveURL('/forgot-password')
await page.getByLabel('邮箱').fill('user@example.com')
await page.getByRole('button', { name: '发送重置链接' }).click()
await expect(page.locator('.success-message')).toContainText('重置链接已发送')
})
})表单测试
typescript
// e2e/form.spec.ts
import { test, expect } from '@playwright/test'
test.describe('表单验证', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/register')
})
test('应该验证必填字段', async ({ page }) => {
await page.click('button[type="submit"]')
await expect(page.locator('.error').first()).toContainText('此字段为必填项')
})
test('应该验证邮箱格式', async ({ page }) => {
await page.fill('[name="email"]', 'invalid-email')
await page.click('button[type="submit"]')
await expect(page.locator('.error')).toContainText('请输入有效的邮箱地址')
})
test('应该验证密码强度', async ({ page }) => {
await page.fill('[name="password"]', '123')
await page.click('button[type="submit"]')
await expect(page.locator('.password-strength')).toContainText('密码强度不足')
})
test('应该验证密码确认', async ({ page }) => {
await page.fill('[name="password"]', 'Password123')
await page.fill('[name="confirmPassword"]', 'Password456')
await page.click('button[type="submit"]')
await expect(page.locator('.error')).toContainText('两次密码输入不一致')
})
test('应该成功提交有效表单', async ({ page }) => {
await page.fill('[name="name"]', 'John Doe')
await page.fill('[name="email"]', 'john@example.com')
await page.fill('[name="password"]', 'Password123!')
await page.fill('[name="confirmPassword"]', 'Password123!')
await page.check('[name="agreeTerms"]')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/success')
})
})文件上传下载
typescript
// e2e/file.spec.ts
import { test, expect } from '@playwright/test'
import path from 'path'
test.describe('文件操作', () => {
test('应该上传单个文件', async ({ page }) => {
await page.goto('/upload')
// 设置文件
await page.setInputFiles('input[type="file"]', path.join(__dirname, '../fixtures/test.pdf'))
// 提交
await page.click('button[type="submit"]')
// 验证
await expect(page.locator('.success-message')).toBeVisible()
await expect(page.locator('.file-name')).toContainText('test.pdf')
})
test('应该上传多个文件', async ({ page }) => {
await page.goto('/upload')
await page.setInputFiles('input[type="file"]', [
path.join(__dirname, '../fixtures/file1.pdf'),
path.join(__dirname, '../fixtures/file2.pdf')
])
await page.click('button[type="submit"]')
await expect(page.locator('.file-item')).toHaveCount(2)
})
test('应该验证文件类型', async ({ page }) => {
await page.goto('/upload')
// 上传不允许的文件类型
await page.setInputFiles('input[type="file"]', path.join(__dirname, '../fixtures/test.exe'))
await expect(page.locator('.error-message')).toContainText('不支持的文件类型')
})
test('应该下载文件', async ({ page }) => {
await page.goto('/download')
// 等待下载
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('text=下载报告')
])
// 验证文件名
expect(download.suggestedFilename()).toMatch(/report.*\.pdf/)
// 保存文件
const downloadPath = path.join(__dirname, '../downloads', download.suggestedFilename())
await download.saveAs(downloadPath)
// 验证文件存在
const fs = require('fs')
expect(fs.existsSync(downloadPath)).toBe(true)
})
})多标签页与窗口
typescript
// e2e/tab.spec.ts
import { test, expect } from '@playwright/test'
test.describe('多标签页', () => {
test('应该处理新标签页', async ({ page, context }) => {
await page.goto('/')
// 监听新页面
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.click('text=在新窗口打开')
])
// 等待新页面加载
await newPage.waitForLoadState()
// 在新页面操作
await expect(newPage).toHaveURL(/.*new-page/)
await newPage.fill('input', 'test')
// 回到原页面
await page.bringToFront()
await expect(page.locator('h1')).toContainText('首页')
})
test('应该在多个标签页之间切换', async ({ page, context }) => {
await page.goto('/')
// 打开多个标签页
const [page2] = await Promise.all([
context.waitForEvent('page'),
page.click('#open-tab-2')
])
const [page3] = await Promise.all([
context.waitForEvent('page'),
page.click('#open-tab-3')
])
// 在不同页面操作
await page2.fill('input', 'page 2')
await page3.fill('input', 'page 3')
// 验证各页面状态
expect(await page2.inputValue('input')).toBe('page 2')
expect(await page3.inputValue('input')).toBe('page 3')
})
})响应式测试
typescript
// e2e/responsive.spec.ts
import { test, expect } from '@playwright/test'
test.describe('响应式设计', () => {
test('桌面端视图', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 })
await page.goto('/')
// 验证桌面端元素
await expect(page.locator('.desktop-menu')).toBeVisible()
await expect(page.locator('.mobile-menu')).not.toBeVisible()
})
test('平板端视图', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 })
await page.goto('/')
// 验证平板端元素
await expect(page.locator('.tablet-layout')).toBeVisible()
})
test('移动端视图', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 })
await page.goto('/')
// 验证移动端元素
await expect(page.locator('.mobile-menu')).toBeVisible()
await expect(page.locator('.desktop-menu')).not.toBeVisible()
// 测试移动端菜单
await page.click('.hamburger-menu')
await expect(page.locator('.mobile-nav')).toBeVisible()
})
test('使用设备预设', async ({ page }) => {
// iPhone 12
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
// iPad Pro
await page.setViewportSize({ width: 1024, height: 1366 })
await page.goto('/')
})
})性能测试
typescript
// e2e/performance.spec.ts
import { test, expect } from '@playwright/test'
test.describe('性能测试', () => {
test('页面加载性能', async ({ page }) => {
const startTime = Date.now()
await page.goto('/')
const loadTime = Date.now() - startTime
// 验证加载时间
expect(loadTime).toBeLessThan(3000) // 3秒内加载完成
// 获取性能指标
const metrics = await page.evaluate(() => {
const timing = performance.timing
return {
domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart,
load: timing.loadEventEnd - timing.navigationStart,
domInteractive: timing.domInteractive - timing.navigationStart
}
})
console.log('性能指标:', metrics)
expect(metrics.load).toBeLessThan(3000)
})
test('网络性能', async ({ page }) => {
// 模拟慢速网络
const client = await page.context().newCDPSession(page)
await client.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (500 * 1024) / 8, // 500kb/s
uploadThroughput: (500 * 1024) / 8,
latency: 200
})
const startTime = Date.now()
await page.goto('/')
const loadTime = Date.now() - startTime
expect(loadTime).toBeLessThan(10000) // 慢速网络下10秒内
})
test('Core Web Vitals', async ({ page }) => {
await page.goto('/')
const vitals = await page.evaluate(() => {
return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries()
resolve(entries.map(entry => ({
name: entry.name,
value: entry.value
})))
}).observe({ entryTypes: ['measure', 'navigation'] })
})
})
console.log('Web Vitals:', vitals)
})
})可访问性测试
typescript
// e2e/accessibility.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test.describe('可访问性测试', () => {
test('应该没有可访问性违规', async ({ page }) => {
await page.goto('/')
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
expect(accessibilityScanResults.violations).toEqual([])
})
test('应该支持键盘导航', async ({ page }) => {
await page.goto('/')
// Tab 导航
await page.keyboard.press('Tab')
await expect(page.getByRole('link', { name: '首页' })).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByRole('link', { name: '产品' })).toBeFocused()
// Enter 激活
await page.keyboard.press('Enter')
await expect(page).toHaveURL('/products')
})
test('应该有正确的 ARIA 标签', async ({ page }) => {
await page.goto('/')
// 验证 landmark 角色
await expect(page.getByRole('navigation')).toBeVisible()
await expect(page.getByRole('main')).toBeVisible()
await expect(page.getByRole('banner')).toBeVisible()
await expect(page.getByRole('contentinfo')).toBeVisible()
// 验证图片有 alt 属性
const images = await page.locator('img').all()
for (const img of images) {
await expect(img).toHaveAttribute('alt')
}
})
})CI/CD 集成
GitHub Actions
Playwright 配置
yaml
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
playwright:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps ${{ matrix.browser }}
- name: Run Playwright tests
run: pnpm exec playwright test --project=${{ matrix.browser }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 30
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v3
with:
name: screenshots-${{ matrix.browser }}
path: test-results/Cypress 配置
yaml
# .github/workflows/cypress.yml
name: Cypress E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
cypress:
runs-on: ubuntu-latest
strategy:
matrix:
browsers: [chrome, firefox]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run Cypress tests
uses: cypress-io/github-action@v5
with:
browser: ${{ matrix.browsers }}
start: npm run dev
wait-on: 'http://localhost:3000'
wait-on-timeout: 120
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v3
with:
name: cypress-screenshots-${{ matrix.browsers }}
path: cypress/screenshots
- name: Upload videos
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-videos-${{ matrix.browsers }}
path: cypress/videosDocker 集成
Dockerfile
dockerfile
# Dockerfile.test
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx playwright install
CMD ["npx", "playwright", "test"]Docker Compose
yaml
# docker-compose.test.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: test
playwright:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
- app
environment:
BASE_URL: http://app:3000
volumes:
- ./test-results:/app/test-results
- ./playwright-report:/app/playwright-reportbash
# 运行测试
docker-compose -f docker-compose.test.yml up --build --abort-on-container-exit并行执行
Playwright 分片
yaml
# .github/workflows/parallel.yml
name: Parallel E2E Tests
on: [push]
jobs:
playwright:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run tests
run: pnpm exec playwright test --shard=${{ matrix.shard }}
- name: Upload results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report-${{ matrix.shard }}
path: playwright-report/
merge-reports:
needs: [playwright]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: all-reports
- name: Merge reports
run: |
pnpm install
pnpm exec playwright merge-reports --reporter html ./all-reportsCypress 并行
yaml
# .github/workflows/cypress-parallel.yml
name: Cypress Parallel
on: [push]
jobs:
cypress:
runs-on: ubuntu-latest
strategy:
matrix:
containers: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v3
- name: Run Cypress
uses: cypress-io/github-action@v5
with:
start: npm run dev
wait-on: 'http://localhost:3000'
parallel: true
record: true
group: 'E2E Tests'
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}测试报告
生成多种格式报告
typescript
// playwright.config.ts
export default defineConfig({
reporter: [
['list'], // 控制台输出
['html', { open: 'never' }], // HTML 报告
['json', { outputFile: 'results.json' }], // JSON 报告
['junit', { outputFile: 'junit.xml' }], // JUnit 报告
['github'] // GitHub Actions 注解
]
})自定义报告器
typescript
// reporters/custom-reporter.ts
import { Reporter, TestCase, TestResult } from '@playwright/test/reporter'
class CustomReporter implements Reporter {
onTestEnd(test: TestCase, result: TestResult) {
console.log(`Test: ${test.title}`)
console.log(`Status: ${result.status}`)
console.log(`Duration: ${result.duration}ms`)
}
}
export default CustomReporter调试与维护
调试技巧
Playwright 调试
typescript
// 1. 使用 page.pause()
test('调试示例', async ({ page }) => {
await page.goto('/')
await page.pause() // 暂停执行,打开调试器
await page.click('button')
})
// 2. 使用调试模式
// npx playwright test --debug
// 3. 使用 trace
test('查看 trace', async ({ page }) => {
await page.goto('/')
await page.context().tracing.start({ screenshots: true, snapshots: true })
await page.click('button')
await page.context().tracing.stop({ path: 'trace.zip' })
})
// 查看 trace
// npx playwright show-trace trace.zip
// 4. 截图和视频
test('截图调试', async ({ page }) => {
await page.goto('/')
await page.screenshot({ path: 'screenshot.png', fullPage: true })
})
// 5. 控制台日志
page.on('console', msg => console.log('PAGE LOG:', msg.text()))
// 6. 页面错误
page.on('pageerror', error => console.log('PAGE ERROR:', error.message))Cypress 调试
javascript
// 1. 使用 cy.pause()
it('调试示例', () => {
cy.visit('/')
cy.pause() // 暂停执行
cy.get('button').click()
})
// 2. 使用 cy.debug()
cy.get('button').debug().click()
// 3. 使用 .then()
cy.get('button').then($btn => {
console.log('Button:', $btn)
debugger
})
// 4. 查看 DOM 快照
cy.get('button').click()
// 在 Test Runner 中可以查看每一步的 DOM 快照
// 5. 日志
Cypress.log({
name: 'Custom Log',
message: 'This is a custom log'
})测试稳定性
常见不稳定因素
- 网络延迟
- 异步操作未完成
- 动态内容
- 外部依赖
- 测试数据污染
提高稳定性策略
typescript
// 1. 使用自动等待
await page.click('button') // Playwright 自动等待
cy.get('button').click() // Cypress 自动等待
// 2. 避免固定等待
// ❌ 不推荐
await page.waitForTimeout(1000)
cy.wait(1000)
// ✅ 推荐
await page.waitForSelector('.loaded')
cy.get('.loaded').should('be.visible')
// 3. 使用重试机制
test('重试测试', async ({ page }) => {
// ...
}, { retries: 3 })
// 4. 隔离测试数据
beforeEach(async () => {
await resetDatabase()
})
// 5. Mock 外部依赖
await page.route('**/api/external', route => {
route.fulfill({ body: JSON.stringify({ data: 'mocked' }) })
})
// 6. 使用稳定的选择器
// ✅ 推荐
await page.getByTestId('submit-button').click()
await page.getByRole('button', { name: '提交' }).click()
// ❌ 不推荐
await page.locator('.btn.btn-primary.submit-btn.v2').click()测试维护
维护策略
-
使用 Page Object 模式
- 封装 UI 变化
- 提高代码复用
-
使用语义化选择器
- 基于角色和文本
- 避免依赖实现细节
-
定期清理测试
- 删除过时测试
- 更新测试数据
-
监控测试健康
- 跟踪失败率
- 分析执行时间
typescript
// 示例:可维护的测试结构
test.describe('用户管理', () => {
let userPage: UserPage
test.beforeEach(async ({ page }) => {
userPage = new UserPage(page)
await userPage.goto()
})
test('应该创建用户', async () => {
await userPage.createUser({
name: 'Test User',
email: 'test@example.com'
})
await expect(userPage.successMessage).toBeVisible()
})
test('应该删除用户', async () => {
const user = await userPage.createUser(testData.user)
await userPage.deleteUser(user.id)
await expect(userPage.userRow(user.id)).not.toBeVisible()
})
})最佳实践
1. 选择正确的测试类型
code
需要测试的功能 → 选择测试类型
↓
单元逻辑 → 单元测试
↓
模块协作 → 集成测试
↓
用户场景 → E2E 测试2. 测试命名规范
typescript
// ✅ 好的命名
test('应该成功登录并跳转到仪表板', async ({ page }) => {
// ...
})
test('当邮箱格式错误时应该显示错误提示', async ({ page }) => {
// ...
})
// ❌ 不好的命名
test('test1', async ({ page }) => {
// ...
})
test('login', async ({ page }) => {
// ...
})3. 测试独立性
typescript
// ✅ 每个测试独立
test('测试 A', async ({ page }) => {
await page.goto('/login')
// ...
})
test('测试 B', async ({ page }) => {
await page.goto('/login')
// ...
})
// ❌ 测试相互依赖
let userId
test('创建用户', async ({ page }) => {
userId = await createUser()
})
test('删除用户', async ({ page }) => {
await deleteUser(userId) // 依赖上一个测试
})4. 测试数据管理
typescript
// ✅ 使用工厂函数
const user = await createUser({
email: 'test@example.com',
role: 'admin'
})
// ✅ 使用夹具
test('使用测试数据', async ({ page }) => {
const { validUser } = useTestData()
// ...
})5. 适当使用 Mock
typescript
// ✅ Mock 外部服务
await page.route('**/api/payment', route => {
route.fulfill({ body: JSON.stringify({ success: true }) })
})
// ❌ 过度 Mock
await page.route('**', route => route.continue()) // Mock 所有请求6. 测试覆盖率
code
关键路径:100% 覆盖
重要功能:80% 覆盖
次要功能:50% 覆盖
边缘情况:选择性覆盖常见问题
1. 元素找不到
typescript
// 问题
await page.click('.button') // 元素不存在
// 解决
// 1. 检查选择器
await page.click('button[type="submit"]')
// 2. 等待元素
await page.waitForSelector('.button')
await page.click('.button')
// 3. 使用更稳定的选择器
await page.getByRole('button', { name: '提交' }).click()2. 测试超时
typescript
// 问题
test('测试', async ({ page }) => {
// 执行时间超过默认超时
})
// 解决
// 1. 增加超时时间
test('测试', async ({ page }) => {
// ...
}, { timeout: 60000 })
// 2. 优化测试逻辑
// 减少不必要的等待
// 3. 并行执行
// 在配置中启用 fullyParallel3. 测试不稳定
typescript
// 问题:测试时而通过时而失败
// 解决
// 1. 使用自动等待
await page.click('button')
await page.waitForLoadState('networkidle')
// 2. 增加重试
test('测试', async ({ page }) => {
// ...
}, { retries: 3 })
// 3. 避免竞态条件
await Promise.all([
page.waitForResponse('**/api/data'),
page.click('button')
])4. 跨域问题
typescript
// Cypress 跨域限制
// 解决:配置白名单
// cypress.config.js
module.exports = {
e2e: {
baseUrl: 'http://localhost:3000',
experimentalOriginDependencies: true
}
}
// Playwright 无跨域限制
// 可以直接访问任何域5. 测试数据污染
typescript
// 问题:测试数据影响其他测试
// 解决
beforeEach(async () => {
await resetDatabase()
})
afterEach(async () => {
await cleanupTestData()
})
// 或使用事务回滚
test('测试', async ({ page }) => {
const transaction = await startTransaction()
// ... 测试
await rollback(transaction)
})6. 性能问题
typescript
// 问题:测试执行慢
// 解决
// 1. 并行执行
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: 4
})
// 2. 只在失败时截图
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure'
}
// 3. 使用分片
// CI 中使用 --shard 参数
// 4. 优化选择器
// 使用更高效的选择器
await page.getByTestId('button') // 快
await page.locator('div > div > button') // 慢故障排查
常见错误
1. Element not found
code
Error: Element not found解决方案:
typescript
// 1. 检查选择器
await page.screenshot({ path: 'debug.png' })
// 2. 等待元素
await page.waitForSelector('.element')
// 3. 使用调试模式
npx playwright test --debug2. Timeout exceeded
code
Error: Timeout of 30000ms exceeded解决方案:
typescript
// 1. 增加超时
test('测试', async ({ page }) => {
// ...
}, { timeout: 60000 })
// 2. 优化等待策略
await page.waitForLoadState('domcontentloaded') // 比 networkidle 快
// 3. 检查网络请求
page.on('request', req => console.log(req.url()))3. Browser not installed
code
Error: Executable doesn't exist解决方案:
bash
# 安装浏览器
npx playwright install
# 安装特定浏览器
npx playwright install chromium
# 安装依赖
npx playwright install-deps4. Port already in use
code
Error: listen EADDRINUSE解决方案:
typescript
// 使用随机端口
const port = await getAvailablePort()
// 或关闭占用端口的进程
// lsof -ti:3000 | xargs kill -9调试清单
markdown
- [ ] 查看截图和视频
- [ ] 查看 trace 文件
- [ ] 检查控制台日志
- [ ] 检查网络请求
- [ ] 验证选择器
- [ ] 检查测试数据
- [ ] 检查环境配置
- [ ] 检查超时设置