前端测试完整指南:从单元测试、组件测试到端到端测试与 CI/CD

前端测试不是单纯追求覆盖率,也不是为了证明"代码能运行"。它真正解决的是:在需求变化、代码重构、多人协作和频繁发布的情况下,持续证明关键业务仍然可用。

前言

现代前端项目已经不再是简单的页面展示。

一个完整的前端应用通常包含:

  • 业务规则与数据计算
  • 表单验证
  • 权限控制
  • 状态管理
  • 异步请求
  • 路由跳转
  • 浏览器兼容
  • 响应式布局
  • 可访问性
  • 性能指标
  • 前后端接口协作
  • 第三方服务集成

仅靠人工点击页面,很难持续覆盖所有分支。

自动化测试的价值,是把团队对系统行为的理解转化为可重复执行的代码。当开发者修改功能时,测试能够快速回答几个关键问题:

  1. 原来的功能是否仍然正常?
  2. 新功能是否满足预期?
  3. 边界条件和异常场景是否被处理?
  4. 页面在真实浏览器里是否可以完成关键流程?
  5. 当前版本是否具备发布条件?

本文将围绕测试策略、单元测试、组件测试、集成测试、端到端测试、接口模拟、视觉回归、可访问性、性能测试、覆盖率和 CI/CD,建立一套完整的前端测试体系。


目录

  1. 前端测试解决什么问题
  2. 前端测试的完整分类
  3. 如何设计测试分层
  4. 主流前端测试工具怎么选
  5. 建立测试目录和命名规范
  6. 使用 Vitest 搭建基础测试环境
  7. 单元测试完整实践
  8. 测试用例的设计方法
  9. React 组件测试实践
  10. Vue、Angular 等框架如何迁移测试思路
  11. 异步请求与接口模拟
  12. Mock、Stub、Spy 和 Fake 的区别
  13. 状态管理与路由测试
  14. 使用 Playwright 编写端到端测试
  15. 管理端到端测试数据
  16. 视觉回归测试
  17. 可访问性测试
  18. 前端性能测试
  19. 前端安全测试
  20. 测试覆盖率应该怎么看
  21. 如何治理不稳定测试
  22. 在 CI/CD 中执行测试
  23. 如何让代码更容易测试
  24. 老项目如何逐步补充测试
  25. 常见错误与反模式
  26. 团队测试规范与质量门禁
  27. 前端测试落地检查清单
  28. 常见问题
  29. 总结
  30. 参考资料

1. 前端测试解决什么问题

1.1 防止功能回归

项目持续迭代时,一处看似普通的修改可能影响多个功能:

  • 修改公共按钮导致多个页面样式变化
  • 修改请求拦截器导致登录状态失效
  • 修改金额计算逻辑导致优惠结果错误
  • 修改路由守卫导致无权限用户进入后台
  • 修改状态管理逻辑导致页面数据无法同步

回归测试可以在代码合并或发布之前发现这些问题。

1.2 降低重构风险

没有测试保护的重构,本质上是在依靠开发者记忆判断系统有没有被破坏。

测试充分时,可以按照下面的流程重构:

  1. 运行现有测试,确认基线正常。
  2. 修改内部实现。
  3. 再次运行测试。
  4. 如果外部行为保持不变,测试应继续通过。
  5. 如果行为发生变化,检查这是需求变化还是回归缺陷。

好的测试关注外部行为,而不是内部实现,因此不会因为变量重命名或函数拆分就大量失败。

1.3 缩短反馈周期

不同测试的反馈速度不同:

测试方式 反馈速度 适合发现的问题
TypeScript、ESLint 秒级 类型错误、语法问题、明显代码缺陷
单元测试 秒级 计算错误、条件分支错误
组件测试 秒级到分钟级 渲染、交互、状态变化
集成测试 分钟级 模块协作、请求与状态联动
端到端测试 分钟级以上 真实用户流程、系统集成问题
人工验收 较慢 主观体验、探索性问题

越早发现问题,修复成本通常越低。

1.4 形成可执行的需求文档

下面这个测试比一段模糊的需求描述更加明确:

ts 复制代码
it('订单金额达到 100 元时免运费', () => {
  expect(calculateShippingFee(100)).toBe(0)
})

it('订单金额不足 100 元时收取 10 元运费', () => {
  expect(calculateShippingFee(99.99)).toBe(10)
})

测试明确描述了输入、边界和预期结果,同时还能被自动执行。


2. 前端测试的完整分类

2.1 静态检查

静态检查不运行应用,主要分析源代码。

常见工具包括:

  • TypeScript
  • ESLint
  • Stylelint
  • HTML Validate
  • Prettier
  • 依赖安全扫描工具

它们可以发现:

  • 类型不匹配
  • 未使用变量
  • 错误的 Promise 调用
  • 不规范的 CSS
  • 无效 HTML
  • 部分潜在安全问题

静态检查应该是前端质量体系的第一道防线。

2.2 单元测试

单元测试验证一个相对独立的代码单元,例如:

  • 工具函数
  • 格式化函数
  • 金额计算
  • 数据转换
  • 校验规则
  • Reducer
  • 状态机
  • Composable 或 Hook 中的纯逻辑

单元测试应该快速、稳定,并尽量避免依赖网络、数据库和真实浏览器。

2.3 组件测试

组件测试验证一个 UI 组件在指定输入下是否正确渲染和响应用户操作。

典型测试内容包括:

  • 是否显示正确文本
  • 按钮是否可以点击
  • 表单是否正确校验
  • 加载状态是否出现
  • 错误消息是否展示
  • 用户操作后是否调用回调
  • 权限不足时是否隐藏功能

组件测试关注的是用户能够观察到的行为。

2.4 集成测试

集成测试验证多个模块组合后的行为,例如:

  • 页面组件与状态管理协作
  • 表单与接口请求协作
  • 路由与权限模块协作
  • 请求层与数据转换层协作
  • 多个组件之间的事件联动

前端项目中的很多高价值测试,实际上都属于集成测试。

2.5 端到端测试

端到端测试,也称 E2E 测试,会在真实浏览器中模拟用户操作。

例如:

  1. 打开登录页。
  2. 输入账号和密码。
  3. 点击登录。
  4. 进入控制台。
  5. 创建订单。
  6. 提交订单。
  7. 在订单列表中看到新订单。

端到端测试最接近用户真实体验,但运行速度和维护成本通常也最高。

2.6 视觉回归测试

视觉回归测试通过截图对比发现界面变化,例如:

  • 按钮错位
  • 字体变化
  • 容器溢出
  • 响应式布局损坏
  • 颜色或间距异常
  • 公共样式影响其他页面

2.7 可访问性测试

可访问性测试关注不同用户能否正常使用产品,包括:

  • 键盘能否完成操作
  • 表单是否有关联标签
  • 图片是否有替代文本
  • 焦点顺序是否合理
  • 对比度是否足够
  • 弹窗是否管理焦点
  • 屏幕阅读器能否理解页面结构

2.8 性能测试

性能测试关注:

  • 首屏加载速度
  • JavaScript 体积
  • 图片资源大小
  • 接口响应
  • 页面交互延迟
  • 布局稳定性
  • 内存泄漏
  • 长任务

2.9 契约测试

契约测试验证前端和后端对接口的理解是否一致,例如:

  • 字段名称是否一致
  • 字段类型是否一致
  • 必填字段是否一致
  • 枚举值是否一致
  • 错误结构是否一致
  • OpenAPI 文档是否与实际响应一致

3. 如何设计测试分层

