Skip to content

E2E 测试

端到端测试(End-to-End Testing)是前端质量保障体系中置信度最高的测试手段。它站在真实用户的视角,启动完整的浏览器环境,模拟点击、输入、导航等操作,验证从前端 UI 到后端 API 再到数据库的完整业务链路是否正常工作。

本文将从核心概念 → Playwright 深度剖析 → Cypress 对比 → 实战案例 → 最佳实践 → 面试高频题六个维度全面拆解 E2E 测试。


一、E2E 测试概念

1.1 什么是 E2E 测试

E2E 测试模拟真实用户在浏览器中的操作行为,端到端验证完整功能流程。它不关心内部实现细节,只关心:用户操作后,界面是否呈现了正确的结果

E2E 测试的"端到端"含义:

用户操作端                                              服务端
    |                                                     |
    |  ┌─────────────────────────────────────────────┐   |
    |  │              E2E 测试覆盖范围                  │   |
    |  │                                               │   |
    |  │  浏览器 UI  →  前端逻辑  →  HTTP 请求  →  API  │   |
    |  │      ↑                                    |    │   |
    |  │      └──────── 响应渲染 ←── 数据库查询 ←──┘    │   |
    |  │                                               │   |
    |  └─────────────────────────────────────────────┘   |
    |                                                     |

与单元测试的本质区别:
- 单元测试:隔离测试一个函数/组件,Mock 掉外部依赖
- E2E 测试:不 Mock 任何东西(或只 Mock 第三方服务),真实走通全流程

1.2 测试金字塔中的位置

测试金字塔(Test Pyramid)由 Mike Cohn 在《Succeeding with Agile》中提出,是测试策略的经典模型:

                        /\
                       /  \
                      / E2E \           数量:少(5~15%)
                     / Tests \          速度:慢(秒~分钟级)
                    /──────────\         成本:高(环境搭建、维护)
                   /            \        信心:最高 ✓
                  /  Integration  \      数量:适中(20~30%)
                 /     Tests      \     速度:中等(百毫秒~秒级)
                /──────────────────\    成本:中等
               /                    \
              /     Unit  Tests      \  数量:多(60~70%)
             /                        \ 速度:极快(毫秒级)
            /──────────────────────────\成本:低

核心原则:越往上,测试数量越少,但每个测试提供的信心越大

不同层级测试发现的问题类型:

┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  E2E 测试能发现的问题:                                        │
│  ✓ 页面间导航是否正确                                          │
│  ✓ 表单提交后数据是否真正保存                                    │
│  ✓ 登录后 Token 是否正确携带                                    │
│  ✓ 多步骤流程(购物车 → 结算 → 支付)是否走通                     │
│  ✓ 前后端数据格式是否一致                                       │
│  ✓ 跨浏览器兼容性问题                                          │
│                                                              │
│  E2E 测试不适合发现的问题:                                      │
│  ✗ 某个工具函数的边界值处理                                      │
│  ✗ 组件 Props 的各种排列组合                                    │
│  ✗ CSS 样式的精确像素值                                         │
│                                                              │
└──────────────────────────────────────────────────────────────┘

1.3 何时需要 E2E 测试

并非所有功能都需要 E2E 测试。E2E 测试成本高、运行慢,应聚焦在关键用户路径(Critical User Journeys)上:

场景是否需要 E2E原因
注册 / 登录流程✅ 强烈推荐核心入口,出错影响所有用户
支付 / 结算流程✅ 强烈推荐涉及金钱,不可出错
核心业务 CRUD✅ 推荐产品核心价值,必须保证可用
权限控制流程✅ 推荐安全相关,出错后果严重
多步骤表单向导✅ 推荐步骤间状态传递容易出问题
组件库内部交互❌ 不推荐用组件测试(Testing Library)更合适
工具函数逻辑❌ 不推荐用单元测试更高效
纯样式验证⚠️ 视情况可用视觉回归测试替代

1.4 E2E 测试的挑战

E2E 测试面临的核心挑战:

┌─────────────────────────────────────────────────┐
│                                                 │
│  1. 速度慢                                       │
│     真实浏览器启动 + 页面渲染 + 网络请求           │
│     一个 E2E 用例可能需要 5~30 秒                  │
│                                                 │
│  2. 不稳定(Flaky)                               │
│     网络波动、动画延迟、异步渲染                    │
│     同一用例偶尔通过偶尔失败                        │
│                                                 │
│  3. 环境依赖                                      │
│     需要后端服务、数据库、第三方 API               │
│     环境不一致导致测试失败                          │
│                                                 │
│  4. 维护成本高                                    │
│     UI 改版 → 定位器失效 → 大量用例需要修改        │
│                                                 │
│  5. 调试困难                                      │
│     失败原因可能在前端、后端、网络的任何环节        │
│                                                 │
└─────────────────────────────────────────────────┘

二、Playwright 深度剖析

2.1 Playwright 是什么

Playwright 是微软开源的浏览器自动化测试框架,于 2020 年发布。它的核心团队成员大多来自 Google 的 Puppeteer 团队,因此 Playwright 可以看作是 Puppeteer 的"进化版"。

Playwright 架构:

┌─────────────────────────────────────────────────────────┐
│                    Playwright Test Runner                 │
│                   (Node.js 进程)                          │
│                                                          │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐              │
│  │ Test File │  │ Test File │  │ Test File │   ...       │
│  │  spec.ts  │  │  spec.ts  │  │  spec.ts  │              │
│  └─────┬─────┘  └─────┬─────┘  └─────┬─────┘              │
│        │               │               │                  │
│        └───────────────┼───────────────┘                  │
│                        ↓                                  │
│           ┌────────────────────────┐                      │
│           │   Playwright Core API   │                      │
│           │   (自动等待、定位器...)    │                      │
│           └──────────┬─────────────┘                      │
│                      │ WebSocket (CDP / 自有协议)          │
│  ┌───────────────────┼──────────────────────────────┐    │
│  │                   ↓                               │    │
│  │  ┌──────────┐ ┌──────────┐ ┌──────────┐         │    │
│  │  │ Chromium  │ │ Firefox  │ │  WebKit  │         │    │
│  │  │ (Chrome)  │ │          │ │ (Safari) │         │    │
│  │  └──────────┘ └──────────┘ └──────────┘         │    │
│  │              浏览器进程池                           │    │
│  └──────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘

2.2 核心优势

特性说明
多浏览器支持Chromium、Firefox、WebKit 三大引擎,一套代码跑三个浏览器
自动等待操作前自动等待元素可见、可点击、可编辑,无需手动 sleep
网络拦截内置 page.route() 拦截请求,可 Mock API 返回值
代码生成器npx playwright codegen 录制操作自动生成测试代码
Trace Viewer时间旅行调试器,可逐步回放测试执行过程
并行执行原生支持多 Worker 并行执行测试,大幅缩短 CI 时间
多语言支持TypeScript/JavaScript、Python、Java、.NET
移动端模拟内置设备列表,可模拟 iPhone、Pixel 等设备的视口和 UA

2.3 安装与配置

bash
npm init playwright@latest

执行后会自动创建项目结构:

project/
├── tests/
│   └── example.spec.ts
├── tests-results/
├── playwright.config.ts
└── package.json

