前端测试策略:别只纠结单测覆盖率

一份覆盖率 82% 的报告,和一次线上支付按钮点不动的事故,是同一个项目在同一周里发生的。这两件事摆在一起看,特别刺眼:报告全绿,事故照出。那个按钮的点击处理函数被单测覆盖得很好,输入什么参数返回什么值都对,可它在页面上被一个 z-index 更高的浮层挡住了,用户根本点不到。单测断言的是函数返回值,压根不会经过真实的渲染链路,覆盖率自然也照亮不到那里。

这件事让我彻底不再拿覆盖率数字当安全感来源。覆盖率统计的是“代码跑到了多少行”,不是“业务风险被保护了多少”。这两者之间的缺口,才是测试策略真正要回答的问题:与其纠结覆盖率往 80% 还是 90% 冲,不如先想清楚——哪里出问题代价最大,测试成本就该往哪里投。

测试资源该按风险分配

测试资源永远是有限的,写测试、维护测试都要花时间,所以问题从来不是“测不测”,而是“先测哪里、每一层测到什么程度”。我的排序标准很直接,跟代码行数没关系,只跟风险有关。

钱、权限、数据修改这三类最优先。下单、支付、退款、余额变动、权限校验,这些地方一旦错了就是真金白银或安全问题,代价最大。其次是高频用户路径,一个每天几十万人走的登录流程,比一个一年点几次的导出功能值得多测。再往后是历史出过事故的地方——同一个坑摔第二次是最不该的,把老 bug 转成回归测试,性价比极高。复杂条件分支也要盯,分支越多、越绕的逻辑,人脑越容易漏。而纯展示、改错了也不影响主流程的低风险页面,可以少测甚至不测。

同样是覆盖率,测在下单链路上的 60%,和测在一堆工具函数上的 90%,保护的业务风险完全不是一个量级。测试是风险控制,不是数字游戏。 想清楚这条,后面每一层测什么、不测什么,判断起来就清楚了。

分层:各层守各层的事

前端测试大致分四层,每层解决的问题不一样,硬用错层去测只会又慢又脆。

最底下是单元测试,测纯函数、复杂逻辑、边界条件。往上是组件测试,测组件的交互、渲染状态、表单校验这些用户能感知的行为。再往上是集成测试,测多个模块协作、路由跳转、数据流是否串得起来。最上面是 E2E,在真实浏览器里跑关键业务路径。

这四层不是越往上越高级,而是各管一段。别指望单元测试能发现弹窗被浮层遮挡——那得渲染出来才知道;也别用 E2E 去穷举一个金额计算函数的所有输入组合——那种事单测几毫秒就跑完了,塞进 E2E 又慢又没必要。选错层,是很多团队测试又贵又不管用的根源。

单元测试守稳定逻辑

单元测试最舒服的场景是输入输出稳定、没有副作用的纯逻辑,比如一个折扣计算:

1export function getDiscount(price: number, level: 'normal' | 'vip') {
2  if (level === 'vip') return price * 0.8
3  return price
4}

这种函数测起来成本极低,边界也明确:

1import { describe, it, expect } from 'vitest'
2
3describe('getDiscount', () => {
4  it('vip 打八折', () => {
5    expect(getDiscount(100, 'vip')).toBe(80)
6  })
7  it('普通用户原价', () => {
8    expect(getDiscount(100, 'normal')).toBe(100)
9  })
10})

金额计算、复杂表单规则、权限判断、数据转换,这些逻辑密度高、又容易被改出问题的地方,都值得单测护着。但反过来,别为了凑覆盖率去给每个 getter、每个一行的简单映射、每个样式类名拼接都写测试。那类测试有个通病:它们和实现细节绑得死死的,你稍微重构一下内部写法,明明行为没变,测试却红了一片,最后测试不是在保护你,是在拖你后腿。测行为,别测实现。 这条原则在下一层组件测试里更关键。

组件测试关注用户行为

