{T}

E2E 测试

介绍

什么是 E2E 测试

端到端(End-to-End,E2E)测试是一种软件测试方法,从用户的角度出发,模拟真实用户场景,验证整个应用从前端到后端的完整流程。它测试的是完整的系统行为,包括用户界面、网络请求、数据库操作等所有组件的集成。

code
用户操作 → 浏览器交互 → API 请求 → 数据库操作 → 响应渲染
    ↓           ↓           ↓           ↓           ↓
  完整的端到端流程验证

测试金字塔

code
        /\
       /E2E\        数量少,速度慢,成本高
      /------\
     / 集成测试 \      数量适中,速度适中
    /----------\
   /   单元测试   \    数量多,速度快,成本低
  /--------------\
测试类型测试范围执行速度维护成本数量占比覆盖重点
单元测试单个函数/组件毫秒级70%业务逻辑
集成测试多模块协作秒级20%接口集成
E2E 测试完整业务流程分钟级10%用户场景

E2E 测试的价值

优势

  • 真实模拟用户行为:最接近真实用户体验的测试方式
  • 发现集成问题:检测前端、后端、数据库之间的协作问题
  • 验证完整流程:确保关键业务流程能够正常完成
  • 提高发布信心:上线前的最后一道防线
  • 文档化用户场景:测试用例即用户使用文档

限制

  • 执行速度慢:需要启动浏览器、等待网络请求
  • 维护成本高:UI 变化会导致测试失败
  • 调试困难:错误可能发生在任何环节
  • 环境依赖:需要完整的应用环境
  • 不稳定:网络延迟、异步操作可能导致间歇性失败

适用场景

适合 E2E 测试的场景

  • ✅ 关键业务流程(登录注册、购物流程、支付流程)
  • ✅ 多步骤用户场景(表单填写、数据提交)
  • ✅ 跨页面交互(导航流程、状态流转)
  • ✅ 第三方集成(支付网关、社交登录)
  • ✅ 回归测试(确保核心功能不受影响)

不适合 E2E 测试的场景

  • ❌ 单个组件逻辑(应使用单元测试)
  • ❌ 算法实现(应使用单元测试)
  • ❌ API 接口(应使用集成测试)
  • ❌ 所有 UI 细节(应选择关键路径)

工具选型

主流工具对比

特性PlaywrightCypressPuppeteerSelenium
浏览器支持Chrome, Firefox, Safari, EdgeChrome, Firefox, EdgeChrome, Chromium所有主流浏览器
并行执行✅ 原生支持需付费版
跨域支持✅ 完全支持⚠️ 有限支持
执行速度⚡ 快🚗 中等⚡ 快🐌 慢
调试体验😊 好🤩 优秀😐 一般😐 一般
学习曲线📈 中等📉 低📈 中等📈 高
API Mock
自动等待
网络拦截⚠️ 有限
视频录制⚠️ 需配置
快照对比⚠️ 需插件⚠️ 需配置
移动端支持✅ 设备模拟✅ 设备模拟✅ 设备模拟✅ 真机
语言支持JS/TS, Python, Java, .NETJS/TSJS/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'
  }
})

配置参数详解

参数类型默认值说明
testDirstring'./'测试文件目录
timeoutnumber30000全局测试超时(毫秒)
retriesnumber0失败重试次数
workersnumberCPU 核心数并行 worker 数量
reporterarray['list']测试报告器
use.baseURLstring-基础 URL
use.tracestring'off'追踪记录策略
use.screenshotstring'off'截图策略
use.videostring'off'视频录制策略
use.headlessbooleantrue无头模式
use.viewportobject-视口大小
use.storageStatestring-认证状态文件路径

基础测试

第一个测试

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 open

3. 自动等待

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
  }
})

配置参数详解

参数类型默认值说明
baseUrlstring-基础 URL
viewportWidthnumber1000视口宽度
viewportHeightnumber660视口高度
videobooleantrue是否录制视频
screenshotOnRunFailurebooleantrue失败时截图
defaultCommandTimeoutnumber4000命令超时(毫秒)
pageLoadTimeoutnumber60000页面加载超时
retriesobject-重试配置

基础测试

第一个测试

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/videos

Docker 集成

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-report
bash
# 运行测试
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-reports

Cypress 并行

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'
})

测试稳定性

常见不稳定因素

  1. 网络延迟
  2. 异步操作未完成
  3. 动态内容
  4. 外部依赖
  5. 测试数据污染

提高稳定性策略

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()

测试维护

维护策略

  1. 使用 Page Object 模式

    • 封装 UI 变化
    • 提高代码复用
  2. 使用语义化选择器

    • 基于角色和文本
    • 避免依赖实现细节
  3. 定期清理测试

    • 删除过时测试
    • 更新测试数据
  4. 监控测试健康

    • 跟踪失败率
    • 分析执行时间
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. 并行执行
// 在配置中启用 fullyParallel

3. 测试不稳定

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 --debug

2. 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-deps

4. Port already in use

code
Error: listen EADDRINUSE

解决方案

typescript
// 使用随机端口
const port = await getAvailablePort()

// 或关闭占用端口的进程
// lsof -ti:3000 | xargs kill -9

调试清单

markdown
- [ ] 查看截图和视频
- [ ] 查看 trace 文件
- [ ] 检查控制台日志
- [ ] 检查网络请求
- [ ] 验证选择器
- [ ] 检查测试数据
- [ ] 检查环境配置
- [ ] 检查超时设置

参考资源

官方文档

学习资源

工具和库