playwright.config.ts 核心配置:

typescript
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

配置项详解:

playwright.config.ts 核心配置项:

┌──────────────────────────────────────────────────────────┐
│  testDir          测试文件目录                              │
│  fullyParallel    是否完全并行执行(文件级 + 用例级)         │
│  forbidOnly       CI 环境禁止 .only(防止遗漏测试)          │
│  retries          失败重试次数(CI 中推荐 1~2 次)           │
│  workers          并行 Worker 数量                         │
│  reporter         报告格式(html / json / list / dot)      │
│                                                          │
│  use:                                                    │
│    baseURL        所有 page.goto() 的基准 URL              │
│    trace          Trace 收集策略                           │
│    screenshot     截图策略                                 │
│    video          录像策略                                 │
│                                                          │
│  projects         多浏览器/多设备配置                       │
│  webServer        测试前自动启动开发服务器                   │
└──────────────────────────────────────────────────────────┘

2.4 核心 API

2.4.1 页面导航

typescript
import { test, expect } from '@playwright/test'

test('页面导航', async ({ page }) => {
  await page.goto('/')

  await page.goto('/about')

  await page.goBack()

  await page.goForward()

  await page.reload()
})

2.4.2 元素交互

typescript
test('元素交互', async ({ page }) => {
  await page.goto('/form')

  await page.click('button#submit')

  await page.dblclick('.item')

  await page.fill('input[name="username"]', 'testuser')

  await page.type('input[name="search"]', 'playwright', { delay: 100 })

  await page.check('input[type="checkbox"]')

  await page.uncheck('input[type="checkbox"]')

  await page.selectOption('select#country', 'china')

  await page.hover('.tooltip-trigger')

  await page.focus('input[name="email"]')
})

2.4.3 键盘与鼠标

typescript
test('键盘操作', async ({ page }) => {
  await page.goto('/editor')

  await page.keyboard.press('Enter')
  await page.keyboard.press('Control+A')
  await page.keyboard.press('Control+C')
  await page.keyboard.press('Control+V')

  await page.keyboard.type('Hello World')

  await page.keyboard.down('Shift')
  await page.keyboard.press('ArrowRight')
  await page.keyboard.press('ArrowRight')
  await page.keyboard.up('Shift')
})

test('鼠标操作', async ({ page }) => {
  await page.mouse.move(100, 200)
  await page.mouse.click(100, 200)
  await page.mouse.dblclick(100, 200)

  await page.mouse.down()
  await page.mouse.move(300, 400)
  await page.mouse.up()
})

2.5 定位器(Locator)

定位器是 Playwright 最核心的概念之一。Locator 代表一种在页面上查找元素的方式,它是惰性的——创建 Locator 不会立即查找元素,只有在执行操作时才会查找。

Locator 的生命周期:

┌─────────────┐     ┌───────────────┐     ┌──────────────┐
│ 创建 Locator │ ──→ │  执行操作/断言  │ ──→ │  查找元素     │
│ (不查找元素) │     │  click/fill/.. │     │  自动等待     │
│              │     │               │     │  自动重试     │
└─────────────┘     └───────────────┘     └──────────────┘

                                          ┌──────┴──────┐
                                          │ 找到 → 执行  │
                                          │ 超时 → 报错  │
                                          └─────────────┘

2.5.1 推荐的定位方式(按优先级)

typescript
test('定位器优先级', async ({ page }) => {
  await page.goto('/dashboard')

  await page.getByRole('button', { name: '提交' }).click()

  await page.getByLabel('用户名').fill('testuser')

  await page.getByPlaceholder('请输入搜索关键词').fill('playwright')

  await page.getByText('欢迎回来').isVisible()

  await page.getByAltText('用户头像').click()

  await page.getByTitle('设置').click()

  await page.getByTestId('submit-btn').click()
})

定位器选择优先级:

优先级定位方式说明稳定性
1getByRole基于 ARIA 角色,最接近用户感知⭐⭐⭐⭐⭐
2getByLabel基于 label 关联,适合表单元素⭐⭐⭐⭐⭐
3getByPlaceholder基于 placeholder 文本⭐⭐⭐⭐
4getByText基于可见文本⭐⭐⭐⭐
5getByTestId基于 data-testid 属性⭐⭐⭐⭐
6locator('css')CSS 选择器⭐⭐⭐
7locator('xpath')XPath 表达式⭐⭐
为什么 getByRole 是首选?

用户视角:    "我要点击「提交」按钮"
getByRole:  page.getByRole('button', { name: '提交' })    ← 最接近用户意图
getByTestId:page.getByTestId('submit-btn')                ← 需要额外属性
CSS 选择器:  page.locator('.form > .actions > button.primary') ← 依赖 DOM 结构

当 UI 重构(改 class、调整 DOM 层级)时:
- getByRole    → 不受影响(只要还是按钮且文字不变)✓
- getByTestId  → 不受影响(只要 data-testid 不变)✓
- CSS 选择器   → 很可能失效 ✗

2.5.2 Locator 链式过滤

typescript
test('Locator 链式过滤', async ({ page }) => {
  await page.goto('/products')

  const productCard = page.locator('.product-card').filter({
    hasText: 'MacBook Pro'
  })

  await productCard.getByRole('button', { name: '加入购物车' }).click()

  const row = page.getByRole('row').filter({
    has: page.getByText('Alice')
  })
  await row.getByRole('button', { name: '编辑' }).click()

  const items = page.locator('.list-item')
  const thirdItem = items.nth(2)
  const firstItem = items.first()
  const lastItem = items.last()
})

2.6 断言系统

Playwright 的断言基于 expect 并内置自动重试机制——断言会持续重试直到条件满足或超时。

普通断言 vs Playwright 自动重试断言:

普通断言(立即判断):
  expect(value).toBe(5)         ← 失败就立即报错

Playwright Web 断言(自动重试):
  await expect(locator).toBeVisible()
      │                            │
      │   ┌────────────────────┐   │
      │   │ 检查元素是否可见     │   │
      │   │   ↓                │   │
      │   │ 不可见?等 100ms    │   │
      │   │   ↓                │   │
      │   │ 再检查...           │   │
      │   │   ↓                │   │
      │   │ 循环直到可见或超时   │   │
      │   └────────────────────┘   │
      │                            │
      默认超时:5 秒

2.6.1 常用断言

typescript
test('断言示例', async ({ page }) => {
  await page.goto('/dashboard')

  await expect(page.getByText('欢迎')).toBeVisible()

  await expect(page.getByText('已删除的内容')).toBeHidden()

  await expect(page.getByRole('button', { name: '提交' })).toBeEnabled()

  await expect(page.getByRole('button', { name: '请稍候' })).toBeDisabled()

  await expect(page.getByLabel('记住我')).toBeChecked()

  await expect(page.getByRole('heading')).toHaveText('仪表盘')

  await expect(page.getByRole('heading')).toContainText('仪表')

  await expect(page.locator('.item')).toHaveCount(5)

  await expect(page.locator('.active')).toHaveClass(/selected/)

  await expect(page.locator('.box')).toHaveCSS('color', 'rgb(255, 0, 0)')

  await expect(page.locator('input')).toHaveValue('test@example.com')

  await expect(page.locator('.list > li')).toHaveText([
    'Apple',
    'Banana',
    'Cherry',
  ])

  await expect(page).toHaveURL(/\/dashboard/)

  await expect(page).toHaveTitle('Dashboard - MyApp')
})