推荐使用"分层测试"而不是依赖单一测试类型。
#mermaid-svg-5wlJPkGEXW5Ctk6S{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5wlJPkGEXW5Ctk6S .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5wlJPkGEXW5Ctk6S .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5wlJPkGEXW5Ctk6S .error-icon{fill:#552222;}#mermaid-svg-5wlJPkGEXW5Ctk6S .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5wlJPkGEXW5Ctk6S .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5wlJPkGEXW5Ctk6S .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5wlJPkGEXW5Ctk6S .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5wlJPkGEXW5Ctk6S .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5wlJPkGEXW5Ctk6S .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5wlJPkGEXW5Ctk6S .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5wlJPkGEXW5Ctk6S .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5wlJPkGEXW5Ctk6S .marker.cross{stroke:#333333;}#mermaid-svg-5wlJPkGEXW5Ctk6S svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5wlJPkGEXW5Ctk6S p{margin:0;}#mermaid-svg-5wlJPkGEXW5Ctk6S .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5wlJPkGEXW5Ctk6S .cluster-label text{fill:#333;}#mermaid-svg-5wlJPkGEXW5Ctk6S .cluster-label span{color:#333;}#mermaid-svg-5wlJPkGEXW5Ctk6S .cluster-label span p{background-color:transparent;}#mermaid-svg-5wlJPkGEXW5Ctk6S .label text,#mermaid-svg-5wlJPkGEXW5Ctk6S span{fill:#333;color:#333;}#mermaid-svg-5wlJPkGEXW5Ctk6S .node rect,#mermaid-svg-5wlJPkGEXW5Ctk6S .node circle,#mermaid-svg-5wlJPkGEXW5Ctk6S .node ellipse,#mermaid-svg-5wlJPkGEXW5Ctk6S .node polygon,#mermaid-svg-5wlJPkGEXW5Ctk6S .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5wlJPkGEXW5Ctk6S .rough-node .label text,#mermaid-svg-5wlJPkGEXW5Ctk6S .node .label text,#mermaid-svg-5wlJPkGEXW5Ctk6S .image-shape .label,#mermaid-svg-5wlJPkGEXW5Ctk6S .icon-shape .label{text-anchor:middle;}#mermaid-svg-5wlJPkGEXW5Ctk6S .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5wlJPkGEXW5Ctk6S .rough-node .label,#mermaid-svg-5wlJPkGEXW5Ctk6S .node .label,#mermaid-svg-5wlJPkGEXW5Ctk6S .image-shape .label,#mermaid-svg-5wlJPkGEXW5Ctk6S .icon-shape .label{text-align:center;}#mermaid-svg-5wlJPkGEXW5Ctk6S .node.clickable{cursor:pointer;}#mermaid-svg-5wlJPkGEXW5Ctk6S .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5wlJPkGEXW5Ctk6S .arrowheadPath{fill:#333333;}#mermaid-svg-5wlJPkGEXW5Ctk6S .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5wlJPkGEXW5Ctk6S .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5wlJPkGEXW5Ctk6S .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5wlJPkGEXW5Ctk6S .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5wlJPkGEXW5Ctk6S .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5wlJPkGEXW5Ctk6S .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5wlJPkGEXW5Ctk6S .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5wlJPkGEXW5Ctk6S .cluster text{fill:#333;}#mermaid-svg-5wlJPkGEXW5Ctk6S .cluster span{color:#333;}#mermaid-svg-5wlJPkGEXW5Ctk6S div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5wlJPkGEXW5Ctk6S .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5wlJPkGEXW5Ctk6S rect.text{fill:none;stroke-width:0;}#mermaid-svg-5wlJPkGEXW5Ctk6S .icon-shape,#mermaid-svg-5wlJPkGEXW5Ctk6S .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5wlJPkGEXW5Ctk6S .icon-shape p,#mermaid-svg-5wlJPkGEXW5Ctk6S .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5wlJPkGEXW5Ctk6S .icon-shape .label rect,#mermaid-svg-5wlJPkGEXW5Ctk6S .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5wlJPkGEXW5Ctk6S .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5wlJPkGEXW5Ctk6S .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5wlJPkGEXW5Ctk6S :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 横向覆盖
横向覆盖
横向覆盖
横向覆盖
发布门禁
静态检查
单元测试
组件与集成测试
端到端测试
人工探索与验收
视觉回归
可访问性
性能与安全

合理的分层原则如下:

  • 大量使用快速、稳定的静态检查和单元测试。
  • 重点使用组件与集成测试验证用户行为。
  • 使用少量端到端测试保护关键业务流程。
  • 使用视觉、可访问性、性能和安全测试补充非功能质量。

不要机械追求固定比例。测试数量应该由业务风险决定。

例如支付系统应该重点覆盖:

  • 金额计算
  • 优惠规则
  • 支付状态机
  • 重复提交
  • 超时处理
  • 支付成功和失败流程

而内容展示型网站可能更关注:

  • 页面渲染
  • SEO
  • 响应式布局
  • 可访问性
  • 性能
  • 视觉回归

4. 主流前端测试工具怎么选

测试目标 常用工具 适用场景
静态类型检查 TypeScript TypeScript 项目
代码规范 ESLint、Stylelint 所有前端项目
单元测试 Vitest、Jest 函数、状态、业务逻辑
DOM 环境 jsdom、happy-dom Node.js 中模拟浏览器 DOM
React 组件测试 React Testing Library React 用户交互测试
Vue 组件测试 Vue Test Utils、Testing Library Vue 组件测试
Angular 测试 Angular Testing Utilities Angular 项目
接口模拟 Mock Service Worker 浏览器和 Node.js 请求模拟
端到端测试 Playwright、Cypress 真实浏览器业务流程
组件工作台 Storybook 组件状态、交互、视觉测试
可访问性 axe-core 自动检测常见可访问性问题
性能测试 Lighthouse CI 性能预算和回归检查
变异测试 Stryker 检查测试是否真正有效

4.1 Vitest 和 Jest 怎么选

适合优先使用 Vitest 的情况:

  • 项目基于 Vite。
  • 希望复用 Vite 配置和插件。
  • 项目主要使用 ES Module。
  • 希望获得更直接的 Vite 项目集成。

适合继续使用 Jest 的情况:

  • 老项目已经建立完整的 Jest 测试体系。
  • 项目严重依赖 Jest 插件或自定义转换器。
  • 迁移成本明显高于收益。
  • 当前框架已有成熟的 Jest 配置。

不要仅仅为了"使用新工具"重写已经稳定运行的测试体系。

4.2 Playwright 和 Cypress 怎么选

Playwright 常见优势:

  • 支持 Chromium、Firefox 和 WebKit。
  • 提供浏览器上下文隔离。
  • Locator 具备自动等待能力。
  • 提供 Trace Viewer、截图和视频等调试信息。
  • 适合跨浏览器和多页面场景。

Cypress 常见优势:

  • 交互式调试体验直观。
  • 命令执行过程容易观察。
  • 自动等待机制降低了部分异步测试难度。
  • 组件测试和端到端测试可以使用相似的开发体验。

两者都能建立成熟的前端测试体系。选择时应考虑团队经验、浏览器要求、现有基础设施和调试习惯。


5. 建立测试目录和命名规范

一种常见目录结构如下:

text 复制代码
src/
├─ components/
│  ├─ LoginForm.tsx
│  └─ LoginForm.test.tsx
├─ features/
│  └─ order/
│     ├─ calculatePrice.ts
│     └─ calculatePrice.test.ts
├─ services/
│  ├─ userApi.ts
│  └─ userApi.test.ts
└─ test/
   ├─ handlers.ts
   ├─ server.ts
   ├─ setup.ts
   ├─ fixtures/
   └─ factories/

e2e/
├─ auth.setup.ts
├─ login.spec.ts
├─ checkout.spec.ts
├─ fixtures/
└─ pages/

playwright.config.ts
vitest.config.ts

推荐命名:

text 复制代码
*.test.ts
*.test.tsx
*.spec.ts
*.spec.tsx

建议保持一种主要规范:

  • 单元和组件测试统一使用 *.test.ts
  • 端到端测试统一使用 *.spec.ts
  • 测试文件尽量靠近被测试代码。
  • 公共测试工具放入 src/test
  • E2E 测试单独放入 e2e

