11 · 前端测试策略
本篇解决什么问题: 测试不是为了"覆盖率好看",而是为了敢改代码。 本篇给出测试金字塔在前端的落地方式:每层测什么、用什么工具、各写多少, 以及覆盖率陷阱与Mock 的正确姿势。
📌 核心原则:越靠近用户真实使用方式的测试越可信,但也越慢越脆弱——要按金字塔配比,不要只写一头。
开篇速览
| 层 | 测什么 | 工具 | 占比建议 |
|---|---|---|---|
| 单元测试 | 纯函数、工具、算法 | Vitest / Jest | ~70%(多、快) |
| 组件/集成测试 | 组件渲染与交互、多组件协作 | Testing Library / Vue Test Utils | ~20% |
| E2E | 关键业务全流程 | Playwright / Cypress | ~10%(少、慢、关键路径) |
| 视觉回归 | 样式/布局意外变化 | Playwright 截图 / Storybook | 按需 |
一、为什么是金字塔(而不是倒过来)
| 单元测试 | E2E | |
|---|---|---|
| 速度 | 毫秒级 | 秒~分钟级 |
| 稳定性 | 高(无环境依赖) | 低(网络、后端、时序都可能 flaky) |
| 定位问题 | 精确 | 只知道"流程挂了",要再查 |
| 可信度 | 单块逻辑对,不等于组合起来对 | 最接近真实用户 |
🎯 结论:大量快的单测打底 + 少量 E2E 覆盖关键路径。 反过来(只有 E2E)会得到一个慢、脆、难维护的测试套件,最后被人关掉。
二、单元测试:Vitest
为什么选 Vitest:与 Vite 共享配置、ESM/TS 开箱即用、快(比 Jest 快数倍)。
ts
// utils/sum.ts
export const sum = (a: number, b: number) => a + b
// utils/sum.test.ts
import { describe, it, expect } from 'vitest'
import { sum } from './sum'
describe('sum', () => {
it('adds two numbers', () => {
expect(sum(1, 2)).toBe(3)
})
it('handles negatives', () => {
expect(sum(-1, 1)).toBe(0)
})
})2.1 什么该写单测
| ✅ 值得写 | ❌ 不值得写 |
|---|---|
| 纯函数(格式化、计算、校验) | 只有 getter/setter 的样板代码 |
| 复杂条件分支(价格计算、权限判断) | 一行调用的封装 |
| 容易回归的 bug 修复(补一个用例) | 第三方库本身的行为 |
| 工具库、SDK | UI 细节(交给组件测试) |
💡 经验法则:有分支、有计算、有边界才写单测。
三、组件测试:Testing Library
核心理念:像用户一样测试——不测内部实现(state),测用户能看到和操作的。
tsx
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Counter from './Counter'
test('点击后计数增加', async () => {
const user = userEvent.setup()
render(<Counter />)
expect(screen.getByText('count: 0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /增加/i }))
expect(screen.getByText('count: 1')).toBeInTheDocument()
})3.1 查询方式优先级(很重要)
| 优先级 | 查询 | 说明 |
|---|---|---|
| 1️⃣ | getByRole | ✅ 最贴近用户(也顺带保证 A11y) |
| 2️⃣ | getByLabelText | 表单元素 |
| 3️⃣ | getByText | 文本内容 |
| 4️⃣ | getByTestId | ⚠️ 最后手段(用户看不到 testid) |
🎯 用
getByRole('button', { name: '提交' })而不是container.querySelector('.btn')——前者在重构样式/结构后仍然通过,后者一改类名就红。这就是"测行为不测实现"。
3.2 Vue 对应方案
ts
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
test('点击后计数增加', async () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('count: 0')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('count: 1')
})四、Mock:用 MSW 而不是手写 fetch mock
问题:vi.mock('fetch') 是mock 实现,一旦后端改了字段名,测试依然绿 → 假的安全感。
解法:MSW(Mock Service Worker) 在网络层拦截,保留真实的请求/响应链路。
ts
// mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/user/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: '张三' })
}),
http.get('/api/user/999', () => new HttpResponse(null, { status: 404 })),
]
// mocks/server.ts(Node 环境)
import { setupServer } from 'msw/node'
export const server = setupServer(...handlers)
// setup.ts
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())MSW 的额外价值:同一套 handlers 可以用于开发(后端没好时前端照常开发)。
4.1 Mock 的原则
| 原则 | 说明 |
|---|---|
| mock 边界,不 mock 内部 | mock 网络/时间/随机数,不要 mock 你自己的模块 |
| mock 越靠近真实越好 | MSW > vi.mock('axios') > 手写对象 |
| 不要过度 mock | mock 太多等于在测 mock 自己 |
五、E2E:Playwright
只测关键路径(登录、下单、支付等),不要试图覆盖所有分支。
ts
import { test, expect } from '@playwright/test'
test('用户可以登录并看到主页', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('用户名').fill('test')
await page.getByLabel('密码').fill('123456')
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL('/dashboard')
await expect(page.getByText('欢迎回来')).toBeVisible()
})5.1 稳定性(E2E 最大的敌人)
| 做法 | 说明 |
|---|---|
用 getByRole / getByLabel | 不依赖易变的选择器 |
| 依赖自动等待 | Playwright 的 expect 自带重试;避免 waitForTimeout |
| 每个用例独立 | 独立登录态/数据,不依赖执行顺序 |
| 失败自动截图/录屏 | 便于定位 |
| 重试策略 | CI 里允许 1~2 次重试(治标),但flaky 用例要修(治本) |
六、视觉回归(按需)
解决:CSS 改动导致意外的视觉变化。
ts
test('首页视觉快照', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveScreenshot('home.png') // 与基线对比
})| 注意 | 说明 |
|---|---|
| 动态内容要屏蔽 | 时间、广告、随机数 → 用 mask 或固定数据 |
| 基线要评审 | 更新基线必须走 review(否则"改了就更新"等于没测) |
| 跨浏览器/分辨率 | 多了成本会上升,通常只测一个分辨率 |
七、覆盖率陷阱
bash
vitest run --coverage| ❌ 陷阱 | ✅ 正确认知 |
|---|---|
| "覆盖率 90% 说明测得好" | 覆盖率只说明代码被执行过,不说明断言正确 |
| 为了覆盖率写"无断言测试" | 这种测试只会让重构更困难 |
| 追求 100% | 边际成本极高;核心逻辑要求高覆盖即可 |
建议的覆盖率策略:
| 代码类型 | 目标 |
|---|---|
| 核心业务逻辑(价格、权限、状态机) | 80%+ |
| 工具函数 | 70%+ |
| UI 组件 | 不追数字,关键交互有用例即可 |
| 配置文件/类型定义 | 不需要 |
🎯 更好的指标:"改坏代码时测试会不会红"。 可以手动改坏一行,跑测试看是否捕获——这比覆盖率数字真实得多。
八、CI 中的测试
yaml
- run: npm run test:unit -- --coverage # 快,每次 PR 都跑
- run: npm run test:e2e # 慢,可在合并主干或 nightly 跑| 策略 | 说明 |
|---|---|
| PR 跑单测 + 组件测试 | 秒级~分钟级,快速反馈 |
| E2E 按需 | 主干合并或定时跑(避免拖慢 PR) |
| 失败即阻断 | 否则测试会逐渐被忽略 |
自检清单
- [ ] 能说出测试金字塔三层的工具、职责、建议占比
- [ ] 能用 Testing Library 按
getByRole写组件测试(测行为不测实现) - [ ] 知道用 MSW 在网络层 mock,而不是 mock 自己的模块
- [ ] 能写出稳定的 E2E(自动等待、独立用例、稳定选择器)
- [ ] 能说出覆盖率的三个陷阱
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "测试越多越好" | 慢、脆的测试会被关掉;按金字塔配比 |
| "测试要覆盖内部实现" | 测用户可见行为,重构时才不会全红 |
| "覆盖率 100% 才安全" | 覆盖率 ≠ 断言质量 |
| "E2E 覆盖所有分支" | 太慢太脆;E2E 只测关键路径 |
| "mock 掉所有依赖" | 过度 mock 等于在测 mock |
动手练习
- 给一个价格计算函数写单测,覆盖**折扣、满减、边界(0 与负数)**三种情况。
- 用 Testing Library 重构一个"用
querySelector('.btn')"的旧测试,改为getByRole,然后改掉组件的 CSS 类名验证测试仍然通过。 - 用 MSW 替换项目里的手写 fetch mock,写一条**错误态(500)**的用例。
相关文档
- CI 中的测试门禁 → A16 · CI/CD 与部署运维 · A28 · 代码生成与规范化工具链
- 线上问题兜底 → A12 · 前端可观测性与线上排障
- 组件 API 设计(可测性)→ A14 · 组件 API 设计与设计系统
- 手写逻辑的测试 → A18 · 手写题与代码设计