JavaScript 测试:用 Vitest 写单元测试
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
代码能跑通一次,和每次改动后都还能跑通,是两件事。测试的价值不在证明自己对,而在于改动之后能立刻知道哪里坏了。这一篇用 Vitest 把测试跑起来,重点讲清楚断言怎么选、异步怎么测、依赖怎么替。
环境准备
沿用上一章的 Node.js 22 LTS 项目,装 Vitest 与覆盖率插件:
npm install -D vitest @vitest/coverage-v8
package.json 加三条脚本:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
vitest 默认进入 watch 模式,改文件就重跑;vitest run 跑一次就退出。CI 里必须用后者,否则会一直挂着。
配置文件单独放 vitest.config.js。注意 defineConfig 要从 vitest/config 导入,从 vite 导入的话 test 字段没有类型提示:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.js"],
environment: "node",
clearMocks: true,
restoreMocks: true,
coverage: {
provider: "v8",
reporter: ["text", "html"],
thresholds: { lines: 80, functions: 80, branches: 70 },
},
},
});
environment: "node" 适合纯逻辑;要测 DOM 就换成 jsdom 或 happy-dom,并额外 npm install -D jsdom。
第一个测试
被测代码是一个格式化价格的小函数,故意带上参数校验:
// src/money.js
export function formatPrice(cents, currency = "CNY") {
if (!Number.isInteger(cents)) {
throw new TypeError("cents must be an integer");
}
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency,
}).format(cents / 100);
}
测试文件放在旁边,命名为 money.test.js:
// src/money.test.js
import { describe, expect, it } from "vitest";
import { formatPrice } from "./money.js";
describe("formatPrice", () => {
it("把整数分格式化为人民币", () => {
expect(formatPrice(1999)).toBe("¥19.99");
});
it("带千分位", () => {
expect(formatPrice(100000)).toBe("¥1,000.00");
});
it("非整数直接报错", () => {
expect(() => formatPrice(19.9)).toThrow(TypeError);
});
});
describe 分组,it(等价于 test)描述一条行为,expect 做断言。跑一次:
npm run test:run
通过时只报文件与用例数;失败时会指出期望值与实际值,并给出源码行号。
断言怎么选
toBe 与 toEqual 的区别是最常踩的坑:
| 断言 | 比较方式 | 用在哪 |
|---|---|---|
toBe |
Object.is,比引用 |
数字、字符串、布尔、同一个对象 |
toEqual |
递归比较结构,忽略 undefined 属性 |
对象、数组、普通数据结构 |
toStrictEqual |
递归比较,同时检查 undefined 属性与原型 |
需要严格一致时 |
toMatchObject |
只检查给出的子集 | 返回值字段很多、只关心其中几个 |
toBeCloseTo |
浮点近似 | 小数运算 |
toThrow |
抛错 / 错误消息子串 | 参数校验、失败分支 |
expect({ a: 1 }).toEqual({ a: 1 }); // 通过
expect({ a: 1 }).toBe({ a: 1 }); // 失败:两个不同对象
expect(0.1 + 0.2).toBeCloseTo(0.3); // 通过
expect({ id: 1, name: "Ada" }).toMatchObject({ id: 1 }); // 通过
同一套逻辑要验多组输入时用 test.each,比复制粘贴十个 it 更好维护:
it.each([
[0, "¥0.00"],
[100, "¥1.00"],
[1999, "¥19.99"],
])("formatPrice(%i) => %s", (cents, expected) => {
expect(formatPrice(cents)).toBe(expected);
});
用例名里的 %i、%s 会被依次替换成参数值,失败时一眼看出是哪一组。
测异步代码
异步函数直接 await,断言用 resolves / rejects:
// src/users.js
export async function loadUser(fetchImpl, id) {
const res = await fetchImpl(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
import { expect, it, vi } from "vitest";
import { loadUser } from "./users.js";
it("返回解析后的 JSON", async () => {
const fakeFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: "Ada" }),
});
await expect(loadUser(fakeFetch, 1)).resolves.toEqual({ id: 1, name: "Ada" });
expect(fakeFetch).toHaveBeenCalledWith("/api/users/1");
});
it("非 2xx 时抛错", async () => {
const fakeFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
await expect(loadUser(fakeFetch, 1)).rejects.toThrow("HTTP 503");
});
await 不能省。expect(promise).resolves.toBe(...) 不加 await 时,断言还没执行测试就结束了,失败也不会报——这类假通过最难查。
涉及 setTimeout、重试、节流的代码用假计时器,别真的等:
import { afterEach, beforeEach, expect, it, vi } from "vitest";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("等待退避时间后才重试", async () => {
const task = retryOnce(fetchOnce, { delayMs: 1000 });
await vi.advanceTimersByTimeAsync(1000);
await expect(task).resolves.toBe("ok");
});
advanceTimersByTimeAsync 会在推进时间后把因此产生的微任务跑完,正好对应「定时器回调里 await 了别的东西」这种场景。同步版本 advanceTimersByTime 不够用。
测试替身:fn、spy 与 mock
三种替身对应三种需求:
// 1. 凭空造一个函数,记录调用
const onClick = vi.fn();
onClick("save");
expect(onClick).toHaveBeenCalledTimes(1);
// 2. 替换对象上的方法,保留原对象
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
doSomething();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
// 3. 整个模块替换
vi.mock("./mailer.js", () => ({
sendMail: vi.fn().mockResolvedValue({ queued: true }),
}));
vi.mock 会被提升到文件顶部,比 import 还早。所以工厂函数里不能引用模块顶层的变量:
// 错误:factory 执行时 sendMock 还是 undefined
const sendMock = vi.fn();
vi.mock("./mailer.js", () => ({ sendMail: sendMock }));
// 正确:用 vi.hoisted 把变量一起提升
const { sendMock } = vi.hoisted(() => ({ sendMock: vi.fn() }));
vi.mock("./mailer.js", () => ({ sendMail: sendMock }));
替身不清理会串味:上一个用例的调用记录留到下一个,断言就假通过。两种做法选一种:
- 配置里开
clearMocks: true、restoreMocks: true(本文开头已开); - 或在
afterEach里显式vi.restoreAllMocks()。
clearMocks 只清调用记录,restoreMocks 会把 spyOn 替换的原方法还回去,一般两个都要。
覆盖率:看趋势,不当 KPI
npm run test:coverage
v8 provider 直接用 V8 的内置覆盖数据,不用插桩,速度更快;istanbul 更老、兼容性更广。终端会打印每行、分支、函数的百分比,reporter 里的 html 会在 coverage/index.html 生成可点开的报告,红黄绿标出没被走到的分支。
阈值配在 coverage.thresholds,低于阈值命令直接失败,适合放在 CI。但覆盖率只说明代码被执行过,不说明断言写对了:
// 覆盖率 100%,但什么都没验证
it("调用不报错", () => {
expect(() => formatPrice(1999)).not.toThrow();
});
先补「边界 + 失败分支」的用例,再看覆盖率数字。
放进 CI
CI 上的命令只有一条:
- run: npm ci
- run: npx vitest run --reporter=dot
几个实用参数:
--bail=1:第一个失败就停,日志更短。--coverage:与test:coverage等价,记得同时上传coverage/产物。--shard=1/2、--shard=2/2:把测试分到多个 job 并行,用例多了才划算。--retry=1:只对确实不稳定的用例开,长期靠重跑掩盖 flaky 等于没测。
本地开发用 npm test(watch),提交前跑一次 npm run test:run,和 lint、build 一起进 pre-push 或 CI。
常见错误与注意事项
1. 用 toBe 比较对象或数组
两个结构相同但引用不同的对象必然失败。改用 toEqual。
2. 异步断言忘了 await
expect(promise).resolves 不 await 时测试不会等待,失败也被吞掉。这类问题只能靠「故意改坏代码,看测试是否变红」来发现。
3. vi.mock 工厂引用外部变量
提升之后变量还没初始化,运行时报 Cannot access before initialization。用 vi.hoisted 或把值写死在工厂里。
4. 假计时器没还原
vi.useFakeTimers() 之后忘了 vi.useRealTimers(),后续用例里的 setTimeout 全部不动,表现为莫名其妙的超时。
5. 测试之间共享状态
模块级数组、单例缓存、数据库连接都会让用例顺序影响结果。每个 it 自己准备数据,在 beforeEach 里重建。
6. 断言实现细节
断言「内部调用了哪个私有函数」而不是「输入对应的输出」,重构一次就红一片。优先测公开行为。
7. 只测成功路径
错误分支、空数组、null、超时才最容易出 bug。至少给每个 throw 配一条用例。
小结
vitest是 watch,vitest run是 CI;配置文件里defineConfig要从vitest/config导入。toBe比引用,toEqual比结构,浮点用toBeCloseTo,多组输入用test.each。- 异步断言必须
await;定时器相关逻辑用假计时器,推进时间用advanceTimersByTimeAsync。 - 替身分
vi.fn/vi.spyOn/vi.mock三层;vi.mock会提升,外部变量要用vi.hoisted。 - 覆盖率看趋势不看数字,CI 里用
vitest run,失败阈值才有意义。