测试名称应描述业务行为:

ts 复制代码
// 不推荐
it('test button', () => {})

// 推荐
it('提交有效表单后调用登录接口', () => {})

it('密码错误时显示服务端返回的错误信息', () => {})

it('未登录用户访问订单页时跳转到登录页', () => {})

6. 使用 Vitest 搭建基础测试环境

以下以 Vite、TypeScript 和 React 项目为例。

6.1 安装依赖

bash 复制代码
npm install -D vitest jsdom @vitest/coverage-v8
npm install -D @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event msw
npm install -D @playwright/test @axe-core/playwright
npx playwright install

如果使用 Vue,可以将 @testing-library/react 替换为:

bash 复制代码
npm install -D @testing-library/vue

6.2 配置 Vitest

创建 vitest.config.ts

ts 复制代码
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    restoreMocks: true,
    clearMocks: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/**/*.d.ts',
        'src/main.tsx',
        'src/test/**',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        statements: 80,
        branches: 75,
      },
    },
  },
})

这里的覆盖率阈值只是示例,不应该直接当作所有项目的标准。

6.3 创建全局测试配置

创建 src/test/setup.ts

ts 复制代码
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './server'

beforeAll(() => {
  server.listen({
    onUnhandledRequest: 'error',
  })
})

afterEach(() => {
  cleanup()
  server.resetHandlers()
})

afterAll(() => {
  server.close()
})

将未处理请求设置为错误,可以防止测试意外访问真实服务。

6.4 添加 npm 命令

json 复制代码
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

本地开发时运行监听模式:

bash 复制代码
npm test

执行一次完整测试:

bash 复制代码
npm run test:run

生成覆盖率:

bash 复制代码
npm run test:coverage

7. 单元测试完整实践

7.1 被测试代码

创建 src/features/order/calculatePrice.ts

ts 复制代码
export interface Coupon {
  type: 'fixed' | 'percentage'
  value: number
}

export function calculatePayable(
  originalAmount: number,
  coupon?: Coupon,
): number {
  if (!Number.isFinite(originalAmount) || originalAmount < 0) {
    throw new RangeError('订单金额必须是大于等于 0 的有限数字')
  }

  if (!coupon) {
    return originalAmount
  }

  if (!Number.isFinite(coupon.value) || coupon.value < 0) {
    throw new RangeError('优惠值不合法')
  }

  let result: number

  if (coupon.type === 'fixed') {
    result = originalAmount - coupon.value
  } else {
    if (coupon.value > 100) {
      throw new RangeError('折扣百分比不能超过 100')
    }

    result = originalAmount * (1 - coupon.value / 100)
  }

  return Math.max(0, Math.round(result * 100) / 100)
}

7.2 编写测试

ts 复制代码
import { describe, expect, it } from 'vitest'
import { calculatePayable } from './calculatePrice'

describe('calculatePayable', () => {
  it('没有优惠券时返回原价', () => {
    expect(calculatePayable(100)).toBe(100)
  })

  it('固定金额优惠不能使应付金额小于 0', () => {
    expect(
      calculatePayable(50, {
        type: 'fixed',
        value: 80,
      }),
    ).toBe(0)
  })

  it.each([
    {
      amount: 100,
      percentage: 10,
      expected: 90,
    },
    {
      amount: 199.99,
      percentage: 20,
      expected: 159.99,
    },
    {
      amount: 0,
      percentage: 50,
      expected: 0,
    },
  ])(
    '$amount 元使用 $percentage% 优惠后应支付 $expected 元',
    ({ amount, percentage, expected }) => {
      expect(
        calculatePayable(amount, {
          type: 'percentage',
          value: percentage,
        }),
      ).toBe(expected)
    },
  )

  it('负数订单金额应该抛出异常', () => {
    expect(() => calculatePayable(-1)).toThrow(
      '订单金额必须是大于等于 0 的有限数字',
    )
  })

  it('折扣百分比超过 100 时应该抛出异常', () => {
    expect(() =>
      calculatePayable(100, {
        type: 'percentage',
        value: 101,
      }),
    ).toThrow('折扣百分比不能超过 100')
  })
})

7.3 一个好的单元测试应该具备什么特点

好的单元测试通常具备以下特点:

  • 只验证一个明确行为。
  • 输入和预期结果清晰。
  • 不依赖执行顺序。
  • 不访问真实网络。
  • 不依赖真实时间。
  • 失败信息容易理解。
  • 重构内部实现后仍然有效。
  • 可以重复运行并得到一致结果。

7.4 使用 AAA 结构

AAA 表示:

  • Arrange:准备数据。
  • Act:执行行为。
  • Assert:验证结果。
ts 复制代码
it('固定优惠后返回正确金额', () => {
  // Arrange
  const amount = 100
  const coupon = {
    type: 'fixed' as const,
    value: 20,
  }

  // Act
  const result = calculatePayable(amount, coupon)

  // Assert
  expect(result).toBe(80)
})

简单测试不必强制写出三段注释,但逻辑上应保持清晰。


8. 测试用例的设计方法

测试不能只覆盖"正常输入"。系统中的大量缺陷出现在边界、异常和状态切换处。

8.1 等价类划分

将大量输入划分为行为相同的类别。

以年龄输入为例:

等价类 示例
小于最小值 -1
最小有效值 0
正常值 18
最大有效值 150
超过最大值 151
非数字 NaN
空值 null

每个等价类至少选择一个代表值。

8.2 边界值分析

假设用户名长度要求是 2~20 个字符,重点测试:

  • 1 个字符
  • 2 个字符
  • 3 个字符
  • 19 个字符
  • 20 个字符
  • 21 个字符

最常见的边界错误是:

  • < 写成 <=
  • > 写成 >=
  • 数组下标越界
  • 日期开始和结束时间包含关系错误
  • 金额舍入错误

8.3 决策表

优惠规则可能同时受到会员等级、订单金额和优惠券状态影响。

会员 满 100 元 优惠券有效 预期结果
原价
使用优惠券
使用会员折扣
按业务优先级计算

决策表适合复杂条件组合,可以避免遗漏分支。

8.4 状态迁移测试

订单可能包含以下状态:

text 复制代码
待支付 -> 已支付 -> 配送中 -> 已完成
待支付 -> 已取消
已支付 -> 退款中 -> 已退款

测试时不仅要验证合法迁移,还要验证非法迁移:

ts 复制代码
it('已取消订单不能再次支付', () => {
  const order = createOrder({
    status: 'cancelled',
  })

  expect(() => order.pay()).toThrow('已取消订单不能支付')
})

8.5 异常场景

常见异常场景包括:

  • 网络超时
  • 接口返回 401
  • 接口返回 500
  • 返回数据缺少字段
  • 重复点击按钮
  • 浏览器刷新
  • Token 过期
  • 本地存储不可用
  • 文件上传中断
  • 用户快速切换页面

9. React 组件测试实践

假设存在下面的登录组件:

tsx 复制代码
import { FormEvent, useState } from 'react'

interface LoginFormProps {
  onSubmit: (data: {
    email: string
    password: string
  }) => Promise<void>
}

export function LoginForm({
  onSubmit,
}: LoginFormProps) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [submitting, setSubmitting] = useState(false)

  async function handleSubmit(
    event: FormEvent<HTMLFormElement>,
  ) {
    event.preventDefault()

    if (!email || !password) {
      setError('请输入邮箱和密码')
      return
    }

    setError('')
    setSubmitting(true)

    try {
      await onSubmit({
        email,
        password,
      })
    } catch {
      setError('登录失败,请稍后重试')
    } finally {
      setSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">邮箱</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
      />

      <label htmlFor="password">密码</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(event) => setPassword(event.target.value)}
      />

      {error && <div role="alert">{error}</div>}

      <button type="submit" disabled={submitting}>
        {submitting ? '登录中...' : '登录'}
      </button>
    </form>
  )
}