2.6.2 取反断言与软断言

typescript
test('取反断言', async ({ page }) => {
  await page.goto('/dashboard')
  await expect(page.getByText('错误')).not.toBeVisible()
  await expect(page.locator('.item')).not.toHaveCount(0)
})

test('软断言(不中断测试)', async ({ page }) => {
  await page.goto('/dashboard')

  await expect.soft(page.getByText('标题1')).toBeVisible()
  await expect.soft(page.getByText('标题2')).toBeVisible()
  await expect.soft(page.getByText('标题3')).toBeVisible()
})

2.7 等待机制

Playwright 的自动等待是其最强大的特性之一。在执行任何操作前,Playwright 会自动等待元素满足可操作性检查(Actionability Checks)。

page.click(selector) 的自动等待流程:

┌─────────────────────────────────────────────────┐
│           click() 自动等待检查链                   │
│                                                 │
│  1. 元素是否已附着到 DOM?                         │
│     └── 否 → 等待元素出现                          │
│     └── 是 ↓                                     │
│                                                 │
│  2. 元素是否可见?                                 │
│     └── 否 → 等待元素变为可见                      │
│     └── 是 ↓                                     │
│                                                 │
│  3. 元素是否稳定(无动画进行中)?                   │
│     └── 否 → 等待动画结束                          │
│     └── 是 ↓                                     │
│                                                 │
│  4. 元素是否可接收事件(未被遮挡)?                 │
│     └── 否 → 等待遮挡移除                          │
│     └── 是 ↓                                     │
│                                                 │
│  5. 元素是否已启用(非 disabled)?                  │
│     └── 否 → 等待元素启用                          │
│     └── 是 ↓                                     │
│                                                 │
│  ✓ 所有检查通过 → 执行 click                       │
│                                                 │
│  ✗ 超时(默认 30s)→ 抛出 TimeoutError              │
└─────────────────────────────────────────────────┘

不同操作的等待检查项:

操作AttachedVisibleStableEnabledReceives Events
click
fill--
check
selectOption--
hover-
screenshot--

2.7.1 显式等待

虽然大多数场景自动等待就够了,但有时仍需要显式等待:

typescript
test('显式等待', async ({ page }) => {
  await page.goto('/dashboard')

  await page.waitForSelector('.data-table', { state: 'visible' })

  await page.waitForURL('**/dashboard')

  const response = await page.waitForResponse(
    resp => resp.url().includes('/api/data') && resp.status() === 200
  )

  await page.waitForLoadState('networkidle')

  await page.waitForFunction(() => {
    return document.querySelectorAll('.item').length >= 10
  })

  await page.waitForTimeout(1000)
})

2.8 网络拦截

page.route() 允许拦截和修改网络请求,这对于 API Mock 和测试边界条件非常有用。

page.route() 工作原理:

┌──────────────────────────────────────────────────────────┐
│                     浏览器发起请求                          │
│                          │                                │
│                    ┌─────┴─────┐                          │
│                    │ 匹配路由?  │                          │
│                    └─────┬─────┘                          │
│                    /           \                           │
│                 是 /             \ 否                       │
│                  /               \                         │
│        ┌────────┴────────┐   ┌───┴───────────┐           │
│        │  执行路由处理函数  │   │ 正常发送到服务器 │           │
│        │                  │   │               │           │
│        │  可选操作:       │   └───────────────┘           │
│        │  • fulfill 直接返回│                              │
│        │  • abort 中止请求  │                              │
│        │  • continue 修改后│                              │
│        │    继续发送       │                               │
│        └─────────────────┘                                │
└──────────────────────────────────────────────────────────┘
typescript
test('API Mock', async ({ page }) => {
  await page.route('**/api/users', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Alice', email: 'alice@test.com' },
        { id: 2, name: 'Bob', email: 'bob@test.com' },
      ]),
    })
  })

  await page.goto('/users')
  await expect(page.getByText('Alice')).toBeVisible()
  await expect(page.getByText('Bob')).toBeVisible()
})

test('模拟网络错误', async ({ page }) => {
  await page.route('**/api/data', async route => {
    await route.fulfill({
      status: 500,
      body: JSON.stringify({ error: 'Internal Server Error' }),
    })
  })

  await page.goto('/dashboard')
  await expect(page.getByText('加载失败')).toBeVisible()
})

test('模拟网络离线', async ({ page }) => {
  await page.route('**/api/**', route => route.abort())

  await page.goto('/dashboard')
  await expect(page.getByText('网络连接失败')).toBeVisible()
})

test('修改请求并继续', async ({ page }) => {
  await page.route('**/api/users', async route => {
    const headers = {
      ...route.request().headers(),
      'X-Custom-Header': 'test-value',
    }
    await route.continue({ headers })
  })

  await page.goto('/users')
})

test('拦截响应并修改', async ({ page }) => {
  await page.route('**/api/config', async route => {
    const response = await route.fetch()
    const json = await response.json()
    json.featureFlag = true
    await route.fulfill({
      response,
      body: JSON.stringify(json),
    })
  })

  await page.goto('/settings')
})

2.9 截图与录像

typescript
test('截图', async ({ page }) => {
  await page.goto('/dashboard')

  await page.screenshot({ path: 'screenshots/full-page.png', fullPage: true })

  await page.locator('.chart-container').screenshot({
    path: 'screenshots/chart.png',
  })
})

test('视觉回归测试', async ({ page }) => {
  await page.goto('/dashboard')
  await expect(page).toHaveScreenshot('dashboard.png', {
    maxDiffPixelRatio: 0.01,
  })

  await expect(page.locator('.sidebar')).toHaveScreenshot('sidebar.png')
})

录像配置(在 playwright.config.ts 中):

typescript
export default defineConfig({
  use: {
    video: 'on-first-retry',
  },
})

video 选项:

选项说明
'off'不录像
'on'每个测试都录像
'retain-on-failure'全部录像,但只保留失败的
'on-first-retry'仅第一次重试时录像

2.10 并行执行与分片

Playwright 并行执行模型:

┌───────────────────────────────────────────────────────┐
│                 Playwright Test Runner                  │
│                                                        │
│  ┌─────────────────────────────────────────────────┐  │
│  │              文件级并行分配                        │  │
│  │                                                  │  │
│  │  spec-1.ts  spec-2.ts  spec-3.ts  spec-4.ts     │  │
│  │     │          │          │          │           │  │
│  │     ↓          ↓          ↓          ↓           │  │
│  │  Worker 1   Worker 2   Worker 3   Worker 4      │  │
│  │  (进程 1)   (进程 2)   (进程 3)   (进程 4)       │  │
│  │     │          │          │          │           │  │
│  │     ↓          ↓          ↓          ↓           │  │
│  │  Browser    Browser    Browser    Browser        │  │
│  │  Context    Context    Context    Context        │  │
│  └─────────────────────────────────────────────────┘  │
│                                                        │
│  每个 Worker 拥有独立的浏览器上下文,彼此完全隔离        │
└───────────────────────────────────────────────────────┘
typescript
export default defineConfig({
  workers: process.env.CI ? 4 : undefined,
  fullyParallel: true,
})