组件测试最常见的误区,是去断言组件内部的 state。测“点了登录后 errorState 变成了什么”,本质上还是在测实现——用户看不见 state,用户看见的是页面上有没有报错。所以组件测试应该站在用户的视角,测他能看到、能操作的结果:

1import { render, screen } from '@testing-library/react'
2import userEvent from '@testing-library/user-event'
3
4it('提交空表单时显示字段错误', async () => {
5  const user = userEvent.setup()
6  render(<LoginForm />)
7
8  await user.click(screen.getByRole('button', { name: '登录' }))
9
10  expect(screen.getByText('请输入账号')).toBeInTheDocument()
11})

这里断言的是一件用户真能感知的事:没填账号就点登录,页面出现提示。至于组件内部用了几个 useState、状态叫什么名字,测试完全不该关心。

选择元素时也有讲究,优先用 getByRolegetByLabelTextgetByTextuserEvent 这一套,少用 container.querySelector('.some-class')。类名是实现细节,改个样式方案测试就崩了;而角色、标签、文本是从用户视角出发的稳定锚点,顺带还能逼着你把可访问性做好——一个连 getByRole('button') 都选不到的元素,多半 a11y 也有问题。

E2E 只压关键路径

E2E 是最接近真实用户的一层,价值毋庸置疑,但它也最慢、最脆、最吃环境。真实浏览器、真实网络、真实依赖,任何一环抖一下测试就可能挂,而且挂了还难定位。所以 E2E 的原则是“少而精”,只压最关键的几条业务主干。

我们项目里 E2E 就守这么几条:用户能登录、核心列表能搜索并进详情、关键表单能提交、权限不足会被拦截、下单支付能跑通。其余的场景,交给更下层去覆盖。

断言也要抓业务结果,别抓过细的 UI:

1import { test, expect } from '@playwright/test'
2
3test('提交表单后能看到成功反馈', async ({ page }) => {
4  await page.goto('/order/new')
5  await page.getByRole('button', { name: '提交' }).click()
6  await expect(page.getByText('提交成功')).toBeVisible()
7})

如果你在 E2E 里去断言像素位置、动画中间态、内部 class 名,那这测试脆得没法用,改个间距就红。E2E 越贪多,CI 越慢、越容易 flaky,团队最后会因为“又是它随机挂了”而慢慢开始忽略它——一个大家都不信任的测试,比没有还糟。

E2E 还有个常被忽略的定位维度:环境和数据的稳定性。真实浏览器跑起来,测试账号的初始状态、数据库里的种子数据、第三方登录/支付的沙箱环境,任何一处不稳定都会让 E2E 无端变红,而这跟被测代码毫无关系。所以关键路径的 E2E 一定要配一套可复位的测试数据——每次跑之前把测试账号、测试订单重置到已知状态,别让上一次跑残留的脏数据把这一次带崩。我们把这套数据准备做成了独立的 setup 脚本,E2E 只负责跑流程、断结果,环境的确定性交给 setup 保证,这样 E2E 挂了,基本能确信是真出了问题,而不是环境又抽风。

Mock 要分层,别一 mock 到底

四层测试的 Mock 策略也不该一刀切。单元测试可以放心 mock 掉依赖;组件测试建议 mock 到网络层;E2E 则尽量贴近真实环境,只用稳定的测试数据兜底。

组件测试这层我尤其偏好用 MSW 这类拦网络请求的方案,而不是直接把业务请求函数 fetchUsers() 给 mock 掉。区别在于:mock 函数是把整条请求路径都短路了,而 MSW 保留了真实的 fetch/拼 URL/解析响应链路,只是在网络出口处把响应换成你控制的数据:

1import { http, HttpResponse } from 'msw'
2import { setupServer } from 'msw/node'
3
4const server = setupServer(
5  http.get('/api/users', () => {
6    return HttpResponse.json([{ id: '1', name: 'Alice' }])
7  }),
8)