9.1 测试正常提交

tsx 复制代码
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  it('用户填写完整信息后提交登录数据', async () => {
    const user = userEvent.setup()
    const onSubmit = vi.fn().mockResolvedValue(undefined)

    render(<LoginForm onSubmit={onSubmit} />)

    await user.type(
      screen.getByLabelText('邮箱'),
      'user@example.com',
    )

    await user.type(
      screen.getByLabelText('密码'),
      'correct-password',
    )

    await user.click(
      screen.getByRole('button', {
        name: '登录',
      }),
    )

    expect(onSubmit).toHaveBeenCalledTimes(1)
    expect(onSubmit).toHaveBeenCalledWith({
      email: 'user@example.com',
      password: 'correct-password',
    })
  })
})

9.2 测试表单校验

tsx 复制代码
it('没有填写邮箱和密码时显示错误信息', async () => {
  const user = userEvent.setup()
  const onSubmit = vi.fn().mockResolvedValue(undefined)

  render(<LoginForm onSubmit={onSubmit} />)

  await user.click(
    screen.getByRole('button', {
      name: '登录',
    }),
  )

  expect(
    screen.getByRole('alert'),
  ).toHaveTextContent('请输入邮箱和密码')

  expect(onSubmit).not.toHaveBeenCalled()
})

9.3 测试异步状态

tsx 复制代码
it('登录请求执行期间禁用提交按钮', async () => {
  const user = userEvent.setup()

  let resolveLogin: (() => void) | undefined

  const onSubmit = vi.fn(
    () =>
      new Promise<void>((resolve) => {
        resolveLogin = resolve
      }),
  )

  render(<LoginForm onSubmit={onSubmit} />)

  await user.type(
    screen.getByLabelText('邮箱'),
    'user@example.com',
  )

  await user.type(
    screen.getByLabelText('密码'),
    'correct-password',
  )

  await user.click(
    screen.getByRole('button', {
      name: '登录',
    }),
  )

  expect(
    screen.getByRole('button', {
      name: '登录中...',
    }),
  ).toBeDisabled()

  resolveLogin?.()

  expect(
    await screen.findByRole('button', {
      name: '登录',
    }),
  ).toBeEnabled()
})

9.4 测试错误状态

tsx 复制代码
it('登录请求失败时显示错误信息', async () => {
  const user = userEvent.setup()

  const onSubmit = vi
    .fn()
    .mockRejectedValue(new Error('network error'))

  render(<LoginForm onSubmit={onSubmit} />)

  await user.type(
    screen.getByLabelText('邮箱'),
    'user@example.com',
  )

  await user.type(
    screen.getByLabelText('密码'),
    'wrong-password',
  )

  await user.click(
    screen.getByRole('button', {
      name: '登录',
    }),
  )

  expect(
    await screen.findByRole('alert'),
  ).toHaveTextContent('登录失败,请稍后重试')
})

9.5 Testing Library 查询方式怎么选

优先使用用户能够感知的语义:

  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByDisplayValue
  6. getByAltText
  7. getByTitle
  8. getByTestId

三类查询的用途:

查询 找不到元素 适用场景
getBy... 立即抛错 元素当前应该存在
queryBy... 返回 null 验证元素不存在
findBy... 异步等待 元素稍后出现

例如:

ts 复制代码
expect(screen.queryByRole('alert')).not.toBeInTheDocument()

expect(
  await screen.findByText('保存成功'),
).toBeInTheDocument()

不要优先使用脆弱的 CSS 选择器:

ts 复制代码
// 不推荐
document.querySelector(
  '.login-form > div:nth-child(2) > button',
)

// 推荐
screen.getByRole('button', {
  name: '登录',
})

语义查询不仅更稳定,也会推动组件使用更合理的 HTML 和可访问性属性。


10. Vue、Angular 等框架如何迁移测试思路

前端测试的核心原则与框架无关:

  • 根据用户能看到的内容定位元素。
  • 模拟真实交互。
  • 验证页面结果。
  • 避免直接断言内部状态。
  • 网络边界使用可控的测试替身。
  • 关键流程使用真实浏览器验证。

Vue 组件可以使用 Testing Library:

ts 复制代码
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm.vue'

it('填写表单后提交登录信息', async () => {
  const user = userEvent.setup()

  render(LoginForm)

  await user.type(
    screen.getByLabelText('邮箱'),
    'user@example.com',
  )

  await user.type(
    screen.getByLabelText('密码'),
    'correct-password',
  )

  await user.click(
    screen.getByRole('button', {
      name: '登录',
    }),
  )

  expect(
    await screen.findByText('登录成功'),
  ).toBeInTheDocument()
})

如果使用 Vue Test Utils,可以直接访问组件实例,但应谨慎测试内部实现。

不推荐:

ts 复制代码
expect(wrapper.vm.internalLoading).toBe(true)

更推荐:

ts 复制代码
expect(
  wrapper.get('button').attributes('disabled'),
).toBeDefined()

expect(wrapper.get('button').text()).toBe('登录中...')

Angular、Svelte 和其他框架也应遵循相同原则:验证用户行为和公开契约,而不是框架内部细节。


11. 异步请求与接口模拟

前端测试不应该依赖不稳定的真实服务。Mock Service Worker 可以在网络层拦截请求,让组件继续使用真实的 fetch 或请求客户端。

11.1 定义请求处理器

创建 src/test/handlers.ts

ts 复制代码
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: '张三',
      role: 'admin',
    })
  }),

  http.post('/api/login', async ({ request }) => {
    const body = (await request.json()) as {
      email: string
      password: string
    }

    if (
      body.email === 'user@example.com' &&
      body.password === 'correct-password'
    ) {
      return HttpResponse.json({
        token: 'test-token',
      })
    }

    return HttpResponse.json(
      {
        message: '账号或密码错误',
      },
      {
        status: 401,
      },
    )
  }),
]

创建 src/test/server.ts

ts 复制代码
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

11.2 测试成功请求

假设存在下面的请求函数:

ts 复制代码
export interface User {
  id: string
  name: string
  role: string
}

export async function loadUser(
  id: string,
): Promise<User> {
  const response = await fetch(`/api/users/${id}`)

  if (!response.ok) {
    throw new Error(`加载用户失败:${response.status}`)
  }

  return response.json()
}

对应测试:

ts 复制代码
import { describe, expect, it } from 'vitest'
import { loadUser } from './userApi'

describe('loadUser', () => {
  it('返回用户信息', async () => {
    await expect(loadUser('1001')).resolves.toEqual({
      id: '1001',
      name: '张三',
      role: 'admin',
    })
  })
})

11.3 临时覆盖错误响应

ts 复制代码
import { http, HttpResponse } from 'msw'
import { expect, it } from 'vitest'
import { server } from '../test/server'
import { loadUser } from './userApi'

it('接口返回 500 时抛出业务异常', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.json(
        {
          message: '服务暂时不可用',
        },
        {
          status: 500,
        },
      )
    }),
  )

  await expect(loadUser('1001')).rejects.toThrow(
    '加载用户失败:500',
  )
})

11.4 需要覆盖的请求场景

每个重要请求至少考虑:

  • 请求成功
  • 空数据
  • 业务失败
  • 401 未登录
  • 403 无权限
  • 404 不存在
  • 500 服务异常
  • 请求超时
  • 返回数据不完整
  • 重复请求
  • 请求取消
  • 竞态条件

不要在每个测试中都复制大段 JSON。可以使用测试数据工厂:

ts 复制代码
interface User {
  id: string
  name: string
  role: 'user' | 'admin'
}

export function createUser(
  overrides: Partial<User> = {},
): User {
  return {
    id: 'user-1001',
    name: '测试用户',
    role: 'user',
    ...overrides,
  }
}

12. Mock、Stub、Spy 和 Fake 的区别

这些概念经常被统称为 Mock,但用途并不完全相同。

