单元测试写了 1200 个还是漏 bug?聊聊覆盖率骗局、Jest 转 Vitest 的坑,还有 mock 的边界

🔑 关键词:单元测试,Jest,Vitest,测试覆盖率,mock

📖 摘要:从一次线上白屏事故说起,聊聊覆盖率 87% 为什么照样漏 bug、Jest 迁 Vitest 具体快多少、vi.mock 和 jest.mock 的 hoist 坑,以及我自己判断一个测试值不值得写的粗糙标准。

上家公司前端仓库的 CI 里躺着 1200 多个单元测试,跑一次 14 分 23 秒,覆盖率报告 87.4%,绿色的。然后线上还是炸了——某个接口返回 data: null,页面里那个 .map() 直接白屏。我盯着 Sentry 报错看了半天,跑去翻相关组件的测试文件,发现清一色是这种写法:

图片

it('应该渲染列表', () => {
  const wrapper = mount(Component, { props: { list: mockList } })
  expect(wrapper.find('.item').length).toBe(3)
})

mockList 是三元素数组,永远不为 null,也永远不会是空数组。这个测试从写下来那天起就不可能失败,它测的不是组件,是我自己的想象力。

覆盖率这个东西,看总数基本等于没看

图片

Istanbul(现在叫 istanbuljs,Jest 内置的那个)默认统计四个维度:statements、branches、functions、lines。大部分人只盯最后那个总百分比,但真正有信息量的是 branches。老版本的配置大多是这么写的:

"coverageThreshold": {
  "global": { "branches": 80 }
}

意思是整体低于 80% 才报错,也就是说某一个模块 0% 覆盖,被另一个 100% 的模块平均一下,照样过。其实 Jest 支持按 glob 分目录配阈值,这个知道的人不算多:

"coverageThreshold": {
  "./src/utils/": { "branches": 95 },
  "./src/components/": { "branches": 60 }
}

图片

utils 里都是纯函数,测到 95% 成本很低、收益也实在;组件里一堆 UI 条件分支,硬凑到 95% 就只剩一条路——写 expect(true).toBe(true)。这个分层的思路其实来自《Software Engineering at Google》,他们内部大概是 70/20/10,70% 单元、20% 集成、10% E2E。但注意这个比例有前提,Google 那套测试基础设施非常变态,你直接照抄到十个前端的小团队里,可能先把自己拖垮。

还有个分支覆盖的细节:a && b 这种短路表达式,Istanbul 会算成两个分支,只看行覆盖率完全看不出来。所以我后来更关注的是那些「决策点密度」高的函数,一个函数里 if/else 嵌套超过三层,不管覆盖率多少,先重构再说。

Jest 换 Vitest,快是真的,坑也是真的

图片

2022 年我把一个中型项目从 Jest 28 迁到 Vitest 0.34(那会儿版本号还很小),CI 里的冷启动从 38 秒掉到 9 秒左右。快的原因不复杂:Jest 默认走 babel-jest 做 transform,Vitest 直接复用 Vite 的 esbuild 管线,esbuild 是 Go 写的,编译这块差距肉眼可见。

但别以为换过去就是免费的。我踩过的具体坑有这么几个:

  1. Jest 从 27 开始,jest-environment-jsdom 不再是内置依赖了,得手动装。很多人升级之后报 Cannot find module 'jest-environment-jsdom',就是这个问题。Vitest 这边写 environment: 'jsdom' 也得先把 jsdom 装上。
  2. vi.mockjest.mock 一样会被 hoist 到文件顶部,所以工厂函数里不能直接引用外部变量。Vitest 给了 vi.hoisted() 专门解决这个,Jest 那边得用 jest.doMock 或者把变量挂到 globalThis 上。这个坑我在两个项目里各踩了一次,第一次排查了挺久,因为报错指向的位置完全对不上。
  3. Jest 的 fake timers 和 Testing Library 的 waitFor 一起用会卡死——waitFor 内部默认靠 setInterval 轮询,你 jest.useFakeTimers() 之后它永远等不到真实时间往前走。Vitest 在后来的版本里让 waitFor 自己检测是不是假定时器,Jest 这边到现在还得手动 jest.advanceTimersByTime。第一次遇到的时候我以为是自己异步写错了,debug 快一个小时。

图片

另外提一句性能参数,Jest 的 maxWorkers 默认是 CPU 核数减一,在 2 核的 CI runner 上基本等于单线程,速度会很难看;Vitest 的 pool 有 threads、forks、vmThreads 三种,默认 threads,涉及原生模块或者需要进程隔离的时候要换成 forks。这些在文档里都有,但迁移的时候不一定会想起来看。

mock 的边界,其实就是耦合的边界

很多人把 mock 当成「隔离依赖」的工具,我的看法不太一样:你 mock 掉的那些东西,恰好就是模块跟外界的耦合面。一个模块要 mock 五个依赖才测得动,这五个依赖就是它的问题,不在测试身上。我给自己定过一个挺粗糙的标准——单个测试文件里出现五次以上 mock(,先不加测试,回去看这个模块能不能拆。

还有一个很隐蔽的问题:jest.mock('axios') 这类全局 mock 会让同一个文件里的测试共享 mock 状态。mockResolvedValueOnce 排到第几次、什么时候用光,执行顺序一乱,测试就开始 flaky,而且 flaky 的方式是偶发的,本地跑十遍没事,CI 上挂一次。Jest 里 clearMocksresetMocksrestoreMocks 三个开关默认全是 false,Vitest 早期版本的 mockReset 默认语义跟 Jest 又不一样,迁移的时候这块要一个一个对,别信网上那种「改个 import 就行」的教程。

图片

我现在大概是怎么写的

一个测试如果不能在重构的时候给我安全感,那它就是负债。写之前我会先问一句:这个断言在什么情况下会失败?答不上来,说明它永远不会失败,删掉比留着强,留着只会在 CI 里占时间,还会让人误以为这块有人管。

生产代码和测试代码的比例,我大概控制在 3:1 左右。明显低于这个数通常覆盖不到位,明显高于这个数,大概率是我在给 getter 和常量写测试,纯属自娱自乐。这个数字没有出处,就是我自己的手感,仅供参考。

🏷️ 标签: