上家公司前端仓库的 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 写的,编译这块差距肉眼可见。
但别以为换过去就是免费的。我踩过的具体坑有这么几个:
- Jest 从 27 开始,
jest-environment-jsdom不再是内置依赖了,得手动装。很多人升级之后报Cannot find module 'jest-environment-jsdom',就是这个问题。Vitest 这边写environment: 'jsdom'也得先把 jsdom 装上。 vi.mock和jest.mock一样会被 hoist 到文件顶部,所以工厂函数里不能直接引用外部变量。Vitest 给了vi.hoisted()专门解决这个,Jest 那边得用jest.doMock或者把变量挂到 globalThis 上。这个坑我在两个项目里各踩了一次,第一次排查了挺久,因为报错指向的位置完全对不上。- 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 里 clearMocks、resetMocks、restoreMocks 三个开关默认全是 false,Vitest 早期版本的 mockReset 默认语义跟 Jest 又不一样,迁移的时候这块要一个一个对,别信网上那种「改个 import 就行」的教程。
我现在大概是怎么写的
一个测试如果不能在重构的时候给我安全感,那它就是负债。写之前我会先问一句:这个断言在什么情况下会失败?答不上来,说明它永远不会失败,删掉比留着强,留着只会在 CI 里占时间,还会让人误以为这块有人管。
生产代码和测试代码的比例,我大概控制在 3:1 左右。明显低于这个数通常覆盖不到位,明显高于这个数,大概率是我在给 getter 和常量写测试,纯属自娱自乐。这个数字没有出处,就是我自己的手感,仅供参考。