12.1 Stub

Stub 为依赖提供预设返回值。

ts 复制代码
const loadUser = vi.fn().mockResolvedValue({
  id: '1001',
  name: '张三',
})

12.2 Spy

Spy 观察一个函数是否被调用以及调用参数。

ts 复制代码
const onSuccess = vi.fn()

await submitOrder(order, {
  onSuccess,
})

expect(onSuccess).toHaveBeenCalledWith({
  orderId: 'order-1001',
})

12.3 Fake

Fake 是一个可工作的简化实现,例如内存仓库:

ts 复制代码
class InMemoryUserRepository {
  private users = new Map<string, User>()

  save(user: User) {
    this.users.set(user.id, user)
  }

  findById(id: string) {
    return this.users.get(id)
  }
}

12.4 Mock 的使用原则

适合模拟:

  • 网络请求
  • 当前时间
  • 随机数
  • 浏览器不支持的 API
  • 文件系统
  • 支付、地图等第三方服务
  • 难以稳定控制的外部依赖

不适合过度模拟:

  • 被测试模块自身的核心逻辑
  • 简单的数据转换
  • 普通内部函数
  • 所有子组件
  • 所有 Hook
  • 所有状态管理行为

测试中出现大量 Mock,可能意味着测试验证的是"Mock 之间能否配合",而不是系统真实行为。


13. 状态管理与路由测试

13.1 状态管理测试

状态管理通常分为两层:

  1. 纯状态逻辑测试。
  2. 组件与状态容器的集成测试。

对于纯 Reducer,可以直接测试输入和输出:

ts 复制代码
interface CartState {
  items: Array<{
    productId: string
    quantity: number
  }>
}

type CartAction =
  | {
      type: 'add'
      productId: string
    }
  | {
      type: 'remove'
      productId: string
    }

export function cartReducer(
  state: CartState,
  action: CartAction,
): CartState {
  if (action.type === 'add') {
    const existing = state.items.find(
      (item) => item.productId === action.productId,
    )

    if (existing) {
      return {
        items: state.items.map((item) =>
          item.productId === action.productId
            ? {
                ...item,
                quantity: item.quantity + 1,
              }
            : item,
        ),
      }
    }

    return {
      items: [
        ...state.items,
        {
          productId: action.productId,
          quantity: 1,
        },
      ],
    }
  }

  return {
    items: state.items.filter(
      (item) => item.productId !== action.productId,
    ),
  }
}
ts 复制代码
it('重复添加商品时增加数量', () => {
  const state = {
    items: [
      {
        productId: 'p-1001',
        quantity: 1,
      },
    ],
  }

  expect(
    cartReducer(state, {
      type: 'add',
      productId: 'p-1001',
    }),
  ).toEqual({
    items: [
      {
        productId: 'p-1001',
        quantity: 2,
      },
    ],
  })
})

13.2 路由测试

需要验证:

  • 未登录用户访问受限页面。
  • 登录用户访问公开页面。
  • 无权限用户访问管理页面。
  • 页面不存在时显示 404。
  • 查询参数和路径参数是否正确解析。
  • 跳转后浏览器历史是否符合预期。

组件测试可以使用内存路由;关键权限流程则应使用 E2E 测试验证。


14. 使用 Playwright 编写端到端测试

14.1 配置 Playwright

创建 playwright.config.ts

ts 复制代码
import {
  defineConfig,
  devices,
} from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: Boolean(process.env.CI),
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,

  reporter: [
    ['list'],
    ['html', { open: 'never' }],
  ],

  use: {
    baseURL: 'http://127.0.0.1:5173',
    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'],
      },
    },
  ],

  webServer: {
    command: 'npm run dev -- --host 127.0.0.1',
    url: 'http://127.0.0.1:5173',
    reuseExistingServer: !process.env.CI,
  },
})

14.2 编写登录测试

创建 e2e/login.spec.ts

ts 复制代码
import {
  expect,
  test,
} from '@playwright/test'

test('用户可以使用正确账号登录', async ({
  page,
}) => {
  await page.goto('/login')

  await page
    .getByLabel('邮箱')
    .fill('user@example.com')

  await page
    .getByLabel('密码')
    .fill('correct-password')

  await page
    .getByRole('button', {
      name: '登录',
    })
    .click()

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

  await expect(
    page.getByRole('heading', {
      name: '控制台',
    }),
  ).toBeVisible()
})

14.3 验证错误流程

ts 复制代码
test('密码错误时显示提示且停留在登录页', async ({
  page,
}) => {
  await page.goto('/login')

  await page
    .getByLabel('邮箱')
    .fill('user@example.com')

  await page
    .getByLabel('密码')
    .fill('wrong-password')

  await page
    .getByRole('button', {
      name: '登录',
    })
    .click()

  await expect(
    page.getByRole('alert'),
  ).toContainText('账号或密码错误')

  await expect(page).toHaveURL(/\/login/)
})

14.4 不要使用固定等待时间

不推荐:

ts 复制代码
await page.waitForTimeout(3000)

推荐等待明确状态:

ts 复制代码
await expect(
  page.getByText('保存成功'),
).toBeVisible()

或等待响应:

ts 复制代码
const responsePromise = page.waitForResponse(
  (response) =>
    response.url().includes('/api/orders') &&
    response.request().method() === 'POST',
)

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

const response = await responsePromise

expect(response.ok()).toBe(true)

固定等待会拖慢测试,并且无法解决机器负载、网络速度和渲染时间变化造成的问题。

14.5 E2E 测试应该覆盖什么

优先覆盖:

  • 登录和退出
  • 注册和找回密码
  • 核心搜索流程
  • 下单和支付
  • 权限与访问控制
  • 文件上传
  • 数据新增、编辑和删除
  • 核心配置发布
  • 关键跨页面流程

不建议把每一个输入框样式和每一个工具函数都放到 E2E 测试中。


15. 管理端到端测试数据

不稳定测试的常见根源不是浏览器,而是测试数据。

15.1 每个测试独立创建数据

不要让测试依赖执行顺序:

text 复制代码
测试 A 创建订单
测试 B 修改测试 A 创建的订单
测试 C 删除测试 B 修改的订单

只运行测试 B 时,它会因为没有订单而失败。

正确做法是每个测试独立准备所需状态。

15.2 通过 API 准备数据

不需要在每个测试中都通过 UI 注册用户。

ts 复制代码
import {
  test as base,
  expect,
} from '@playwright/test'

interface RegisteredUser {
  id: string
  email: string
  password: string
}

type Fixtures = {
  registeredUser: RegisteredUser
}

const test = base.extend<Fixtures>({
  registeredUser: async (
    { request },
    use,
  ) => {
    const suffix = crypto.randomUUID()

    const user = {
      email: `e2e-${suffix}@example.com`,
      password: 'Test-password-123',
    }

    const createResponse = await request.post(
      '/api/test/users',
      {
        data: user,
      },
    )

    expect(createResponse.ok()).toBe(true)

    const createdUser =
      (await createResponse.json()) as RegisteredUser

    await use(createdUser)

    await request.delete(
      `/api/test/users/${createdUser.id}`,
    )
  },
})