CI 中的分片(Sharding)——将测试分散到多台机器执行:

bash
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4
分片执行示意图(4 台 CI 机器):

Machine 1 (shard 1/4):  [spec-01, spec-02, spec-03]  ──→  结果
Machine 2 (shard 2/4):  [spec-04, spec-05, spec-06]  ──→  结果
Machine 3 (shard 3/4):  [spec-07, spec-08, spec-09]  ──→  结果
Machine 4 (shard 4/4):  [spec-10, spec-11, spec-12]  ──→  结果

                              ← ── ── 合并报告 ── ── ── ── ┘

2.11 浏览器上下文与多标签页

typescript
test('多标签页操作', async ({ context, page }) => {
  await page.goto('/dashboard')

  const [newPage] = await Promise.all([
    context.waitForEvent('page'),
    page.click('a[target="_blank"]'),
  ])

  await newPage.waitForLoadState()
  await expect(newPage).toHaveTitle(/详情/)

  await newPage.close()
})

test('隔离的浏览器上下文', async ({ browser }) => {
  const userContext = await browser.newContext({
    storageState: 'auth/user.json',
  })
  const adminContext = await browser.newContext({
    storageState: 'auth/admin.json',
  })

  const userPage = await userContext.newPage()
  const adminPage = await adminContext.newPage()

  await userPage.goto('/dashboard')
  await adminPage.goto('/admin')

  await expect(userPage.getByText('管理后台')).not.toBeVisible()
  await expect(adminPage.getByText('管理后台')).toBeVisible()

  await userContext.close()
  await adminContext.close()
})

2.12 文件上传与下载

typescript
test('文件上传', async ({ page }) => {
  await page.goto('/upload')

  await page.setInputFiles('input[type="file"]', 'test-data/photo.png')

  await page.setInputFiles('input[type="file"]', [
    'test-data/photo1.png',
    'test-data/photo2.png',
  ])

  await page.setInputFiles('input[type="file"]', [])
})

test('文件下载', async ({ page }) => {
  await page.goto('/reports')

  const [download] = await Promise.all([
    page.waitForEvent('download'),
    page.click('button#export-csv'),
  ])

  const suggestedFilename = download.suggestedFilename()
  await download.saveAs(`downloads/${suggestedFilename}`)
})

2.13 拖放操作

typescript
test('拖放操作', async ({ page }) => {
  await page.goto('/kanban')

  await page.locator('.task-card').first().dragTo(
    page.locator('.column-done')
  )

  await page.locator('.task-card').first().hover()
  await page.mouse.down()
  await page.locator('.column-done').hover()
  await page.mouse.up()
})

三、Cypress 对比了解

3.1 Cypress 核心特性

Cypress 是另一个流行的 E2E 测试框架,诞生于 2014 年,比 Playwright 更早。它的核心理念是让测试编写变得简单、调试变得直观

Cypress 独特的运行架构:

┌─────────────────────────────────────────────────────┐
│                     浏览器进程                        │
│                                                     │
│  ┌───────────────────┐  ┌───────────────────────┐  │
│  │    应用 iframe      │  │   Cypress 命令 iframe  │  │
│  │                    │  │                        │  │
│  │  http://localhost  │  │  cy.get('.btn')        │  │
│  │  :3000             │  │  cy.click()            │  │
│  │                    │  │  cy.type('hello')      │  │
│  │  ┌──────────────┐ │  │                        │  │
│  │  │  Your App    │ │  │  Cypress 与你的应用    │  │
│  │  │              │ │  │  运行在同一个浏览器中    │  │
│  │  └──────────────┘ │  │  可以直接访问 DOM       │  │
│  └───────────────────┘  └───────────────────────┘  │
│                                                     │
│          ← 同源,直接 DOM 操作 →                     │
└─────────────────────────────────────────────────────┘

Playwright 架构对比:

┌──────────────────┐         ┌──────────────────┐
│   Node.js 进程    │ ──CDP──→ │   浏览器进程      │
│                  │ WebSocket│                   │
│  测试代码运行在   │ ←────── │   应用运行在       │
│  Node.js 中      │         │   浏览器中         │
└──────────────────┘         └──────────────────┘
     进程外控制                    被控制端

Cypress 的这种架构带来了独特优势,但也有明显局限。

3.2 Cypress 基本用法

typescript
describe('登录流程', () => {
  beforeEach(() => {
    cy.visit('/login')
  })

  it('成功登录', () => {
    cy.get('[name="email"]').type('alice@test.com')
    cy.get('[name="password"]').type('password123')
    cy.get('button[type="submit"]').click()
    cy.url().should('include', '/dashboard')
    cy.contains('Welcome, Alice').should('be.visible')
  })

  it('登录失败显示错误', () => {
    cy.get('[name="email"]').type('wrong@test.com')
    cy.get('[name="password"]').type('wrong')
    cy.get('button[type="submit"]').click()
    cy.contains('邮箱或密码错误').should('be.visible')
  })
})

3.3 Playwright vs Cypress 全面对比

维度PlaywrightCypress
浏览器支持Chromium + Firefox + WebKitChromium + Firefox + WebKit(v13+)
运行架构Node.js 进程外控制浏览器运行在浏览器内部
多标签页✅ 原生支持❌ 不支持
多域名✅ 原生支持⚠️ cy.origin() 部分支持
iframe✅ 原生支持⚠️ 需要插件
语言支持JS/TS、Python、Java、.NET仅 JS/TS
并行执行✅ 内置 Workers⚠️ 需要 Cypress Cloud(付费)
自动等待✅ 操作级自动等待✅ 命令级自动重试
网络拦截✅ page.route()✅ cy.intercept()
API 风格async/await链式调用(类 jQuery)
调试体验Trace Viewer(时间旅行)时间旅行 + 实时 DOM 快照
代码生成✅ codegen⚠️ Cypress Studio(实验性)
组件测试⚠️ 实验性✅ 原生支持
CI 集成免费,内置报告基础免费,高级功能需付费
执行速度⚡ 快(进程外+并行)🔶 中等
社区生态快速增长成熟,插件丰富
学习曲线中等(需理解 async/await)低(链式 API 直觉化)

3.4 如何选择

选择 Playwright 的场景:

✓ 需要跨浏览器测试(尤其是 Safari/WebKit)
✓ 涉及多标签页、多域名、iframe 场景
✓ 团队使用 Python/Java/.NET 等非 JS 语言
✓ 需要强大的并行能力降低 CI 时间
✓ 项目处于起步阶段,追求长期技术投资

选择 Cypress 的场景:

✓ 团队前端经验为主,偏好直觉化 API
✓ 需要强大的组件测试能力
✓ 重视实时开发调试体验
✓ 项目已有 Cypress 基础设施
✓ 不涉及多标签页/多域名等复杂场景

四、E2E 测试实战

4.1 登录流程测试

typescript
import { test, expect } from '@playwright/test'

