前端单元测试怎么写?Jest 和 Vitest 有什么区别?
单元测试挑一个纯函数或小模块,按“准备输入 → 调用 → 断言结果”写,覆盖正常值、边界值和异常。Jest 和 Vitest 的 describe/it/expect 写法几乎一样;主要差别在于 Vitest 复用 Vite 的配置和插件,默认不注入全局 API,并原生支持 ESM,Jest 对 ESM 的支持仍是实验性的,TypeScript 要经 Babel、ts-jest 或 Node 类型擦除处理。
简答
商品价格接口返回的是“分”,页面要显示成
¥19.99。这个转换函数就是最适合写单元测试的对象:给定输入,结果是确定的,不依赖网络和 DOM。测试就三步:准备输入、调用函数、断言输出,再补上 0、负数、非整数这类边界情况。单元测试的价值是快和准。它一秒能跑几百个,出错时能直接定位到哪个函数的哪种输入。所以要把业务计算从组件里抽成纯函数,组件只负责展示,测试才好写。
Jest 和 Vitest 的写法几乎一样,都是
describe、it、expect,Vitest 官方说它兼容大部分 Jest API。区别主要在配置:Vitest 直接复用项目的vite.config,原生支持 ESM 和 TypeScript;Jest 要通过 Babel 或 ts-jest 转 TypeScript,ESM 支持官方仍标为实验性。用 Vite 的项目选 Vitest 更省事。迁移时要注意几个细节:Jest 默认注入全局的
describe、expect,Vitest 默认不注入,要么显式导入,要么开globals;jest.mock的工厂函数返回值直接当默认导出,而vi.mock的工厂要返回包含每个导出的对象。
被测函数长什么样
下面这个函数把“分”格式化成带货币符号的字符串,不合法的金额直接抛错。它可以直接在 node 里运行:
function formatPrice(cents, currency = 'CNY') {
if (!Number.isInteger(cents) || cents < 0) {
throw new RangeError('金额必须是非负整数(单位:分)');
}
const symbol = currency === 'USD' ? '$' : '¥';
return `${symbol}${(cents / 100).toFixed(2)}`;
}
console.log(formatPrice(1999)); // ¥19.99
console.log(formatPrice(5, 'USD')); // $0.05
try {
formatPrice(-1);
} catch (error) {
console.log(error.name, error.message); // RangeError 金额必须是非负整数(单位:分)
}
为什么金额用整数“分”而不是小数“元”?因为 0.1 + 0.2 在 JavaScript 里不等于 0.3,用整数传、只在展示时除以 100,能避开大部分浮点误差。测试要守住的正是这些约定。
测试怎样写
用 Vitest 写,显式从 vitest 导入 API:
import { describe, it, expect } from 'vitest';
import { formatPrice } from './formatPrice';
describe('formatPrice', () => {
it('把分转换成两位小数的元', () => {
expect(formatPrice(1999)).toBe('¥19.99');
});
it('不足一元时补零', () => {
expect(formatPrice(5, 'USD')).toBe('$0.05');
});
it('0 元也能正常显示', () => {
expect(formatPrice(0)).toBe('¥0.00');
});
it.each([-1, 1.5, Number.NaN])('拒绝不合法的金额 %s', (value) => {
expect(() => formatPrice(value)).toThrow(RangeError);
});
});
每个 it 只验证一件事,名字写成“在什么情况下得到什么结果”,失败时一眼就能看出坏在哪里。边界值用 it.each 批量覆盖。断言抛错时要把调用包进箭头函数,写成 expect(formatPrice(-1)).toThrow() 的话,错误在 expect 执行之前就抛出来了,测试会直接失败。
换成 Jest,只要把第一行导入去掉(或者改从 @jest/globals 导入),其余代码基本不用动。
单元测试应该测什么
优先测有分支和计算的纯逻辑:格式化、校验、价格计算、状态转换、数据整理。测的是输入和输出的关系,不测函数内部用了哪个变量,这样重构实现时测试不用跟着改。
依赖网络、时间、随机数的代码,要么把这些依赖作为参数传进来,要么在测试里替换掉。什么时候该替换、替换多了有什么问题,见《什么时候应该 mock,mock 太多有什么问题?》;定时器相关的见《前端怎样测试定时器、重试和超时,避免 Fake Timers 造成假通过?》。和用户交互有关的组件,按用户行为写,见《前端组件测试应该围绕哪些用户行为?》。
Jest 和 Vitest 具体差在哪里
| 方面 | Jest | Vitest |
|---|---|---|
| 配置 | 独立的 Jest 配置 | 复用 vite.config,共享插件和别名 |
| TypeScript | 经 Babel(只转译不做类型检查)、ts-jest,或 Node 类型擦除 | 由 Vite 管线处理 |
| ESM | 官方标为实验性,需要 --experimental-vm-modules | 原生支持 |
| 全局 API | 默认注入 | 默认不注入,可开 globals |
| 默认测试环境 | node | node |
| 模块 mock | jest.mock,__mocks__ 目录自动生效 | vi.mock,__mocks__ 只在调用 vi.mock() 时加载 |
Vitest 官方的比较文档说得很直接:如果应用基于 Vite,再维护一套 Jest 的转换管线是重复的复杂度;即使不用 Vite,Vitest 默认的监听模式和更快的运行也值得考虑。反过来,Jest 生态更早、资料更多,老项目已经稳定运行在 Jest 上时,没有必要为了换而换。
Jest 文档还提醒,通过 Babel 支持 TypeScript 只是转译,运行测试时不会做类型检查,需要类型检查就用 ts-jest 或单独跑 tsc。Vitest 则另外提供了类型测试:*.test-d.ts 文件里可以用 expectTypeOf 断言类型,官方说明这类文件不会被执行,而是在底层调用 tsc 或 vue-tsc 做静态分析。普通测试照常运行,项目仍然应该单独跑一次 tsc。
常见追问
测试环境要用 jsdom 吗?
两者默认都是
node环境。只有测试里要访问document、window时,才把对应文件或配置切换成jsdom或happy-dom。纯函数测试留在node环境更快。
从 Jest 迁移到 Vitest,最容易踩什么坑?
官方迁移指南列的几条最常见:全局 API 默认关闭,不开
globals时 Testing Library 不会自动清理 DOM;vi.mock的工厂要返回包含所有导出的对象;requireActual要换成vi.importActual;mockReset在 Vitest 里会恢复成vi.fn(impl)里传入的原始实现,而不是空函数。
覆盖率达到多少才够?
覆盖率只能说明代码被执行过,不能说明断言有没有验证对结果。详见《为什么 100% 代码覆盖率也可能漏掉严重问题?》。