test('新用户可以登录', async ({
  page,
  registeredUser,
}) => {
  await page.goto('/login')

  await page
    .getByLabel('邮箱')
    .fill(registeredUser.email)

  await page
    .getByLabel('密码')
    .fill(registeredUser.password)

  await page
    .getByRole('button', {
      name: '登录',
    })
    .click()

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

测试专用接口必须:

  • 仅在测试环境开放。
  • 具备严格访问控制。
  • 不允许在生产环境启用。
  • 能够创建和清理测试数据。
  • 保持幂等性或使用唯一数据标识。

15.3 登录状态复用

可以通过 Playwright 的 storageState 保存认证状态,避免所有测试都重复执行 UI 登录。

但是至少应保留一条完整的登录 E2E 测试,确保登录页面本身没有失效。


16. 视觉回归测试

视觉回归测试通过基准截图判断页面像素是否发生变化。

Playwright 示例:

ts 复制代码
import {
  expect,
  test,
} from '@playwright/test'

test('结算页面视觉效果保持稳定', async ({
  page,
}) => {
  await page.goto('/checkout')

  await expect(
    page.getByRole('main'),
  ).toHaveScreenshot('checkout-page.png', {
    animations: 'disabled',
  })
})

适合视觉测试的场景:

  • 设计系统组件
  • 登录和注册页
  • 营销落地页
  • 数据大屏
  • 打印页面
  • 响应式导航栏
  • 主题切换
  • 复杂表格
  • 图表和可视化

为了减少无意义差异,需要控制:

  • 浏览器版本
  • 操作系统
  • 字体
  • 时区
  • 语言
  • 视口尺寸
  • 动画
  • 当前时间
  • 随机数据
  • 网络数据
  • 图片资源

截图发生变化不一定代表测试失败,也可能是合法的设计修改。因此视觉差异应该经过人工审核后再更新基准图。


17. 可访问性测试

安装 Playwright 的 axe 集成后,可以执行自动化扫描:

ts 复制代码
import AxeBuilder from '@axe-core/playwright'
import {
  expect,
  test,
} from '@playwright/test'

test('登录页面没有自动检测到的可访问性违规', async ({
  page,
}) => {
  await page.goto('/login')

  const results = await new AxeBuilder({
    page,
  }).analyze()

  expect(
    results.violations,
    JSON.stringify(results.violations, null, 2),
  ).toEqual([])
})

自动化测试适合发现:

  • 表单缺少标签
  • 图片缺少替代文本
  • ARIA 属性不合法
  • 标题层级问题
  • 部分颜色对比度问题
  • 重复 ID
  • 按钮缺少可识别名称

但自动化工具不能替代人工测试。

还需要手工验证:

  • 只使用键盘能否完成核心流程。
  • Tab 顺序是否合理。
  • 焦点是否清晰可见。
  • 弹窗打开后焦点是否进入弹窗。
  • 弹窗关闭后焦点是否返回触发按钮。
  • 屏幕阅读器能否理解状态变化。
  • 页面放大后是否仍然可用。
  • 减少动画设置是否生效。

18. 前端性能测试

18.1 建立性能预算

可以根据项目目标定义预算:

指标 示例门槛
首屏 JavaScript 不超过约定大小
单个异步资源 不超过约定大小
Largest Contentful Paint 不高于团队目标
Cumulative Layout Shift 不高于团队目标
页面请求数量 不超过约定数量
Lighthouse 性能分数 不低于团队基线

门槛应基于真实业务、用户设备和网络环境制定,不能把示例数值直接当成所有项目的标准。

18.2 使用 Lighthouse CI

安装工具:

bash 复制代码
npm install -D @lhci/cli

创建 lighthouserc.cjs

js 复制代码
module.exports = {
  ci: {
    collect: {
      staticDistDir: './dist',
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:performance': [
          'warn',
          {
            minScore: 0.8,
          },
        ],
        'largest-contentful-paint': [
          'error',
          {
            maxNumericValue: 2500,
          },
        ],
        'cumulative-layout-shift': [
          'error',
          {
            maxNumericValue: 0.1,
          },
        ],
      },
    },
  },
}

添加命令:

json 复制代码
{
  "scripts": {
    "test:performance": "lhci autorun"
  }
}

实验室测试应该与真实用户监控结合。实验室环境容易重复执行,真实用户数据则能反映设备、地区、网络和业务场景差异。

18.3 防止资源体积回归

构建产物也可以设置门禁:

  • 主包体积突然增加时报警。
  • 新增大型依赖时要求说明。
  • 图片未压缩时阻止合并。
  • 重复依赖或无效 Polyfill 进入构建时报警。
  • Source Map、分析报告和构建统计保留为 CI 产物。

19. 前端安全测试

前端测试不能代替专业安全审计,但可以保护常见安全边界。

需要关注:

  • 用户输入是否被安全输出。
  • URL 参数是否可能造成开放重定向。
  • Token 是否被错误暴露。
  • 敏感信息是否进入日志。
  • 权限按钮隐藏后,后端是否仍然校验权限。
  • 第三方脚本是否受到控制。
  • 文件上传类型和大小是否受限。
  • CSP 等安全响应头是否存在。
  • 依赖是否存在已知漏洞。
  • 错误页面是否泄露内部信息。

一个常见误区是:

前端隐藏了删除按钮,所以普通用户无法删除数据。

隐藏按钮只是用户体验控制,不能构成安全边界。真正的权限必须由后端验证。

可以在 E2E 或接口测试中验证:

ts 复制代码
test('普通用户不能访问管理员页面', async ({
  page,
}) => {
  await loginAsNormalUser(page)

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

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

  await expect(
    page.getByText('无权访问'),
  ).toBeVisible()
})

还应该直接验证后端接口拒绝无权限请求,而不是只验证页面跳转。


20. 测试覆盖率应该怎么看

覆盖率常见指标包括:

  • Statements:语句覆盖率
  • Branches:分支覆盖率
  • Functions:函数覆盖率
  • Lines:行覆盖率

20.1 高覆盖率不代表高质量

下面的测试虽然执行了函数,但几乎没有验证:

ts 复制代码
it('调用提交函数', async () => {
  await submitOrder({
    productId: 'p-1001',
    quantity: 1,
  })
})

如果没有断言,函数即使返回错误数据,测试也可能通过。

更合理的测试应该验证:

ts 复制代码
it('提交成功后返回订单编号', async () => {
  const result = await submitOrder({
    productId: 'p-1001',
    quantity: 1,
  })

  expect(result).toMatchObject({
    status: 'created',
  })

  expect(result.orderId).toBeTruthy()
})

20.2 优先关注分支覆盖率

前端缺陷经常出现在条件分支:

ts 复制代码
if (isAdmin) {
  // 管理员逻辑
} else if (isOwner) {
  // 所有者逻辑
} else {
  // 普通用户逻辑
}

只执行其中一个分支,也可能得到较高的行覆盖率,但权限逻辑仍然没有被完整验证。

20.3 推荐的覆盖率治理方式

  • 新增核心业务必须包含测试。
  • 修复缺陷时先补充能够复现缺陷的测试。
  • 重点模块设置更高门槛。
  • 自动生成代码不纳入覆盖率。
  • 配置文件和类型声明合理排除。
  • 关注变更代码覆盖率。
  • 定期检查没有断言或断言无效的测试。
  • 对极高风险逻辑考虑使用变异测试。

覆盖率的作用是帮助发现测试盲区,而不是证明系统没有缺陷。


21. 如何治理不稳定测试

不稳定测试也叫 Flaky Test,表现为同一份代码有时通过、有时失败。

21.1 常见原因

  • 使用固定等待时间
  • 依赖真实网络
  • 测试数据冲突
  • 多个测试共享状态
  • 当前时间不可控
  • 随机数不可控
  • 异步操作没有正确等待
  • 动画尚未结束
  • 页面元素定位方式脆弱
  • 测试依赖执行顺序
  • CI 机器资源不足
  • 并行测试竞争同一数据

21.2 不要用重试掩盖问题

重试可以降低偶发基础设施问题对流水线的影响,但不能代替修复。

如果一个测试第一次失败、第二次通过,仍然说明它存在稳定性风险。

21.3 控制时间

时间相关逻辑最好通过依赖注入:

ts 复制代码
export function isExpired(
  expiresAt: Date,
  now: Date = new Date(),
): boolean {
  return expiresAt.getTime() <= now.getTime()
}

测试:

ts 复制代码
it('过期时间等于当前时间时视为已过期', () => {
  const now = new Date(
    '2026-08-27T10:00:00.000Z',
  )

  expect(isExpired(now, now)).toBe(true)
})

必要时也可以使用假定时器:

ts 复制代码
import {
  afterEach,
  beforeEach,
  expect,
  it,
  vi,
} from 'vitest'

beforeEach(() => {
  vi.useFakeTimers()

  vi.setSystemTime(
    new Date('2026-08-27T10:00:00.000Z'),
  )
})

afterEach(() => {
  vi.useRealTimers()
})

it('显示固定的当前日期', () => {
  expect(new Date().toISOString()).toBe(
    '2026-08-27T10:00:00.000Z',
  )
})

21.4 排查 Playwright 不稳定测试

重复执行:

bash 复制代码
npx playwright test e2e/checkout.spec.ts --repeat-each=20

单线程执行,排查并发问题:

bash 复制代码
npx playwright test e2e/checkout.spec.ts --workers=1

打开 Trace:

bash 复制代码
npx playwright show-trace path/to/trace.zip

排查时重点查看:

  • 失败前页面 DOM
  • 请求和响应
  • 控制台错误
  • 页面跳转
  • Cookie 和存储
  • 元素是否被遮挡
  • 测试数据是否冲突
  • 是否只有特定浏览器失败

22. 在 CI/CD 中执行测试

以下是 GitHub Actions 示例。

创建 .github/workflows/test.yml

yaml 复制代码
name: Frontend Test

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  quality:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Type check
        run: npm run typecheck

      - name: Lint
        run: npm run lint

      - name: Unit and component tests
        run: npm run test:coverage

      - name: Build
        run: npm run build

      - name: Install Chromium
        run: npx playwright install --with-deps chromium

      - name: End-to-end smoke tests
        run: npm run test:e2e -- --project=chromium

      - name: Upload coverage report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          if-no-files-found: ignore

      - name: Upload Playwright report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          if-no-files-found: ignore

22.1 推荐的流水线分层

提交代码时:

  • TypeScript 检查
  • ESLint
  • 受影响的单元测试

创建 Pull Request 时:

  • 完整单元测试
  • 组件测试
  • 覆盖率
  • 构建检查
  • Chromium 冒烟测试

合并到主分支时:

  • 完整 E2E
  • 部署预览环境测试
  • 视觉回归
  • 可访问性扫描

定时任务中:

  • Chromium、Firefox、WebKit 全量测试
  • 性能测试
  • 依赖安全扫描
  • 长时间稳定性测试

发布前:

  • 生产构建验证
  • 数据迁移兼容检查
  • 核心业务冒烟测试
  • 人工探索性测试
  • 回滚方案确认

23. 如何让代码更容易测试

测试困难往往不是测试工具的问题,而是代码结构的问题。

23.1 将业务逻辑与 UI 分离

不推荐把所有逻辑都写在组件里:

tsx 复制代码
function CheckoutPage() {
  // 请求、金额计算、权限、埋点、
  // 表单校验和渲染全部混在一起
}

更合理的结构是:

text 复制代码
页面组件
├─ 用户交互与渲染
├─ 业务服务
├─ 纯计算函数
├─ 接口适配器
└─ 状态管理

这样可以:

  • 单独测试纯函数。
  • 使用组件测试验证交互。
  • 使用接口模拟验证异常状态。
  • 使用少量 E2E 验证整体流程。

23.2 对不可控依赖进行注入

不推荐:

ts 复制代码
export function createId() {
  return `${Date.now()}-${Math.random()}`
}

更容易测试的实现:

ts 复制代码
interface IdDependencies {
  now: () => number
  random: () => number
}

export function createId(
  dependencies: IdDependencies = {
    now: Date.now,
    random: Math.random,
  },
) {
  return [
    dependencies.now(),
    dependencies.random(),
  ].join('-')
}

测试:

ts 复制代码
it('根据时间和随机数创建 ID', () => {
  expect(
    createId({
      now: () => 1000,
      random: () => 0.5,
    }),
  ).toBe('1000-0.5')
})

23.3 使用稳定的公开契约

组件应该拥有:

  • 清晰的 Props
  • 明确的事件
  • 合理的语义标签
  • 可预测的加载和错误状态
  • 稳定的 API 数据模型

不要为了测试而导出内部私有函数。优先通过公开行为验证功能。


24. 老项目如何逐步补充测试

不要一开始就要求整个老项目达到很高覆盖率。

第一阶段:建立基线

先加入:

  • TypeScript 检查
  • ESLint
  • 测试运行器
  • CI 测试命令
  • 一条关键 E2E 冒烟测试

目标是让测试能够稳定运行。

第二阶段:保护高风险逻辑

优先为以下内容补测试:

  • 金额计算
  • 权限判断
  • 数据转换
  • 状态机
  • 表单验证
  • 历史缺陷频发模块

第三阶段:修复缺陷时补回归测试

推荐流程:

  1. 编写能够复现缺陷的测试。
  2. 确认测试失败。
  3. 修复代码。
  4. 确认测试通过。
  5. 将测试作为长期回归保护。

第四阶段:覆盖关键用户流程

建立少量高价值 E2E:

  • 登录
  • 核心查询
  • 创建业务数据
  • 修改业务数据
  • 提交或发布
  • 权限限制

第五阶段:建立质量门禁

逐步增加:

  • 覆盖率门槛
  • 变更代码测试要求
  • 视觉回归
  • 可访问性检查
  • 性能预算
  • 跨浏览器测试

老项目测试建设的重点是持续降低风险,而不是一次性追求漂亮数字。


25. 常见错误与反模式

25.1 只测试实现细节

不推荐:

ts 复制代码
expect(component.state.loading).toBe(true)

推荐:

ts 复制代码
expect(
  screen.getByRole('button', {
    name: '提交中...',
  }),
).toBeDisabled()

25.2 为了覆盖率编写没有价值的测试

不推荐:

ts 复制代码
it('组件能够渲染', () => {
  render(<UserPage />)
})

如果没有任何断言,这个测试很难保护实际行为。

25.3 大量使用快照测试

大型 DOM 快照通常存在几个问题:

  • 变化频繁。
  • 难以人工审核。
  • 开发者容易直接更新快照。
  • 无法说明用户行为是否正确。

快照测试适合结构稳定、输出明确的小范围内容,不应替代行为断言。

25.4 一个测试验证过多内容

测试过长时,失败原因难以判断。

应该根据业务行为拆分,但不要拆成大量只验证一行细节的测试。

25.5 测试之间共享可变状态

每个测试都应该能够单独运行:

bash 复制代码
npx vitest run src/features/order/order.test.ts

如果单独运行失败,说明测试可能依赖其他用例创建的状态。

25.6 使用真实生产服务

自动化测试不应该:

  • 向生产环境创建订单。
  • 给真实用户发送短信。
  • 发起真实支付。
  • 删除生产数据。
  • 调用真实第三方计费接口。

测试环境必须与生产环境明确隔离。

25.7 遇到异步问题就增加等待时间

ts 复制代码
await sleep(5000)

这通常只是暂时掩盖竞态条件。应该等待具体的页面状态、请求结果或业务事件。

25.8 把所有问题都交给 E2E

E2E 测试不是越多越好。

如果一个金额函数可以用 10 毫秒的单元测试验证,就不应该完全依赖数分钟的浏览器流程才能发现错误。


26. 团队测试规范与质量门禁

一套可执行的团队规范可以包含以下内容。

26.1 Pull Request 要求

每个 Pull Request 应回答:

  • 变更了什么行为?
  • 哪些测试证明功能正确?
  • 是否包含异常和边界场景?
  • 是否影响已有 E2E?
  • 是否产生视觉变化?
  • 是否改变接口契约?
  • 是否存在暂未覆盖的风险?

26.2 缺陷修复要求

对于可自动化复现的缺陷:

  • 修复前增加失败测试。
  • 修复后确认测试通过。
  • 测试名称描述真实缺陷场景。
  • 不通过删除原有断言来"修复"测试。