test.describe('登录流程', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login')
  })

  test('使用有效凭据登录成功', async ({ page }) => {
    await page.getByLabel('邮箱').fill('alice@example.com')
    await page.getByLabel('密码').fill('password123')
    await page.getByRole('button', { name: '登录' }).click()

    await expect(page).toHaveURL('/dashboard')
    await expect(page.getByText('欢迎回来, Alice')).toBeVisible()
  })

  test('使用无效凭据显示错误信息', async ({ page }) => {
    await page.getByLabel('邮箱').fill('wrong@example.com')
    await page.getByLabel('密码').fill('wrongpassword')
    await page.getByRole('button', { name: '登录' }).click()

    await expect(page.getByText('邮箱或密码错误')).toBeVisible()
    await expect(page).toHaveURL('/login')
  })

  test('空表单提交显示验证错误', async ({ page }) => {
    await page.getByRole('button', { name: '登录' }).click()

    await expect(page.getByText('请输入邮箱')).toBeVisible()
    await expect(page.getByText('请输入密码')).toBeVisible()
  })

  test('记住我功能', async ({ page, context }) => {
    await page.getByLabel('邮箱').fill('alice@example.com')
    await page.getByLabel('密码').fill('password123')
    await page.getByLabel('记住我').check()
    await page.getByRole('button', { name: '登录' }).click()

    await expect(page).toHaveURL('/dashboard')

    const cookies = await context.cookies()
    const rememberCookie = cookies.find(c => c.name === 'remember_token')
    expect(rememberCookie).toBeTruthy()
    expect(rememberCookie!.expires).toBeGreaterThan(Date.now() / 1000)
  })

  test('登录后重定向到原始页面', async ({ page }) => {
    await page.goto('/profile')
    await expect(page).toHaveURL(/\/login\?redirect=/)

    await page.getByLabel('邮箱').fill('alice@example.com')
    await page.getByLabel('密码').fill('password123')
    await page.getByRole('button', { name: '登录' }).click()

    await expect(page).toHaveURL('/profile')
  })
})

4.2 表单提交测试

typescript
test.describe('用户注册表单', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/register')
  })

  test('成功注册新用户', async ({ page }) => {
    await page.getByLabel('用户名').fill('newuser')
    await page.getByLabel('邮箱').fill('newuser@example.com')
    await page.getByLabel('密码').fill('StrongPass123!')
    await page.getByLabel('确认密码').fill('StrongPass123!')
    await page.getByLabel('我已阅读并同意服务条款').check()
    await page.getByRole('button', { name: '注册' }).click()

    await expect(page).toHaveURL('/welcome')
    await expect(page.getByText('注册成功')).toBeVisible()
  })

  test('密码强度验证', async ({ page }) => {
    const passwordInput = page.getByLabel('密码')
    const strengthIndicator = page.locator('.password-strength')

    await passwordInput.fill('123')
    await expect(strengthIndicator).toHaveText('弱')

    await passwordInput.fill('abc12345')
    await expect(strengthIndicator).toHaveText('中')

    await passwordInput.fill('Str0ng!Pass#2024')
    await expect(strengthIndicator).toHaveText('强')
  })

  test('邮箱唯一性检查', async ({ page }) => {
    await page.route('**/api/check-email', async route => {
      const body = route.request().postDataJSON()
      await route.fulfill({
        status: 200,
        body: JSON.stringify({
          available: body.email !== 'taken@example.com',
        }),
      })
    })

    await page.getByLabel('邮箱').fill('taken@example.com')
    await page.getByLabel('邮箱').blur()
    await expect(page.getByText('该邮箱已被注册')).toBeVisible()

    await page.getByLabel('邮箱').fill('fresh@example.com')
    await page.getByLabel('邮箱').blur()
    await expect(page.getByText('该邮箱已被注册')).not.toBeVisible()
  })
})

4.3 列表操作测试(CRUD)

typescript
test.describe('任务管理 CRUD', () => {
  test.beforeEach(async ({ page }) => {
    await page.route('**/api/tasks', async route => {
      if (route.request().method() === 'GET') {
        await route.fulfill({
          body: JSON.stringify([
            { id: 1, title: '完成设计稿', done: false },
            { id: 2, title: '编写测试', done: true },
            { id: 3, title: '代码评审', done: false },
          ]),
        })
      }
    })

    await page.goto('/tasks')
  })

  test('展示任务列表', async ({ page }) => {
    await expect(page.getByRole('listitem')).toHaveCount(3)
    await expect(page.getByText('完成设计稿')).toBeVisible()
    await expect(page.getByText('编写测试')).toBeVisible()
    await expect(page.getByText('代码评审')).toBeVisible()
  })

  test('创建新任务', async ({ page }) => {
    await page.route('**/api/tasks', async route => {
      if (route.request().method() === 'POST') {
        const body = route.request().postDataJSON()
        await route.fulfill({
          status: 201,
          body: JSON.stringify({ id: 4, title: body.title, done: false }),
        })
      } else {
        await route.continue()
      }
    })

    await page.getByPlaceholder('添加新任务').fill('部署上线')
    await page.getByRole('button', { name: '添加' }).click()

    await expect(page.getByText('部署上线')).toBeVisible()
  })

  test('编辑任务', async ({ page }) => {
    await page.route('**/api/tasks/1', async route => {
      if (route.request().method() === 'PUT') {
        const body = route.request().postDataJSON()
        await route.fulfill({
          body: JSON.stringify({ ...body, id: 1 }),
        })
      }
    })

    const firstTask = page.getByRole('listitem').filter({ hasText: '完成设计稿' })
    await firstTask.getByRole('button', { name: '编辑' }).click()

    const editInput = firstTask.getByRole('textbox')
    await editInput.clear()
    await editInput.fill('完成设计稿 v2')
    await firstTask.getByRole('button', { name: '保存' }).click()

    await expect(page.getByText('完成设计稿 v2')).toBeVisible()
  })

  test('删除任务', async ({ page }) => {
    await page.route('**/api/tasks/1', async route => {
      if (route.request().method() === 'DELETE') {
        await route.fulfill({ status: 204 })
      }
    })

    const firstTask = page.getByRole('listitem').filter({ hasText: '完成设计稿' })
    await firstTask.getByRole('button', { name: '删除' }).click()

    await page.getByRole('button', { name: '确认删除' }).click()

    await expect(page.getByText('完成设计稿')).not.toBeVisible()
    await expect(page.getByRole('listitem')).toHaveCount(2)
  })

  test('切换任务完成状态', async ({ page }) => {
    await page.route('**/api/tasks/1', async route => {
      if (route.request().method() === 'PATCH') {
        await route.fulfill({
          body: JSON.stringify({ id: 1, title: '完成设计稿', done: true }),
        })
      }
    })

    const firstTask = page.getByRole('listitem').filter({ hasText: '完成设计稿' })
    await firstTask.getByRole('checkbox').check()

    await expect(firstTask).toHaveClass(/completed/)
  })
})

4.4 API Mock 进阶

