Skip to main content

前端测试分层与覆盖率

覆盖率是「哪些代码被执行过」,不是「哪些行为被验证过」。定覆盖率目标前先分清测试层次——否则很容易写出一个绿油油、但什么都没保障的测试套件。

一、单元、集成与端到端​

四层按「测什么、跑多快、谁维护」来分。单元测试测纯函数与自定义 hook——最快、最稳、数量最多;组件测试测渲染与交互,性价比最高;集成测试测页面与请求协作,较慢;端到端测关键路径,最慢、最少。

真正的金字塔是倒过来的工作量分配:绝大多数用例应该在最下面两层。端到端只覆盖「挂了就会出事」的几条路径——登录、下单、支付这类。它写起来贵、跑起来慢、还最容易因时序抖动而偶发失败,所以绝不是越多越好。

层级测什么速度数量
单元测试纯函数、hook、工具库毫秒级最多
组件测试渲染 + 交互较快多
集成测试多组件 + 请求层秒级中
端到端关键链路分钟级最少

端到端只覆盖「挂了就出事」的这几条路径,其余交给下面两层

用例数量应该倒过来分配端到端集成单元数量多、跑得快、定位准端到端一大坨集成单元慢、脆、维护贵覆盖率数字不能替代分层:绝大多数用例应该压在最下面两层。
图:测试金字塔与它的反模式——越往上,维护成本越高、定位越难

二、坏测试的几种长相​

四种最典型的坏测试,共同特征是——它们让测试变绿,但没让人更放心。

第一种,断言实现细节。测组件内部 state、测私有方法,重构一次全红:

// 坏:测内部状态,实现一改测试就挂
expect(wrapper.vm.internalCount).toBe(3)
expect(vi.spyOn(component, 'computeTotal')).toHaveBeenCalled()

// 好:测用户看到的结果
expect(screen.getByText('合计:¥30')).toBeInTheDocument()

第二种,mock 过度。把被测模块依赖的全部外部调用都 mock 掉,最后测的是 mock 而不是真实代码——真实接口改了签名,测试依然绿。正确做法是只在系统边界 mock(网络、存储、时间),业务内部不要层层打桩。

// 只在系统边界 mock:网络、存储这类外部依赖
vi.mock('../api', () => ({ fetchUser: vi.fn() }))
// ❌ 把被测模块内部的纯函数也 mock 掉,等于在测 mock 本身
// vi.mock('../utils/format', () => ({ formatPrice: vi.fn() }))

第三种,快照滥用。toMatchSnapshot() 改动就无脑回车更新,等于没有断言。快照只能作为辅助(比如确认某个复杂结构的「形状」没变),不能当唯一断言。

第四种,依赖真实时间与随机数。Date.now()、Math.random() 不固定,必然 flaky。时间用可注入的 clock,随机用固定种子,让用例可重复。

坏测试的共同特征是让测试变绿,但没让人更放心

三、覆盖率该怎么设​

覆盖率要按模块定档,不要追一个统一数字。纯函数、算法、金额计算这类「错了就出事」的逻辑,可以要求高一些(例如接近 80% 甚至更高);UI 组件不追求行覆盖率,追求关键交互有断言——一个按钮点了有没有反应,比「这 200 行渲染代码跑了没有」重要得多。

分支覆盖率比行覆盖率更能说明问题:行覆盖率只告诉你某行被执行过,分支覆盖率告诉你 if/else 两个方向都走过没有。一段「价格大于 0 走 A、否则走 B」的逻辑,只测了 A 分支,行覆盖率可能是 100%,但 B 分支没覆盖就等于没测。核心算法与金额计算要把分支拉满,因为它们最容易出边界 bug。

把覆盖率当趋势看、按模块定档,不要追一个统一数字

覆盖率门槛要按目录分层设,一刀切等于逼人写无用测试:

// vitest.config.js
export default defineConfig({
test: {
coverage: {
// 核心逻辑要求高,UI 组件不卡行覆盖率
thresholds: {
'src/core/**': { lines: 85, functions: 80 },
'src/utils/**': { lines: 90 },
'src/components/**': { lines: 40 },
},
},
},
})

四、测试投入的性价比​

先补最痛的地方,再谈覆盖率。一条可执行顺序:先给纯函数与边界逻辑写测试(收益最高、最稳),再补曾经出过 bug 的地方(同样的问题最容易复发),最后才是覆盖率报表里那些大片空白。

不要为了覆盖率给渲染组件写无意义的快照——它只会把数字推上去,不增加任何保障。覆盖率的告警应该指向「这里有逻辑没测」,而不是「这里行数没跑满」。

按模块定档的阈值配置长这样(仅示意,数字按项目实际定,不追求统一值):

// 覆盖率配置示意:核心逻辑门槛高,UI 组件不卡行覆盖率
{
test: {
coverage: {
thresholds: { lines: 80, branches: 75 },
// 金额计算、权限判断等关键模块可单独再拉高
},
},
}

先补最核心链路与最痛的故障点,再谈覆盖率数字

覆盖率能告诉你哪行没跑到,但判断不了断言是否有意义。一条对照:

// 覆盖到了,但没有断言:改坏了也不会失败
it('计算折扣', () => {
calcDiscount(100, 0.2)
})

// 同样的覆盖,但真的在验证行为
it('计算折扣', () => {
expect(calcDiscount(100, 0.2)).toBe(80)
expect(calcDiscount(100, 1)).toBe(0)
expect(() => calcDiscount(-1, 0.2)).toThrow()
})

五、指标之外要看什么​

追 100% 会逼出大量无意义断言,覆盖率应该按模块定档,而不是追一个统一数字。

行覆盖率够了也不说明问题——分支覆盖率更能说明问题,条件分支没覆盖就等于没测。

测试偶发失败更不能重跑就算了。不稳定的测试会消耗团队对整套测试的信任,必须修掉或删掉。

把覆盖率当趋势看,先补最核心链路与最痛的故障点,再谈数字。

跑测试的成本控制与 watch 模式配置看 Vitest 文档,覆盖率阈值也可以在那里直接设。