组件仍然像在真实环境里一样发请求、处理响应,只是响应内容由测试说了算,比直接替换请求函数更接近实际行为,也更能测到序列化、错误处理这些容易漏的环节。

而且用 MSW 有个额外好处:mock 异常路径特别方便。测试最常见的盲区就是只测成功路径,200 一路绿。可线上出问题的往往是异常分支——空列表、接口 500、权限 403、某个字段返回了 null、超长文本撑破布局、慢请求下的 loading 卡死。这些用 MSW 换个 handler 就能造出来:

1it('接口 500 时展示错误占位', async () => {
2  server.use(
3    http.get('/api/users', () => new HttpResponse(null, { status: 500 })),
4  )
5  render(<UserList />)
6  expect(await screen.findByText('加载失败,请重试')).toBeInTheDocument()
7})

只测成功路径的测试,给的是一种虚假的安全感——它让报告变绿,却对真正会出事的那一半场景一无所知。而线上真正把用户绊住的,恰恰是这些异常分支:接口偶发 500、慢网络下的重复提交、返回了个没人预料到的 null。异常路径的覆盖,往往比再给成功路径加几个断言值钱得多。

Mock 也要警惕“过度 mock”。如果一个组件测试把它依赖的东西全 mock 光了,那它其实什么真实协作都没验证到,只是在验证“我 mock 了什么它就用什么”,退化成了一个自说自话的空壳。mock 的范围应该尽量往外推——推到网络这层、推到真正不可控的外部依赖那里,让组件内部的真实逻辑尽可能跑起来。mock 得越靠内,测试离真实行为就越远。

前后端契约错位,测试也拦不住

有一类线上事故,上面四层测试全都拦不住,因为它们测的都是“前端在既定假设下工作正常”,而事故恰恰出在假设本身。最典型的就是前后端契约错位:后端某天把 user.name 从必返改成了可能为 null,或者把字段从 phone 改成了 phoneNumber,前端所有 mock 里写的还是老结构,测试一路绿,线上直接 Cannot read properties of null

问题的根子在于——组件测试里 MSW 返回的假数据、单测里造的入参,都是我们照着自己以为的接口结构手写的。一旦真实接口偷偷变了,我们的 mock 不会跟着变,测试就成了在一个已经过期的假设上自我验证。

彻底的解法是契约测试,让前端 mock 的数据结构和后端真实响应对齐同一份 schema。我们做得不算重,但守住了关键接口:核心链路的接口用 zod 定义一份 schema,前端请求层拿它做运行时校验,mock 数据也从同一份 schema 生成——这样只要后端改了结构不同步 schema,本地和 CI 就会先报出来:

1import { z } from 'zod'
2
3const UserSchema = z.object({
4  id: z.string(),
5  name: z.string().nullable(), // 契约里显式声明可空,前端就被迫处理这个分支
6})
7
8// 请求层校验,接口结构变了这里第一时间抛
9export async function fetchUser(id: string) {
10  const raw = await http.get(`/api/users/${id}`)
11  return UserSchema.parse(raw)
12}

再配合后端在 CI 里维护接口 schema(OpenAPI 或类似),前端定期比对,就能把“契约漂移”这类测试盲区收住。这块投入要按接口重要性来——核心交易接口值得,一堆低频的后台接口没必要都上。

视觉回归:布局崩了断言测不出来

前面那个“支付按钮被浮层遮住”的事故,其实指向另一层盲区:布局和视觉层面的回归,功能断言天然测不到。断言 getByRole('button') 存在、可点击,全都通过——可它在视觉上被盖住了、被挤到屏幕外了、或者字色和背景撞成了看不清,断言一无所知。

这类问题靠视觉回归测试兜底:给关键页面截图存基线,每次改动重新截图和基线比对,像素级差异超过阈值就报出来让人确认。Playwright 自带了这个能力:

1test('订单详情页视觉基线', async ({ page }) => {
2  await page.goto('/order/12345')
3  await expect(page).toHaveScreenshot('order-detail.png', {
4    maxDiffPixelRatio: 0.01, // 留一点点抗抖动余量
5  })
6})

视觉回归很强,但也最难伺候:字体渲染、动画、时间戳、随机数据都会让截图抖动,产生一堆假阳性。所以它只适合压在少数稳定的关键页面上,还得把动态内容 mock 成固定值、关掉动画、统一渲染环境(最好在 CI 里用容器跑,别本地各人机器各截各的)。铺太广的话,维护基线的成本会反过来吃掉它的收益。

测试名要像一句需求

还有个不起眼但影响很大的习惯:测试名怎么写。我坚持让测试名直接描述业务行为,读起来像一条需求:

1it('提交空表单时显示字段错误', async () => {})
2it('接口返回 403 时跳转到无权限页面', async () => {})
3it('删除成功后从列表移除当前行', async () => {})

而不是 it('should work') 或者 it('test case 1') 这种。半年之后没人记得 “case 1” 保护的是什么,一旦它挂了,你还得反过来读实现才能猜出它想验证什么。好的测试名有个硬标准:它失败的时候,光看名字就知道是哪个业务行为坏了,而不是只告诉你“某个快照变了”。快照测试尤其容易掉进这个坑——快照一变,你只知道“有东西不一样了”,却不知道是该有的变化还是回归,最后大家习惯性一路 --update,测试彻底失去意义。

慢和 flaky 的测试会被团队悄悄放弃

策略再对,如果测试跑得慢、还时不时随机失败,最后的结局都是一样的:大家开始 --skip,开始不看红灯就合并,测试体系名存实亡。所以测试的可维护性,和它测得对不对同样重要。

跑得慢,先分层治理。单测和组件测试应该秒级反馈,我们从 Jest 迁到 Vitest 之后,靠它的原生 ESM 和并行执行,本地单测套件从几十秒压到了几秒,改代码时能开着 watch 模式即时看结果——反馈越快,开发越愿意写、越愿意跑。E2E 慢是天性,能做的是别让它在每次提交都全量跑:提交时只跑单测和组件测试,E2E 放到合并前或定时任务里跑,用分片(sharding)并行铺开:

1// playwright.config.ts:CI 上分片并行,缩短墙钟时间
2export default defineConfig({
3  fullyParallel: true,
4  workers: process.env.CI ? 4 : undefined,
5  retries: process.env.CI ? 1 : 0, // 只在 CI 上给一次重试,抹平偶发抖动
6})

flaky 比慢更致命,因为它会摧毁信任。一个隔三差五无故变红的测试,会让人对所有红灯都麻木——反正“可能又是它抽风”。治 flaky 的第一原则是绝不用 retries 去掩盖它,retries 只该用来抹平真实存在的网络抖动,不该用来盖住测试本身的时序 bug。前端 flaky 的高发区就那么几类:等异步渲染时用了固定 sleep(500) 而不是等条件成立、依赖了元素的出现顺序、共享状态没在用例之间清理干净。正确的等待姿势是等语义化条件,而不是等时间:

1// 脆:赌 500ms 内一定渲染完,机器一慢就挂
2await sleep(500)
3expect(screen.getByText('加载完成')).toBeInTheDocument()
4
5// 稳:等到出现为止,快就快、慢就多等一会
6expect(await screen.findByText('加载完成')).toBeInTheDocument()

我们定了条规矩:任何被标记 flaky 的测试,要么当天修好,要么先删掉不留在套件里——留着一个随机失败的测试,比没有这个测试更糟,它污染的是整个 CI 的可信度。

测试数据别散在各处

测试写多了会冒出一个不起眼却很拖后腿的问题:造数据。每个用例开头都手搓一个 user 对象、一个 order 对象,字段一多就复制粘贴,等某天数据结构加了个必填字段,几十个测试文件里的假数据全得改,改漏一个就报一片。这和契约漂移是同一类病——测试数据没有单一来源。