typescript
test.describe('API Mock 进阶技巧', () => {
  test('监听请求并验证请求体', async ({ page }) => {
    let capturedRequest: any = null

    await page.route('**/api/orders', async route => {
      capturedRequest = route.request().postDataJSON()
      await route.fulfill({
        status: 201,
        body: JSON.stringify({ id: 'order-001', status: 'created' }),
      })
    })

    await page.goto('/checkout')
    await page.getByRole('button', { name: '提交订单' }).click()

    expect(capturedRequest).toEqual(
      expect.objectContaining({
        items: expect.any(Array),
        totalAmount: expect.any(Number),
      })
    )
  })

  test('模拟加载状态', async ({ page }) => {
    await page.route('**/api/data', async route => {
      await new Promise(resolve => setTimeout(resolve, 3000))
      await route.fulfill({
        body: JSON.stringify({ items: [] }),
      })
    })

    await page.goto('/dashboard')
    await expect(page.getByText('加载中...')).toBeVisible()
    await expect(page.getByText('加载中...')).not.toBeVisible({ timeout: 5000 })
  })

  test('模拟分页接口', async ({ page }) => {
    await page.route('**/api/products*', async route => {
      const url = new URL(route.request().url())
      const pageNum = parseInt(url.searchParams.get('page') || '1')
      const pageSize = 10

      const items = Array.from({ length: pageSize }, (_, i) => ({
        id: (pageNum - 1) * pageSize + i + 1,
        name: `商品 ${(pageNum - 1) * pageSize + i + 1}`,
      }))

      await route.fulfill({
        body: JSON.stringify({
          items,
          total: 50,
          page: pageNum,
          pageSize,
        }),
      })
    })

    await page.goto('/products')
    await expect(page.getByText('商品 1')).toBeVisible()

    await page.getByRole('button', { name: '下一页' }).click()
    await expect(page.getByText('商品 11')).toBeVisible()
  })
})

4.5 视觉回归测试

视觉回归测试工作原理:

第一次运行(生成基准截图):

  页面渲染 ──→ 截图 ──→ 保存为基准图(Golden Image)
                         screenshots/dashboard.png

后续运行(对比截图):

  页面渲染 ──→ 截图 ──→ 与基准图对比

                    ┌─────────┴─────────┐
                    │                    │
                差异 < 阈值           差异 > 阈值
                    │                    │
                  ✓ 通过              ✗ 失败

                              生成差异图(diff image)
                              标记出像素差异区域
typescript
test.describe('视觉回归测试', () => {
  test('首页整体截图', async ({ page }) => {
    await page.goto('/')
    await page.waitForLoadState('networkidle')
    await expect(page).toHaveScreenshot('homepage.png', {
      fullPage: true,
      maxDiffPixelRatio: 0.01,
    })
  })

  test('深色模式截图对比', async ({ page }) => {
    await page.goto('/')
    await expect(page).toHaveScreenshot('homepage-light.png')

    await page.getByRole('button', { name: '切换主题' }).click()
    await expect(page).toHaveScreenshot('homepage-dark.png')
  })

  test('响应式布局截图', async ({ page }) => {
    await page.goto('/dashboard')

    await page.setViewportSize({ width: 1920, height: 1080 })
    await expect(page).toHaveScreenshot('dashboard-desktop.png')

    await page.setViewportSize({ width: 768, height: 1024 })
    await expect(page).toHaveScreenshot('dashboard-tablet.png')

    await page.setViewportSize({ width: 375, height: 667 })
    await expect(page).toHaveScreenshot('dashboard-mobile.png')
  })

  test('忽略动态区域', async ({ page }) => {
    await page.goto('/dashboard')
    await expect(page).toHaveScreenshot('dashboard-stable.png', {
      mask: [
        page.locator('.timestamp'),
        page.locator('.random-avatar'),
      ],
    })
  })
})

五、E2E 测试最佳实践

5.1 Page Object Model(POM)模式

POM 是 E2E 测试中最经典的设计模式,它将页面的定位逻辑操作逻辑封装到独立的类中,测试用例只调用高层语义方法。

不使用 POM vs 使用 POM:

不使用 POM(定位逻辑散落在测试中):

  test('登录', async ({ page }) => {
    await page.goto('/login')
    await page.fill('#email', 'alice@test.com')       ← 定位逻辑
    await page.fill('#password', 'pass123')            ← 定位逻辑
    await page.click('button.submit-btn')              ← 定位逻辑
    await expect(page).toHaveURL('/dashboard')
  })

  test('修改密码', async ({ page }) => {
    await page.goto('/login')
    await page.fill('#email', 'alice@test.com')       ← 重复!
    await page.fill('#password', 'pass123')            ← 重复!
    await page.click('button.submit-btn')              ← 重复!
    ...
  })

使用 POM(封装页面操作):

  test('登录', async ({ page }) => {
    const loginPage = new LoginPage(page)
    await loginPage.goto()
    await loginPage.login('alice@test.com', 'pass123')  ← 语义化
    await loginPage.expectSuccess()
  })

  UI 改版时:只需修改 LoginPage 类,所有用例自动生效

完整的 POM 实现:

typescript
import { type Page, type Locator, expect } from '@playwright/test'

class LoginPage {
  readonly page: Page
  readonly emailInput: Locator
  readonly passwordInput: Locator
  readonly submitButton: Locator
  readonly rememberCheckbox: Locator
  readonly errorMessage: Locator

  constructor(page: Page) {
    this.page = page
    this.emailInput = page.getByLabel('邮箱')
    this.passwordInput = page.getByLabel('密码')
    this.submitButton = page.getByRole('button', { name: '登录' })
    this.rememberCheckbox = page.getByLabel('记住我')
    this.errorMessage = page.locator('.error-message')
  }

  async goto() {
    await this.page.goto('/login')
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.submitButton.click()
  }

  async loginWithRemember(email: string, password: string) {
    await this.login(email, password)
    await this.rememberCheckbox.check()
    await this.submitButton.click()
  }

  async expectSuccess() {
    await expect(this.page).toHaveURL('/dashboard')
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toHaveText(message)
  }
}

class DashboardPage {
  readonly page: Page
  readonly welcomeText: Locator
  readonly logoutButton: Locator
  readonly sidebar: Locator

  constructor(page: Page) {
    this.page = page
    this.welcomeText = page.locator('.welcome-text')
    this.logoutButton = page.getByRole('button', { name: '退出登录' })
    this.sidebar = page.locator('.sidebar')
  }

  async goto() {
    await this.page.goto('/dashboard')
  }

  async logout() {
    await this.logoutButton.click()
  }

  async expectWelcome(name: string) {
    await expect(this.welcomeText).toContainText(name)
  }
}

在测试中使用 POM:

typescript
import { test, expect } from '@playwright/test'

test.describe('用户认证流程', () => {
  test('登录 → 查看仪表盘 → 退出', async ({ page }) => {
    const loginPage = new LoginPage(page)
    const dashboard = new DashboardPage(page)

    await loginPage.goto()
    await loginPage.login('alice@example.com', 'password123')
    await loginPage.expectSuccess()

    await dashboard.expectWelcome('Alice')

    await dashboard.logout()
    await expect(page).toHaveURL('/login')
  })
})

使用 Playwright Fixture 注入 POM:

typescript
import { test as base } from '@playwright/test'

type Pages = {
  loginPage: LoginPage
  dashboardPage: DashboardPage
}

