Skip to content

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 修复(补一个用例)第三方库本身的行为
工具库、SDKUI 细节(交给组件测试)

💡 经验法则:有分支、有计算、有边界才写单测。


三、组件测试: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') > 手写对象
不要过度 mockmock 太多等于在测 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)
失败即阻断否则测试会逐渐被忽略

详见 A16 · CI/CD 与部署运维。


自检清单 ​

  • [ ] 能说出测试金字塔三层的工具、职责、建议占比
  • [ ] 能用 Testing Library 按 getByRole 写组件测试(测行为不测实现)
  • [ ] 知道用 MSW 在网络层 mock,而不是 mock 自己的模块
  • [ ] 能写出稳定的 E2E(自动等待、独立用例、稳定选择器)
  • [ ] 能说出覆盖率的三个陷阱

常见误区 ​

❌ 误区✅ 事实
"测试越多越好"慢、脆的测试会被关掉;按金字塔配比
"测试要覆盖内部实现"测用户可见行为,重构时才不会全红
"覆盖率 100% 才安全"覆盖率 ≠ 断言质量
"E2E 覆盖所有分支"太慢太脆;E2E 只测关键路径
"mock 掉所有依赖"过度 mock 等于在测 mock

动手练习 ​

  1. 给一个价格计算函数写单测,覆盖**折扣、满减、边界(0 与负数)**三种情况。
  2. 用 Testing Library 重构一个"用 querySelector('.btn')"的旧测试,改为 getByRole,然后改掉组件的 CSS 类名验证测试仍然通过。
  3. 用 MSW 替换项目里的手写 fetch mock,写一条**错误态(500)**的用例。

相关文档 ​