前端测试不是单纯追求覆盖率,也不是为了证明"代码能运行"。它真正解决的是:在需求变化、代码重构、多人协作和频繁发布的情况下,持续证明关键业务仍然可用。
前言
现代前端项目已经不再是简单的页面展示。
一个完整的前端应用通常包含:
- 业务规则与数据计算
- 表单验证
- 权限控制
- 状态管理
- 异步请求
- 路由跳转
- 浏览器兼容
- 响应式布局
- 可访问性
- 性能指标
- 前后端接口协作
- 第三方服务集成
仅靠人工点击页面,很难持续覆盖所有分支。
自动化测试的价值,是把团队对系统行为的理解转化为可重复执行的代码。当开发者修改功能时,测试能够快速回答几个关键问题:
- 原来的功能是否仍然正常?
- 新功能是否满足预期?
- 边界条件和异常场景是否被处理?
- 页面在真实浏览器里是否可以完成关键流程?
- 当前版本是否具备发布条件?
本文将围绕测试策略、单元测试、组件测试、集成测试、端到端测试、接口模拟、视觉回归、可访问性、性能测试、覆盖率和 CI/CD,建立一套完整的前端测试体系。
目录
- 前端测试解决什么问题
- 前端测试的完整分类
- 如何设计测试分层
- 主流前端测试工具怎么选
- 建立测试目录和命名规范
- 使用 Vitest 搭建基础测试环境
- 单元测试完整实践
- 测试用例的设计方法
- React 组件测试实践
- Vue、Angular 等框架如何迁移测试思路
- 异步请求与接口模拟
- Mock、Stub、Spy 和 Fake 的区别
- 状态管理与路由测试
- 使用 Playwright 编写端到端测试
- 管理端到端测试数据
- 视觉回归测试
- 可访问性测试
- 前端性能测试
- 前端安全测试
- 测试覆盖率应该怎么看
- 如何治理不稳定测试
- 在 CI/CD 中执行测试
- 如何让代码更容易测试
- 老项目如何逐步补充测试
- 常见错误与反模式
- 团队测试规范与质量门禁
- 前端测试落地检查清单
- 常见问题
- 总结
- 参考资料
1. 前端测试解决什么问题
1.1 防止功能回归
项目持续迭代时,一处看似普通的修改可能影响多个功能:
- 修改公共按钮导致多个页面样式变化
- 修改请求拦截器导致登录状态失效
- 修改金额计算逻辑导致优惠结果错误
- 修改路由守卫导致无权限用户进入后台
- 修改状态管理逻辑导致页面数据无法同步
回归测试可以在代码合并或发布之前发现这些问题。
1.2 降低重构风险
没有测试保护的重构,本质上是在依靠开发者记忆判断系统有没有被破坏。
测试充分时,可以按照下面的流程重构:
- 运行现有测试,确认基线正常。
- 修改内部实现。
- 再次运行测试。
- 如果外部行为保持不变,测试应继续通过。
- 如果行为发生变化,检查这是需求变化还是回归缺陷。
好的测试关注外部行为,而不是内部实现,因此不会因为变量重命名或函数拆分就大量失败。
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 测试,会在真实浏览器中模拟用户操作。
例如:
- 打开登录页。
- 输入账号和密码。
- 点击登录。
- 进入控制台。
- 创建订单。
- 提交订单。
- 在订单列表中看到新订单。
端到端测试最接近用户真实体验,但运行速度和维护成本通常也最高。
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 查询方式怎么选
优先使用用户能够感知的语义:
getByRolegetByLabelTextgetByPlaceholderTextgetByTextgetByDisplayValuegetByAltTextgetByTitlegetByTestId
三类查询的用途:
| 查询 | 找不到元素 | 适用场景 |
|---|---|---|
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 状态管理测试
状态管理通常分为两层:
- 纯状态逻辑测试。
- 组件与状态容器的集成测试。
对于纯 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 冒烟测试
目标是让测试能够稳定运行。
第二阶段:保护高风险逻辑
优先为以下内容补测试:
- 金额计算
- 权限判断
- 数据转换
- 状态机
- 表单验证
- 历史缺陷频发模块
第三阶段:修复缺陷时补回归测试
推荐流程:
- 编写能够复现缺陷的测试。
- 确认测试失败。
- 修复代码。
- 确认测试通过。
- 将测试作为长期回归保护。
第四阶段:覆盖关键用户流程
建立少量高价值 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. 总结
完整的前端测试体系不是安装一个测试框架,而是建立从代码提交到生产发布的持续验证能力。
一套健康的前端测试体系应该做到:
- 使用类型检查和 Lint 尽早发现问题。
- 使用单元测试保护核心业务规则。
- 使用组件测试验证用户可见行为。
- 使用接口模拟稳定覆盖异步和异常场景。
- 使用少量高价值 E2E 保护关键业务流程。
- 使用视觉回归发现布局和样式变化。
- 使用自动化与人工方式检查可访问性。
- 使用性能预算防止体验持续退化。
- 在 CI/CD 中自动执行测试和质量门禁。
- 持续治理测试速度、稳定性和维护成本。
测试的目标不是得到一份全绿报告,而是让团队对每次修改和每次发布建立有证据的信心。
真正优秀的测试通常具有三个特征:
- 在缺陷发生时能够及时失败。
- 在正确重构时不会无意义失败。
- 失败后能够快速说明问题发生在哪里。
当测试成为开发流程的一部分,而不是项目结束后的补充工作时,前端系统才能在持续迭代中保持稳定。