const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page))
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page))
  },
})

test('登录流程', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto()
  await loginPage.login('alice@example.com', 'password123')
  await loginPage.expectSuccess()
  await dashboardPage.expectWelcome('Alice')
})

5.2 测试数据管理

测试数据策略:

┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  策略 1:API Mock(推荐用于大部分场景)                         │
│  ┌──────────────────────────────────────────┐               │
│  │  page.route('**/api/users', ...)          │               │
│  │  • 最稳定:不依赖后端                       │               │
│  │  • 最快:无真实网络请求                      │               │
│  │  • 缺点:无法验证真实 API 行为               │               │
│  └──────────────────────────────────────────┘               │
│                                                              │
│  策略 2:Seed Data(测试前通过 API 创建数据)                   │
│  ┌──────────────────────────────────────────┐               │
│  │  beforeAll:                                │               │
│  │    POST /api/test/seed                     │               │
│  │  afterAll:                                 │               │
│  │    POST /api/test/cleanup                  │               │
│  │  • 真实数据流                               │               │
│  │  • 需要后端提供测试 API                      │               │
│  └──────────────────────────────────────────┘               │
│                                                              │
│  策略 3:数据库快照(CI 环境推荐)                              │
│  ┌──────────────────────────────────────────┐               │
│  │  每次测试前恢复数据库到已知状态               │               │
│  │  • 完全可控                                 │               │
│  │  • 需要 Docker 或类似工具支持                │               │
│  └──────────────────────────────────────────┘               │
│                                                              │
└──────────────────────────────────────────────────────────────┘
typescript
import { test as setup } from '@playwright/test'

setup('创建测试数据', async ({ request }) => {
  await request.post('/api/test/seed', {
    data: {
      users: [
        { email: 'alice@test.com', password: 'pass123', role: 'admin' },
        { email: 'bob@test.com', password: 'pass123', role: 'user' },
      ],
      tasks: [
        { title: 'Task 1', assignee: 'alice@test.com' },
        { title: 'Task 2', assignee: 'bob@test.com' },
      ],
    },
  })
})

setup('清理测试数据', async ({ request }) => {
  await request.post('/api/test/cleanup')
})

5.3 认证状态复用

每个测试都重新走一遍登录流程太慢了。Playwright 支持保存和复用认证状态:

typescript
import { test as setup, expect } from '@playwright/test'

const authFile = 'playwright/.auth/user.json'

setup('authenticate', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('邮箱').fill('alice@example.com')
  await page.getByLabel('密码').fill('password123')
  await page.getByRole('button', { name: '登录' }).click()
  await page.waitForURL('/dashboard')

  await page.context().storageState({ path: authFile })
})

在配置文件中使用:

typescript
export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'chromium',
      dependencies: ['setup'],
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
    },
  ],
})
认证状态复用流程:

┌──────────────┐     ┌─────────────────┐     ┌──────────────────┐
│  Setup 项目   │     │ 保存 storageState│     │  测试项目使用      │
│              │     │                  │     │                   │
│  执行登录流程 │ ──→ │ cookies + storage│ ──→ │ 跳过登录,直接     │
│  (仅一次)   │     │ → auth.json     │     │ 进入已登录状态     │
└──────────────┘     └─────────────────┘     └──────────────────┘

时间节省:
  20 个测试 × 3 秒登录 = 60 秒 → 只需 3 秒(一次登录)

5.4 测试环境隔离

typescript
test.describe.configure({ mode: 'serial' })

test.describe('订单流程(串行执行)', () => {
  test('步骤1:添加商品到购物车', async ({ page }) => {
    await page.goto('/products')
    await page.getByText('MacBook Pro').click()
    await page.getByRole('button', { name: '加入购物车' }).click()
    await expect(page.locator('.cart-count')).toHaveText('1')
  })

  test('步骤2:进入结算页', async ({ page }) => {
    await page.goto('/cart')
    await page.getByRole('button', { name: '去结算' }).click()
    await expect(page).toHaveURL('/checkout')
  })
})

5.5 CI 集成(GitHub Actions)

yaml
name: E2E Tests
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  e2e-tests:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright Browsers
        run: npx playwright install --with-deps

      - name: Run E2E tests
        run: npx playwright test --shard=${{ matrix.shard }}

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report-${{ strategy.job-index }}
          path: playwright-report/
          retention-days: 30

      - name: Upload blob report
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: blob-report-${{ strategy.job-index }}
          path: blob-report/
          retention-days: 1

  merge-reports:
    if: ${{ !cancelled() }}
    needs: e2e-tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Install dependencies
        run: npm ci

      - name: Download blob reports
        uses: actions/download-artifact@v4
        with:
          path: all-blob-reports
          pattern: blob-report-*
          merge-multiple: true

      - name: Merge reports
        run: npx playwright merge-reports --reporter html ./all-blob-reports

      - name: Upload merged report
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30
CI 分片执行流程:

┌─────────────────────────────────────────────────────────┐
│                    GitHub Actions                         │
│                                                          │
│  Push / PR 触发                                          │
│        │                                                 │
│        ↓                                                 │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐  │
│  │ Shard 1/4│ │ Shard 2/4│ │ Shard 3/4│ │ Shard 4/4│  │
│  │          │ │          │ │          │ │          │  │
│  │ 25% 用例 │ │ 25% 用例 │ │ 25% 用例 │ │ 25% 用例 │  │
│  │          │ │          │ │          │ │          │  │
│  │ blob     │ │ blob     │ │ blob     │ │ blob     │  │
│  │ report   │ │ report   │ │ report   │ │ report   │  │
│  └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘  │
│       │            │            │            │         │
│       └────────────┴──────┬─────┴────────────┘         │
│                           ↓                             │
│                  ┌────────────────┐                     │
│                  │  合并测试报告    │                     │
│                  │  HTML Report   │                     │
│                  └────────────────┘                     │
└─────────────────────────────────────────────────────────┘

5.6 避免 Flaky Tests

Flaky Test 是 E2E 测试最大的敌人——同一个测试有时通过有时失败,严重侵蚀团队对测试的信任。

Flaky Test 的常见原因与解决方案:

原因                              解决方案
─────────────────────────────────────────────────────────────
硬编码 sleep/timeout              使用 Playwright 自动等待
  ↓                               ↓
  await page.waitForTimeout(3000) await expect(loc).toBeVisible()

依赖动画完成                       禁用动画
  ↓                               ↓
  动画中点击位置偏移                page.addStyleTag({
                                    content: '*, *::before, *::after {
                                      animation: none !important;
                                      transition: none !important;
                                    }'
                                  })

依赖外部 API                      Mock 外部依赖
  ↓                               ↓
  第三方 API 不稳定                page.route('**/external-api/**',
                                    route => route.fulfill({...}))

依赖执行顺序                       测试间完全隔离
  ↓                               ↓
  测试 A 创建的数据被测试 B 使用    每个测试独立准备数据

元素定位不稳定                     使用稳定定位器
  ↓                               ↓
  page.locator('.btn:nth-child(3)')  page.getByRole('button',
                                       { name: '提交' })

防抖实践示例:

typescript
test.describe('稳定的测试写法', () => {
  test('等待数据加载完成后再断言', async ({ page }) => {
    await page.goto('/dashboard')

    await expect(page.locator('.skeleton')).toHaveCount(0, { timeout: 10000 })

    await expect(page.getByText('数据已加载')).toBeVisible()
  })

  test('等待网络请求完成', async ({ page }) => {
    await page.goto('/dashboard')

    const responsePromise = page.waitForResponse('**/api/data')
    await page.getByRole('button', { name: '刷新' }).click()
    await responsePromise

    await expect(page.locator('.data-table')).toBeVisible()
  })

  test('使用 retry 处理偶发不稳定', async ({ page }) => {
    await page.goto('/notifications')

    await expect(async () => {
      await page.getByRole('button', { name: '刷新' }).click()
      await expect(page.locator('.notification')).toHaveCount(3)
    }).toPass({
      timeout: 10000,
      intervals: [1000, 2000, 3000],
    })
  })
})

Playwright 配置级别的防抖策略:

typescript
export default defineConfig({
  retries: process.env.CI ? 2 : 0,

  expect: {
    timeout: 10000,
  },

  use: {
    actionTimeout: 15000,
    navigationTimeout: 30000,
  },
})

六、面试高频问题

Q1:E2E 测试与单元测试、集成测试的区别?如何确定测试比例?

回答思路

从测试金字塔出发,三者在测试对象、隔离程度、运行速度、维护成本、置信度五个维度上各有侧重。单元测试覆盖最广、运行最快但置信度最低;E2E 测试覆盖关键路径、运行最慢但置信度最高。推荐比例遵循 70/20/10 原则(单元/集成/E2E),但需根据项目类型调整——ToC 产品的核心流程应加大 E2E 比例,而工具库/SDK 则更侧重单元测试。关键是 E2E 只覆盖关键用户路径(Critical User Journeys),不要试图用 E2E 覆盖所有边界情况。

Q2:Playwright 的自动等待机制是如何实现的?为什么说它比手动 sleep 更好?

回答思路

Playwright 在每个操作(click、fill 等)执行前会自动执行一系列可操作性检查(Actionability Checks):元素是否 attached 到 DOM、是否 visible、是否 stable(无进行中的动画)、是否 enabled、是否可接收事件(不被遮挡)。这些检查以轮询方式进行,直到全部通过或超时。

sleep(3000) 相比:sleep 是固定等待——如果元素 1 秒就出现了还要白等 2 秒,如果 4 秒才出现就会测试失败。自动等待是精确等待——元素一就绪立刻操作,既快又稳。这也是 Playwright 测试比传统 Selenium 测试更稳定的核心原因之一。

Q3:Playwright 的 Locator 和直接用 CSS 选择器有什么区别?为什么推荐 getByRole?

回答思路

Locator 是惰性的(lazy)——创建时不查找元素,操作时才查找,并且每次操作都会重新查找。这意味着即使 DOM 动态变化,Locator 也能正确定位最新元素。

推荐 getByRole 有三个原因:(1)最接近用户感知——用户看到的是"一个叫提交的按钮"而非".btn-primary";(2)天然具备可访问性验证——如果 getByRole('button') 找不到,说明你的 HTML 可能缺少正确的 ARIA 语义;(3)对 UI 重构最稳定——改 class 名、调 DOM 结构都不影响 Role 定位。

Q4:如何解决 E2E 测试中的 Flaky Test 问题?

回答思路

Flaky Test 根因通常有五类:(1)竞态条件——操作时元素未就绪,解决方案是使用 Playwright 自动等待而非 sleep;(2)动画干扰——元素在动画过程中点击位置偏移,解决方案是全局禁用动画;(3)外部依赖——第三方 API 不稳定,解决方案是 Mock;(4)测试间耦合——测试 A 产生的数据影响测试 B,解决方案是每个测试独立管理数据;(5)定位器脆弱——依赖 DOM 结构的 CSS 选择器,解决方案是使用 getByRole/getByTestId。

系统性治理:配置合理的 retries、使用 Playwright 的 --last-failed 重跑失败用例、在 CI 中标记 Flaky 测试并限期修复。

Q5:Page Object Model(POM)模式有什么优缺点?在什么场景下适合使用?

回答思路

POM 将页面的定位逻辑和操作逻辑封装为独立类,测试用例只调用语义化方法(如 loginPage.login())。优点是:(1)可维护性——UI 改版只需修改 Page Object,所有用例自动适应;(2)可读性——测试读起来像用户故事;(3)复用性——登录操作封装一次,所有测试复用。

缺点是:(1)引入额外抽象层,增加了代码量;(2)过度封装会导致 Page Object 臃肿。适用场景:测试用例超过 20 个的中大型项目。小项目(<10 个用例)直接写反而更简单。Playwright 官方推荐使用 Fixture 机制注入 Page Object,比传统的手动实例化更优雅。

Q6:如何在 CI/CD 中高效运行 E2E 测试?

回答思路

四个核心策略:(1)并行 + 分片——利用 Playwright 的 workers 在单机并行执行,利用 --shard 在多台 CI 机器间分片执行;(2)认证状态复用——通过 storageState 保存登录态,避免每个测试重复登录;(3)选择性执行——PR 只运行与变更相关的测试(通过 tag 或目录划分),全量测试放到 nightly build;(4)容器化环境——使用 Docker 保证 CI 环境一致性,Playwright 提供官方 Docker 镜像 mcr.microsoft.com/playwright

实测数据:100 个 E2E 用例,单机串行约 30 分钟,4 workers 并行约 8 分钟,4 台机器分片约 2 分钟。

Q7:Playwright 和 Cypress 的核心架构差异是什么?对测试能力有什么影响?

回答思路

最本质的差异在于运行位置:Cypress 的测试代码运行在浏览器内部(与被测应用同一个浏览器上下文),而 Playwright 的测试代码运行在 Node.js 进程中,通过 CDP/WebSocket 协议控制浏览器。

这导致了能力差异:Cypress 因为运行在浏览器内,可以直接访问 DOM、window 对象,调试体验直观(实时 DOM 快照),但无法操作多标签页、无法跨域、受浏览器沙箱限制;Playwright 因为是进程外控制,天然支持多标签页、多浏览器上下文、iframe、跨域操作,且可以做到真正的多浏览器(Chromium/Firefox/WebKit)。

Q8:如何设计一个前端项目的完整测试策略?

回答思路

分四层设计:

第一层——静态分析(TypeScript + ESLint):零运行成本,捕获类型错误和代码规范问题。

第二层——单元测试(Vitest):覆盖工具函数、Hooks、纯逻辑组件。关注边界值、异常处理。目标覆盖率 80%+。

第三层——集成测试(Vitest + Testing Library + MSW):覆盖组件交互、页面级功能。用 MSW Mock API,测试用户视角的功能行为。

第四层——E2E 测试(Playwright):仅覆盖关键用户路径——注册、登录、核心业务流程、支付。用 API Mock 保证稳定性,在 CI 中分片并行执行。

关键原则:每一层测试只测该层该测的东西,不要在 E2E 中测边界值(那是单元测试的事),也不要在单元测试中测页面导航(那是 E2E 的事)。


七、延伸阅读

用心学习,用代码说话 💻