26.3 质量门禁建议

可以设置:

  • 类型检查必须通过。
  • Lint 必须通过。
  • 单元和组件测试必须通过。
  • 构建必须成功。
  • 核心 E2E 必须通过。
  • 覆盖率不能低于项目基线。
  • 新增高风险逻辑必须有测试。
  • 视觉差异必须经过审核。
  • 严重可访问性问题阻止合并。

26.4 应持续观察的指标

除了覆盖率,还应关注:

  • 测试总耗时
  • 测试失败率
  • 不稳定测试比例
  • 缺陷逃逸率
  • 回归缺陷数量
  • 核心流程覆盖情况
  • 测试修复平均时间
  • CI 因基础设施失败的次数

如果测试越来越慢、越来越不稳定,团队最终会开始绕过测试。因此测试套件本身也需要维护。


27. 前端测试落地检查清单

静态质量

  • 启用 TypeScript 严格模式
  • 配置 ESLint
  • 配置样式检查
  • CI 中执行类型检查
  • CI 中执行 Lint
  • 锁定依赖版本

单元测试

  • 核心业务规则有测试
  • 边界值有测试
  • 错误输入有测试
  • 时间和随机数可控制
  • 测试不依赖网络
  • 测试之间相互独立

组件测试

  • 使用语义方式定位元素
  • 覆盖正常状态
  • 覆盖加载状态
  • 覆盖空状态
  • 覆盖错误状态
  • 覆盖禁用状态
  • 覆盖用户交互
  • 不依赖组件内部实现

接口测试

  • 请求成功有测试
  • 业务失败有测试
  • 401 和 403 有测试
  • 500 有测试
  • 超时有测试
  • 异常响应结构有测试
  • 未处理请求会使测试失败
  • Mock 数据与接口契约保持一致

端到端测试

  • 登录流程有测试
  • 核心业务流程有测试
  • 权限控制有测试
  • 每个测试独立准备数据
  • 测试结束后清理数据
  • 不使用固定等待时间
  • 失败时保存 Trace
  • 至少在 Chromium 中执行
  • 定期执行跨浏览器测试

非功能测试

  • 关键页面有视觉回归
  • 关键页面有自动化可访问性扫描
  • 核心流程完成键盘测试
  • 建立性能预算
  • 监控构建产物体积
  • 扫描依赖安全问题

CI/CD

  • Pull Request 自动执行测试
  • 主分支受到质量门禁保护
  • 测试报告可下载
  • 覆盖率报告可查看
  • E2E 失败截图和 Trace 被保留
  • 不稳定测试有负责人和修复期限
  • 发布前执行生产构建冒烟测试

28. 常见问题

28.1 测试覆盖率达到多少才合适?

不存在适合所有项目的统一数字。

普通展示组件和支付金额计算的风险完全不同。建议先建立当前基线,再逐步提高高风险模块的覆盖率。

比全局覆盖率更重要的是:

  • 核心业务路径是否覆盖。
  • 条件分支是否覆盖。
  • 历史缺陷是否有回归测试。
  • 新增代码是否有合理测试。
  • 测试是否包含有效断言。

28.2 应该先写测试还是先写代码?

两种方式都可以。

适合测试先行的场景:

  • 业务规则明确。
  • 输入输出清晰。
  • 修复可复现缺陷。
  • 编写状态机和数据转换。
  • 开发公共组件或公共库。

探索性 UI 开发可以先完成原型,再补充行为测试,但不应该长期依赖"以后再补"。

28.3 组件测试应该 Mock 子组件吗?

默认不要全部 Mock。

如果子组件只是普通展示组件,可以直接渲染。以下情况可以考虑替换:

  • 子组件依赖复杂第三方服务。
  • 子组件初始化成本很高。
  • 子组件已经被独立充分测试。
  • 当前测试只关心父组件与它的公开契约。

28.4 测试私有函数是否合理?

通常不建议。

私有函数属于实现细节。应该通过公开函数、组件行为或模块接口间接验证。

如果私有函数逻辑非常复杂,可能意味着它应该被提取成独立的业务模块。

28.5 E2E 应该连接真实后端吗?

关键业务流程最好在可控测试环境中连接真实后端,以验证完整链路。

但异常场景、第三方服务和难以构造的边界条件,可以通过请求拦截或测试替身验证。

常见组合是:

  • 单元和组件测试大量使用接口模拟。
  • 核心 E2E 使用真实测试后端。
  • 少量 E2E 使用网络拦截构造异常场景。
  • 生产环境发布后只执行安全、只读的冒烟检查。

28.6 为什么本地通过,CI 失败?

常见原因包括:

  • 大小写敏感差异
  • Node.js 版本不同
  • 时区和语言不同
  • 字体不同
  • 浏览器版本不同
  • 测试依赖执行顺序
  • CI 性能较低
  • 未提交快照或测试资源
  • 本地存在未声明的环境变量
  • 代码意外访问了本机服务

应该让本地与 CI 尽量使用一致的运行时、锁文件、环境变量和浏览器版本。

28.7 是否需要同时使用 Vitest、Jest、Playwright 和 Cypress?

通常不需要。

一个新建的 Vite 项目可以选择:

  • Vitest:单元和组件测试
  • Testing Library:用户行为查询与交互
  • MSW:接口模拟
  • Playwright:端到端测试
  • axe-core:可访问性检查
  • Lighthouse CI:性能预算

已有 Jest 或 Cypress 项目如果运行稳定,可以继续使用现有体系。


29. 总结

完整的前端测试体系不是安装一个测试框架,而是建立从代码提交到生产发布的持续验证能力。

一套健康的前端测试体系应该做到:

  1. 使用类型检查和 Lint 尽早发现问题。
  2. 使用单元测试保护核心业务规则。
  3. 使用组件测试验证用户可见行为。
  4. 使用接口模拟稳定覆盖异步和异常场景。
  5. 使用少量高价值 E2E 保护关键业务流程。
  6. 使用视觉回归发现布局和样式变化。
  7. 使用自动化与人工方式检查可访问性。
  8. 使用性能预算防止体验持续退化。
  9. 在 CI/CD 中自动执行测试和质量门禁。
  10. 持续治理测试速度、稳定性和维护成本。

测试的目标不是得到一份全绿报告,而是让团队对每次修改和每次发布建立有证据的信心。

真正优秀的测试通常具有三个特征:

  • 在缺陷发生时能够及时失败。
  • 在正确重构时不会无意义失败。
  • 失败后能够快速说明问题发生在哪里。

当测试成为开发流程的一部分,而不是项目结束后的补充工作时,前端系统才能在持续迭代中保持稳定。


30. 参考资料

相关推荐
咖啡星人k2 小时前
2026 AI 自动化测试:让 AI 帮你写用例、跑测试、修 Bug(MonkeyCode 云端实战)
人工智能·功能测试·单元测试·测试用例·bug·集成测试
钛态9 小时前
Vite 中的 CSS 工程化:从 CSS Modules 到 UnoCSS 的渐进式迁移
前端·vue·react·web
Csvn11 小时前
原型链、this 与闭包内存:三座深水区一次打通(含内存泄漏排查实战)
前端·javascript
葡萄城技术团队11 小时前
如何通过 JSON 数据源 / Web API 快速构建轻量级报表(Report)?
前端·json·原型模式
浪兎兎11 小时前
【Java Web】Servlet + JSP 笔记
java·前端·servlet
Patrick_Wilson12 小时前
iOS Safari Clipboard API 权限弹窗问题
前端·ios·safari
OpenTiny社区13 小时前
Naive UI × GenUI SDK:自定义物料库搭建实战
前端·ai编程
kyriewen13 小时前
我拿 4 个真实前端任务试了 GLM-5.3 Flash:代码一遍跑通,账单 4 分钱
前端·程序员·ai编程
IT_陈寒13 小时前
Vue的响应式更新有时候真的不听话
前端·人工智能·后端