覆盖率报告说这行跑过了,删掉它测试照样绿——Vitest 4 把 V8 的假阳性从 15% 压到接近 0
覆盖率报告说这行跑过了,删掉它测试照样绿——Vitest 4 把 V8 的假阳性从 15% 压到接近 0
先说结论:如果你正在用 Vitest 的 --coverage,而且项目是 Vite + TypeScript,那么升级到 Vitest 4 之后,你的覆盖率数字大概率会「下降」几个点。这不是你的测试变差了,而是旧版本一直在虚报。
这篇讲两件事:覆盖率为什么曾经在骗人,以及 Browser Mode 稳定之后,那些在 Node 下能跑的 vi.spyOn 为什么会突然抛错。
假阳性是怎么来的
Vitest 从很早就用 V8 的原生覆盖率(--experimental-vm-modules 那套)。V8 报的是字节码偏移,而你在报告里看到的却是源码行号。中间这一层映射,Vitest 3 之前一直交给 v8-to-istanbul 做。
问题就出在这层映射上。V8 的字节码位置和源码位置不是一一对应的,尤其是这几类代码:
- 被 esbuild 转换过的 TS/JSX——类型注解、装饰器、
?.、??都会改变字节码结构 - 多行表达式被压成一行,或者一行被拆成多行
import提升后的模块顶层代码
v8-to-istanbul 用的是一个偏启发式的行号推算。结果就是覆盖率报告里时不时出现「这行被标记为已覆盖,但它其实从没执行过」。
更坑的是这个错误的方向:它只往高了报。你看到 92%,真实可能只有 77%。而一个虚高的覆盖率会把 CI 上刚设好的 threshold 直接喂饱,让你以为补测已经做完。
before / after 感受一下这个数字的差异(同一份 TS 项目,@vitest/coverage-v8):
Vitest 3.x (v8-to-istanbul 映射) 行覆盖率 92% ← 含假阳性
Vitest 4.x (AST 重映射) 行覆盖率 77% ← 真实值
掉下来的这 15 个百分点,就是以前被误标的行。
Vitest 4 换了什么
Vitest 4 丢弃了 v8-to-istanbul,改成自研的 AST-based remapping:把 V8 的字节码位置先落到转换后的源码上,再借助 source map 里记录的 AST 节点信息,精确回推到你原始的 .ts / .tsx 位置。它做到了和 @vitest/coverage-istanbul(Istanbul 那套插桩)同级别的准确度,却没有 Istanbul 插桩带来的运行时开销。
一个容易被忽略的收益:以前为了严谨,很多团队只能被迫用 Istanbul provider,代价是每个源文件都要过一遍 Babel 插桩。实测下来,覆盖率的收集时间会明显拉长——同一套 5 万条规模的企业级测试,开 Istanbul 覆盖率大约要多花 34 秒,而 V8 + AST 重映射只多花 8 秒左右,是 4 倍差距。次数一多,这就是实打实的 CI 分钟数。
对应的配置改动也顺手记一下,experimentalAstAwareRemapping 这个开关在 4.0 里已经默认开启,不用再手动加;同时 coverage.all 和 coverage.extensions 被移除了——以前默认会把项目里所有没跑到的文件也算进分母,导致经常扫到压缩过的产物、报告卡死。现在默认只统计实际加载过的文件。
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
// 4.0 起 all / extensions 已移除
// 用 include 明确圈定你的源码范围
include: ['packages/*/src/**/*.{ts,tsx}'],
exclude: ['**/*.test.ts', '**/generated/**'],
},
},
})
如果你从 3.x 升上来发现报告里少了文件,八成是 include 没配——这是升级后最常见的「我覆盖率怎么变低了」的误报。
Browser Mode 下,vi.spyOn 会直接抛错
第二个坑更隐蔽,尤其是现在大家都开始在真实浏览器里跑组件测试。
Vitest 4 把 Browser Mode 转正了,测试跑在真实的 Chromium / Firefox / WebKit 里,不再是 jsdom 模拟。但代价是:浏览器里模块是按原生 ESM 加载的,模块命名空间对象是被冻结(sealed)的。
这意味着下面这行在 Node 测试里好好的,在浏览器测试里会直接抛错:
// ❌ 浏览器里抛:TypeError: Cannot redefine property
import * as api from './api.js'
vi.spyOn(api, 'fetchUser')
原因很直接:原生 ESM 的命名空间是只读的,没有 Node module runner 那种可以在运行时替换导出的缝。所以你想 mock 一个模块的导出,不能再 spy 它的命名空间了,得换成 vi.mock 加 spy: true:
// ✅ Browser Mode 下的写法
vi.mock('./api.js', { spy: true })
import { fetchUser } from './api.js'
import { vi } from 'vitest'
vi.mocked(fetchUser).mockImplementation(async () => ({ id: 1, name: 'test' }))
{ spy: true } 的语义是:保留原本的实现,只是把它包一层 spy,这样既能断言调用,又不会在没 mock 的情况下让整个模块失效。另外,通过 export let 导出的变量在浏览器里是完全无法替换的——这类代码从设计上就不是给你 mock 的,只能改架构。
这里还有个连锁反应要提醒:Vitest 4 顺带重写了 mock 的实现,vi.restoreAllMocks() 现在只恢复你手动 vi.spyOn 创建的 spy,不再影响自动 mock;vi.fn().getMockName() 的默认名字也从 spy 变成了 vi.fn(),如果你的快照里存了 mock 名字,升级后会出现一堆「看起来没变但快照对不上」的 diff。
给你的下一步
如果你手上是 Vite + TS 项目,这周可以按这个顺序做一遍:
- 先升到 Vitest 4,别急着修 CI 里的 threshold——先跑一次
npx vitest run --coverage,把真实数字看清楚,再决定阈值定在哪。 - 检查
coverage.include有没有配,没配的话报告会缺文件。 - 全局搜一下
vi.spyOn(,凡是*第一个参数是 `import as xxx** 的,全部改成vi.mock(…, { spy: true })`,否则一上 Browser Mode 就崩。 - 跑一次
npx vitest run -u更新快照(因为 mock 名字和序列化默认值变了),确认 diff 是纯格式变化后再提交。
覆盖率从虚高变成真实,第一眼会难受,但至少从此这个数字能拿来做决策了。
评论区
登录后可评论。