E2E 测试

E2E(End-to-End)测试从用户视角验证完整功能流程——打开浏览器、导航到页面、点击按钮、填写表单、检查结果。它是测试金字塔的顶层,数量最少但信心最强。Nuxt4 通过 @nuxt/test-utils 集成 Playwright,提供了一套开箱即用的 E2E 测试方案。本章从 E2E 测试的价值定位讲起,深入 Playwright 与 Nuxt 的集成原理,再到视觉回归和 CI 最佳实践。

1. E2E 测试的价值与边界

1.1 E2E 测试解决什么问题

单元测试验证"每块砖是好的",E2E 测试验证"整栋楼能住人"。有些 bug 单元测试永远抓不到:

  • 组件间通信错误:父组件传了 videoId,子组件期望 id——两个组件的单测都过了,但组合起来就挂了
  • 路由和中间件联动:未登录用户访问 /dashboard 应该重定向到 /login,这需要路由守卫、Session 状态、页面组件协同工作
  • SSR Hydration 不匹配:服务端渲染的 HTML 和客户端激活后的 DOM 不一致,只在真实浏览器中才能发现
  • 第三方集成:支付回调、OAuth 登录、视频播放器——这些涉及真实的浏览器环境和网络请求

1.2 E2E 测试的代价

维度代价应对
速度每个测试 2-10 秒只测关键流程,不测细节
稳定性网络抖动、动画时序、DOM 变化合理等待策略,避免 sleep
维护UI 改动频繁破坏测试data-testid 而非 CSS 选择器
调试失败时不容易定位原因截图 + trace + 视频录制

核心原则:E2E 测试应该少而精。测 10 个核心用户旅程(注册→登录→上传→播放→退出),不要试图用 E2E 覆盖所有边界条件——那是单元测试的职责。

1.3 什么适合 E2E 测试

适合不适合
用户注册/登录流程输入校验的每种错误消息
核心业务流程(上传→发布→播放)组件的 props 组合
支付/订阅流程工具函数的边界条件
权限隔离验证(普通用户不能访问 admin)样式和布局细节
搜索和筛选的完整流程API 响应的数据结构

2. Playwright + Nuxt4 集成

2.1 为什么是 Playwright

Playwright 是 Microsoft 开发的浏览器自动化框架,与 Nuxt4 / Vitest 生态深度集成。相比 Cypress:

特性PlaywrightCypress
浏览器支持Chromium + Firefox + WebKit仅 Chromium(免费版)
多标签页 / 多窗口✅ 原生支持❌ 不支持
并行执行✅ 进程级并行需付费
网络拦截✅ 精细控制
执行速度快(无进程内通信开销)较慢(运行在浏览器内)
学习曲线中等低(可视化调试好)

2.2 @nuxt/test-utils 的 E2E 模式

@nuxt/test-utils 的 E2E 模式做了一件关键的事:自动启动 Nuxt 开发服务器。你不需要手动启动服务器再跑测试,setup() 会在测试开始前构建并启动 Nuxt 应用,测试结束后自动关闭。

import { setup, createPage, url } from '@nuxt/test-utils/e2e'
import { describe, it, expect } from 'vitest'
 
describe('Homepage', async () => {
  await setup({
    server: true,
    browser: true,
    browserOptions: {
      type: 'chromium',
    },
  })
 
  it('renders the hero section', async () => {
    const page = await createPage('/')
    const title = await page.textContent('h1')
    expect(title).toContain('AI 视频平台')
  })
})

createPage(path)@nuxt/test-utils 对 Playwright page 的封装,自动拼接 Nuxt 服务器的 URL。返回的 page 对象就是标准的 Playwright Page,可以使用所有 Playwright API。

2.3 url() 辅助函数

url(path) 返回 Nuxt 测试服务器的完整 URL。当你需要直接使用 Playwright 原生 API 时:

import { url } from '@nuxt/test-utils/e2e'
 
it('navigates correctly', async () => {
  const page = await createPage()
  await page.goto(url('/videos'))
  await expect(page).toHaveURL(/\/videos/)
})

3. 核心用户流程测试

3.1 页面导航和 SSR 验证

