测试
前端测试最难的从来不是写断言,而是决定测什么、在哪一层测、以及怎么让测试不变成维护负担。本文按"先策略、后工具"的顺序展开:金字塔与分层 → Vitest 单元测试 → 组件测试 → E2E → mock 边界 → 覆盖率与 CI。示例基于 Vitest 3(2025-01 发布,Vite 6+ 时代的默认单测工具)+ Playwright(当前主线),可复制进
npm create vite@latest+vitest模板直接运行。
一、测试策略:先分层,再写码
1.1 测试金字塔(前端版)
| 层 | 覆盖面 | 速度 | 成本 | 主要工具 |
|---|---|---|---|---|
| 单元测试 | 纯函数、状态逻辑、utils | 极快(ms) | 低 | Vitest |
| 组件/集成测试 | 组件行为、store、表单流 | 快 | 中 | VTU / Testing Library |
| E2E | 关键用户路径(注册、下单) | 慢(s~min) | 高 | Playwright |
| 视觉回归 | 样式不漂移 | 慢 | 高 | Playwright 截图对比 |
金字塔的隐喻:底层多、顶层少。E2E 只覆盖"收益大于成本"的少数关键路径;把业务规则尽量下沉为可单测的纯逻辑(如把"折扣计算""权限判断"从组件里抽成函数),比在 E2E 里点一万次更划算。
1.2 三类被测对象的取舍
- 实现细节不测:内部变量名、私有函数、CSS 类名——测了只会让重构寸步难行;
- 行为/契约优先:输入→输出、用户可感知的交互结果;
- 先写会坏的东西:被测代码未来会改且改了不能发现问题的,才值得写测试(防回归),一次性脚本不用测。
二、Vitest 单元测试
ts
// utils/price.ts
export function calcDiscount(price: number, code?: string) {
if (code === 'SAVE20') return { price: round2(price * 0.8), off: round2(price * 0.2) }
return { price, off: 0 }
}
const round2 = (n: number) => Math.round(n * 100) / 100ts
// utils/price.test.ts
import { describe, it, expect } from 'vitest'
import { calcDiscount } from './price'
describe('calcDiscount', () => {
it('无券时不优惠', () => {
expect(calcDiscount(100)).toEqual({ price: 100, off: 0 })
})
it('SAVE20 打八折并返回立减额', () => {
expect(calcDiscount(100, 'SAVE20')).toEqual({ price: 80, off: 20 })
})
it('金额做两位小数', () => {
expect(calcDiscount(99.99, 'SAVE20').price).toBe(79.99) // 80.00? 见实现:round2(79.992)=79.99
})
})sh
npx vitest run # CI 一次性跑
npx vitest # watch 模式(开发)
npx vitest --coverage # 覆盖率(v8 provider)ts
// vitest.config.ts —— 环境:纯逻辑用 node;涉及 DOM 用 jsdom/happy-dom
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts?(x)'],
coverage: { provider: 'v8', thresholds: { lines: 80, functions: 80 } },
},
})三、组件测试:测行为,不测实现
3.1 查询哲学(Testing Library 系)
组件测试应该像"用户使用"那样找元素:优先角色/可见文本,而不是类名与结构:
tsx
// Counter.test.tsx(React + @testing-library/react + user-event)
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
test('点击按钮计数增加且可见', async () => {
render(<Counter />)
const btn = screen.getByRole('button', { name: '增加' }) // 可访问性角色,而非 .btn
await userEvent.click(btn)
expect(screen.getByText('1 次')).toBeInTheDocument()
})Vue 侧两种风格:
ts
// ① @vue/test-utils:wrapper 驱动
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
it('点击 +1', async () => {
const wrapper = mount(Counter)
await wrapper.get('button.inc').trigger('click')
expect(wrapper.text()).toContain('1')
})
// ② @testing-library/vue:行为优先(推荐,与 React 风格统一)
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import Counter from './Counter.vue'
it('点击 +1', async () => {
render(Counter)
await userEvent.click(screen.getByRole('button', { name: '+' }))
expect(screen.getByText(/已点击 1 次/)).toBeTruthy()
})坑 1:快照测试(
toMatchSnapshot)只适合"稳定且低价值"的整块 UI,小改动就红一片、reviewer 肌肉记忆地-u——拿快照当回归保障是大坑;能断言的用行为断言,快照宁缺毋滥。
3.2 组件测试的异步与网络
ts
// 组件内 fetch 数据 → 用 MSW(Mock Service Worker)在网络层拦截,最接近真实
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { render, screen } from '@testing-library/vue'
import UserCard from './UserCard.vue'
const server = setupServer(
http.get('/api/user/1', () => HttpResponse.json({ name: 'Ada' })),
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
it('加载后显示用户名', async () => {
render(UserCard, { props: { id: 1 } })
expect(await screen.findByText('Ada')).toBeTruthy() // findBy* 自动等待异步
})四、Playwright E2E:测真实关键路径
ts
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test'
test('游客可完成下单', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: '加入购物车' }).first().click()
await page.getByRole('button', { name: '去结算' }).click()
// web-first 断言:自动重试直到出现,代替手写 sleep
await expect(page.getByText('订单提交成功')).toBeVisible({ timeout: 10_000 })
// 可截图留存(trace 失败时自动留痕)
await page.screenshot({ path: 'test-results/order-ok.png' })
})ts
// playwright.config.ts(要点)
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } }, // 移动视口路径
],
use: { trace: 'on-first-retry', baseURL: 'http://localhost:4173' },
webServer: { command: 'npm run preview', port: 4173, reuseExistingServer: true },
})要点:
- 自动等待内建(actionability),别用
sleep;重试断言用expect(...).toBeX({ timeout }); - 数据准备与隔离:E2E 用独立测试库/账号,用
test.beforeEach造数据,断言后别依赖清理顺序; npx playwright codegen录制探索;失败自动截 trace,本地npx playwright show-trace回放。
五、mock 的边界:能少则少
| 该 mock | 不该 mock |
|---|---|
| 网络层(MSW)——外部服务不可控 | 自己团队刚写的纯函数(应该真测) |
浏览器能力(matchMedia、IntersectionObserver) | 被测组件自己的内部逻辑(不然测了个寂寞) |
| 第三方 SDK/鉴权(真实环境有副作用) | 数据结构本身(mock 数据要贴近真实形状,用 zod schema 校验) |
坑 2:mock 粒度错了,测试会"绿得虚伪"——组件测试里把内部 store 全 mock 掉,最终只验证了模板拼接。规则:mock 一切"跨进程边界"的东西,测一切"自己进程内"的逻辑。
六、覆盖率与 CI 门禁
sh
npx vitest run --coverage && npx playwright test- 覆盖率是发现盲区的地图,不是目标本身(100% 覆盖也可能全是废话测试);
- CI 里两条门禁分开:单测(秒级、卡阈值)+ E2E(分钟级、卡关键路径);
- 慢测试标注与切分:
--shard=1/4把 E2E 分片并行; - 配合 git pre-push / PR 检查:覆盖率下降、关键 spec 失败即拦截。
七、常见坑速查
- 测试依赖执行顺序/共享状态:用例间泄漏(module 级 let、没清理的 server)——
beforeEach重置,describe内隔离; - 异步没等待:
findBy*/waitFor不用的轮询、await忘加 → 偶发"绿了又红"; - E2E 用真实数据/真实支付:环境串联炸一片——独立账号/沙箱/
testId数据标记; - 快照滥用(§3.1 坑 1);
- mock 掉一切(§5 坑 2);
- 把时间花在重复断言上:同一逻辑在单测、组件测、E2E 各写一遍——在最有价值的一层写一份,其他层做轻验证;
- 环境不一致:本地 node 环境、CI 用
--reporter/容器有差异(字体、viewport、时区)——E2E 设固定 viewport/timezoneId; - 测试变"例程"没人看结果:红灯没人修 = 没有测试文化——把失败塞进通知与 PR 检查。
状态与参考
- 状态:已收录(2026-09-02,由前端领域规划清单「测试|单元/组件/E2E 测试策略」转正式)。
- 版本:基于 Vitest 3(2025-01 发布,适配 Vite 6+;断言/钩子/覆盖率 API 与 2.x 兼容演进)+ Playwright 当前主线(web-first 断言、trace、shard 分片为稳定能力);MSW 2.x。示例为标准用法,可复制进
npm create vite@latest(含 test 模板)运行;版本细节以官方文档为准。 - 参考:Vitest 官方文档、Vue Test Utils、Testing Library(React/Vue)、Playwright 文档、MSW、Vue 官方测试指南。
- 阅读联动:Vue 项目测试与 Vue 3 生态 Vitest 子主题 呼应;CI 门禁与部署见项目部署;被测代码质量前提见 TypeScript 工程实践。
下一步
- [ ] 用一个 Vite + Vue 工程落地"纯逻辑单测 + 组件行为测试(MSW)+ Playwright 关键路径"三层示例并跑通 CI
- [ ] 补一篇"测试金字塔拆解真实业务(列表页/表单/权限)"的分层决策笔记
- [ ] 调研视觉回归与 storybook 联动方案,评估团队落地成本
写作规范请参阅领域概览。