解法是把测试数据收敛成 factory(工厂函数),给一份合理的默认值,用例只覆盖它关心的那几个字段:

1// factories/user.ts:一处定义,全项目复用
2export function makeUser(overrides = {}) {
3  return {
4    id: 'u_1',
5    name: 'Alice',
6    role: 'normal',
7    createdAt: '2024-01-01T00:00:00Z',
8    ...overrides,
9  }
10}
11
12// 用例里只声明和本次断言相关的差异,别的字段不用管
13const vip = makeUser({ role: 'vip' })
14const noName = makeUser({ name: null })

这样做的好处不只是省事:用例读起来更聚焦——一眼就看出这个测试关心的是 role: 'vip',其余都是无关背景;数据结构变了也只改工厂一处。factory 和前面的 zod schema 还能接起来,让工厂产出的数据也过一遍 schema 校验,从源头保证测试数据和真实契约一致。

有些东西就是不该写测试

聊了半天该测什么,也得说清楚哪些不值得测,否则很容易走到另一个极端——为了覆盖率给一切都上测试,结果养出一堆脆弱、只会拖慢重构的测试。

几类我基本不写:第三方库的行为(React 会不会正确渲染、lodash 的 groupBy 对不对,那是它们自己的测试该保证的,你测它等于替别人打工);纯样式和视觉细节交给前面说的视觉回归去兜,别用断言硬测某个元素 color 是不是某个值;一行的透传函数、简单 getter,测了也只是把实现抄一遍当断言,改实现就得改测试,纯负担;还有那种和内部 state、私有方法强绑定的测试——它们不测行为、只测写法,是重构路上最常见的绊脚石。

判断一个测试值不值得写,我的标准就一句:它挂了,是在提醒我“用户会遇到问题”,还是只在抱怨“你改了实现”? 前者是资产,后者是负债。前者越多越敢改,后者越多越不敢动——测试写反了方向,比不写还伤。

我现在怎么定测试策略

把上面这些揉到一起,一套能落地的前端测试策略大致是这样:纯逻辑和复杂规则交给单元测试;关键组件交互写组件测试;核心业务链路配少量 E2E;接口错误和空状态必须覆盖,不能只有成功路径;始终测用户行为而非实现细节;测试数据收敛到 factory 和 schema,别散在各处;契约和视觉这两类盲区在关键链路上补一层专门的兜底;不追求无意义的覆盖率数字;每修一个线上 bug,顺手把它转成一条回归测试,别让同一个坑摔第二次。

这套策略不是一次定死的。项目早期 E2E 还没搭起来,先把单测和组件测试铺在核心逻辑上;链路稳定了再补关键路径的 E2E;出过一次事故,就往那个方向加一层防护。测试的形态应该跟着业务的风险分布走,而不是照着某张“测试金字塔”的图硬凑比例。哪层的投入产出比最高,资源就往哪层挪——这个判断每隔一段时间就该重新做一次。

回到开头那份 82% 和那个点不动的按钮。覆盖率可以看,它是个有用的辅助信号——覆盖率低的地方往往确实是盲区,值得回头看看是不是漏了该测的东西。但它只能回答“代码跑到了多少行”,回答不了“业务风险被保护了多少”,把它当目标去凑,只会催生一堆为覆盖而覆盖的空测试,数字好看,事故照出。

好的测试体系,衡量标准不是那个百分比,而是三件事能不能同时成立:开发敢改、CI 拦得住、线上少炸。开发敢改,是因为测试守住了行为、不绑实现,重构不会误伤;CI 拦得住,是因为关键路径有真能发现问题的测试、且没被 flaky 拖垮信任;线上少炸,是因为最值钱的链路投了最多的测试成本。测试要暴露的是那些最要命的问题、赶在它们上线之前——这件事做好了,覆盖率是多少反而没那么重要了。