it('SSR renders video list', async () => {
  const page = await createPage('/videos')
 
  // 验证 SSR:页面加载时内容已经在 HTML 中
  const html = await page.content()
  expect(html).toContain('视频列表')
 
  // 验证 Hydration:客户端 JS 激活后交互正常
  await page.click('[data-testid="sort-by-date"]')
  await page.waitForSelector('[data-testid="video-card"]')
})

3.2 表单交互和验证

it('validates login form', async () => {
  const page = await createPage('/login')
 
  // 空表单提交
  await page.click('[data-testid="login-btn"]')
  await expect(page.locator('[data-testid="email-error"]')).toBeVisible()
 
  // 填写正确信息
  await page.fill('[data-testid="email-input"]', '[email protected]')
  await page.fill('[data-testid="password-input"]', 'Password123')
  await page.click('[data-testid="login-btn"]')
 
  // 登录成功后跳转
  await page.waitForURL('**/dashboard')
})

3.3 认证流程测试

E2E 测试认证流程时,通常不想每个测试都走一遍登录。Playwright 的 storageState 可以保存认证状态:

// tests/e2e/auth.setup.ts — 全局 setup
import { setup, createPage, url } from '@nuxt/test-utils/e2e'
 
await setup({ server: true, browser: true })
 
const page = await createPage('/login')
await page.fill('[data-testid="email-input"]', '[email protected]')
await page.fill('[data-testid="password-input"]', 'TestPassword123')
await page.click('[data-testid="login-btn"]')
await page.waitForURL('**/dashboard')
 
// 保存认证状态到文件
await page.context().storageState({ path: '.auth/admin.json' })

后续测试直接使用保存的认证状态,跳过登录步骤。

3.4 API Mock 与网络拦截

E2E 测试中,某些外部依赖(支付 API、AI 模型服务)不应该真正调用。Playwright 的 route API 可以拦截和 mock 网络请求:

it('handles AI generation failure gracefully', async () => {
  const page = await createPage('/create')
 
  // 拦截 AI 生成 API,返回错误
  await page.route('**/api/ai/generate', route => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ message: 'AI 服务暂时不可用' }),
    })
  })
 
  await page.click('[data-testid="generate-btn"]')
  await expect(page.locator('[data-testid="error-toast"]')).toContainText('AI 服务暂时不可用')
})

这比真正调用 AI 服务快得多,也更稳定——不受外部服务可用性的影响。

4. 视觉回归测试

4.1 什么是视觉回归

视觉回归测试通过截图对比检测 UI 变化。每次运行测试时截图,与基线截图逐像素比较。如果差异超过阈值,测试失败。

它解决了一个棘手问题:CSS 改动的副作用很难用断言捕获。你改了一个全局 padding,可能影响了 20 个页面的布局——单元测试和功能 E2E 测试都发现不了。

4.2 Playwright 截图断言

it('video card matches snapshot', async () => {
  const page = await createPage('/videos')
 
  // 等待内容稳定
  await page.waitForSelector('[data-testid="video-card"]')
  await page.waitForLoadState('networkidle')
 
  // 截图对比
  await expect(page).toHaveScreenshot('video-list.png', {
    maxDiffPixelRatio: 0.01,  // 允许 1% 的像素差异
  })
})

首次运行会生成基线截图(存在 tests/e2e/__snapshots__/ 目录中),后续运行与基线对比。如果 UI 有意改动了,用 --update-snapshots 更新基线。

4.3 视觉测试的实践要点

  • 稳定性:动态内容(时间戳、随机推荐)会导致截图每次不同。用 mock 数据或 page.evaluate() 冻结动态内容。
  • 字体渲染:不同操作系统的字体渲染有细微差异。CI 中使用 Docker 容器(mcr.microsoft.com/playwright)确保一致性。
  • 响应式:同一页面在不同视口宽度下布局不同。用 page.setViewportSize() 测试关键断点(mobile 375px、tablet 768px、desktop 1440px)。
  • 暗色模式:如果支持主题切换,需要分别截图测试。

5. E2E 测试架构

5.1 Page Object 模式

当测试数量增长后,直接操作 page.click('[data-testid="xxx"]') 会导致大量重复。Page Object 模式将页面交互封装为类:

// tests/e2e/pages/LoginPage.ts
export class LoginPage {
  constructor(private page: Page) {}
 
  async goto() {
    await this.page.goto(url('/login'))
  }
 
  async login(email: string, password: string) {
    await this.page.fill('[data-testid="email-input"]', email)
    await this.page.fill('[data-testid="password-input"]', password)
    await this.page.click('[data-testid="login-btn"]')
  }
 
  async expectError(message: string) {
    await expect(this.page.locator('[data-testid="error-message"]'))
      .toContainText(message)
  }
}
it('shows error for wrong password', async () => {
  const page = await createPage()
  const loginPage = new LoginPage(page)
 
  await loginPage.goto()
  await loginPage.login('[email protected]', 'wrong-password')
  await loginPage.expectError('邮箱或密码错误')
})

Page Object 的好处:UI 结构变化时只需修改 Page Object 类,不需要改每个测试。

5.2 测试数据管理

E2E 测试需要已知的、可预测的数据。三种策略:

策略方式适用场景
Seed 数据库测试前用种子脚本写入数据,测试后清理测试 CRUD 流程
API MockPlaywright route 拦截,返回固定数据不依赖数据库的 UI 测试
创建即测试测试中先创建数据,再验证完整流程验证

推荐组合:Seed + API Mock。核心流程用 Seed 数据保证真实性,外部依赖用 Mock 保证稳定性。

6. CI 中的 E2E 测试

6.1 GitHub Actions 配置要点

# .github/workflows/e2e.yml
- name: Install Playwright
  run: npx playwright install --with-deps chromium
 
- name: Run E2E tests
  run: npx vitest run tests/e2e
  env:
    CI: true
    DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
 
- name: Upload test artifacts
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-report
    path: |
      test-results/
      playwright-report/

关键细节:

  • 只安装需要的浏览器(chromium),不安装全部三个,节省 CI 时间
  • 失败时上传截图和 trace 文件,方便远程调试
  • 用专门的测试数据库,不影响其他环境

6.2 调试失败的 E2E 测试

Playwright 的 Trace Viewer 是杀手级调试工具。在配置中开启 trace:

browserOptions: {
  type: 'chromium',
  launch: { trace: 'on-first-retry' },
}

失败时生成 trace.zip,用 npx playwright show-trace trace.zip 打开——可以看到测试的每一步操作、截图、DOM 快照、网络请求、控制台日志。就像录像回放一样还原失败现场。

6.3 并行执行与隔离

Playwright 默认按文件级别并行执行测试。每个测试文件使用独立的浏览器上下文(context),Cookie 和 localStorage 互不干扰。

如果测试间有数据库写入冲突(两个测试同时创建同名用户),需要:

  • 每个测试使用唯一的测试数据(比如邮箱加上随机后缀)
  • 或者串行执行有数据冲突的测试文件(test.describe.configure({ mode: 'serial' })

7. E2E 测试 Checklist

测试覆盖

  • 用户注册 → 登录 → 退出完整流程
  • 核心业务流程(上传 → 编辑 → 发布 → 播放)
  • 权限隔离(普通用户不能访问 admin 页面)
  • 关键错误处理(API 失败、网络超时)
  • SSR 首屏渲染验证

稳定性

  • 使用 data-testid 选择器,不依赖 CSS 类名
  • 使用 Playwright 的 auto-wait,不用 sleep
  • 外部 API 用 route 拦截 mock
  • 动态数据冻结或 mock

CI

  • 失败时上传截图和 trace
  • 测试数据库隔离
  • 视觉回归使用 Docker 容器保证一致性

本章小结

  • E2E 定位:少而精,只测核心用户旅程和组件间协作,不替代单元测试
  • Playwright + @nuxt/test-utilssetup() 自动启动 Nuxt 服务器,createPage() 创建浏览器页面
  • 网络拦截page.route() mock 外部 API,提升速度和稳定性
  • 视觉回归:截图对比检测 CSS 副作用,注意字体渲染和动态内容的一致性
  • Page Object:封装页面交互,UI 变化时只改一处
  • CI 调试:Trace Viewer 回放失败现场,trace + 截图是远程调试的关键
  • 稳定性铁律data-testid 选择器 + auto-wait + API mock + 数据隔离