本节目标:把「测试」从一句口号变成你每天都会跑的命令。读完后,你会知道为什么现代 TypeScript 项目更倾向 Vitest,能从零装好它并写下第一个断言,能分清
toBe与toEqual的判等语义,能用vi.fn/vi.spyOn/vi.mock三种替身隔离依赖,还能用假定时器把「三十天后过期」压缩到毫秒级测完。
4.1 Vitest 单元测试
前几章我们把地基铺好了:用 pnpm + tsx + tsup 搭脚手架,用严格模式与分层 tsconfig 把类型错误挡在编译期,又用 Result 类型、错误边界和结构化日志讲清了「出错时会发生什么」。
但有个问题始终悬着:你凭什么相信这些代码是对的? 类型系统能挡住「字段名写错」「少传一个参数」,却挡不住「金额算反了」「汇率取错方向」。这类错误只会在线上爆发,测试就是补上这一层的手段。本节先把单元测试做扎实,集成测试留给 4.2,类型测试与覆盖率门禁留给 4.3。
最朴素的「测试」是手写 if (result !== 3) throw new Error(...)。它能跑,但失败时你只看到一句「计算结果错误」,不知道实际得到的是几;想验证 20 个函数就要写 20 段 if,第一个失败后面的全不执行;也没法 mock 时间、统计覆盖率。测试框架解决的正是这四件事:断言表达力、批量组织与独立执行、替身与生命周期钩子、覆盖率与并行调度。
4.1.1 为什么选 Vitest 而不是 Jest
Vitest 已是新 TypeScript 项目的默认选择,核心原因不在 API,而在它复用了 Vite 的转换管线。
| 维度 | Jest | Vitest | 说明 |
|---|---|---|---|
| TypeScript | 需要 ts-jest 或 babel-jest | 开箱即用,esbuild 转译 | 少一层转译不一致的风险 |
| ESM | 长期需要 --experimental-vm-modules | 原生支持 | 现代包的 exports 字段直接生效 |
| 路径别名 | 另配 moduleNameMapper | 复用 vite-tsconfig-paths | 与第 2 章的别名配置单一来源 |
| 监听模式 | 以全量重跑为主 | 基于 Vite 模块图增量重跑 | 大项目里体验差异明显 |
| API | describe/it/expect | 高度兼容 Jest | 迁移成本低,jest.fn → vi.fn |
Node.js 自带的 node:test 也在快速成熟,零依赖是优势,但缺少成熟的替身体系与覆盖率生态。想横向看 Vite 与 Vitest 在真实项目里的整合方式,可以延伸阅读 Vite 与 Vitest 测试实战
。
4.1.2 安装与最小配置
pnpm add -D vitest @vitest/coverage-v8
在 package.json 里加脚本,区分「跑一次」与「监听重跑」:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
vitest 默认进入监听模式,适合本地开发;CI 里必须用 vitest run,否则进程不会退出。这是新手在流水线上卡住的第一个坑。
接着建 vitest.config.ts,它和 vite.config.ts 是两套文件,但都走 Vite 的 defineConfig:
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "node", // 组件测试换成 "jsdom"
include: ["src/**/*.{test,spec}.ts"],
globals: false, // 显式 import,避免隐式全局污染类型
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts"],
},
},
});
globals: false 是有意为之:开启全局变量虽然省掉 import,但要让类型生效还得额外注入 "types": ["vitest/globals"]。
4.1.3 第一个测试:从纯函数开始
单元测试最好的起点是没有依赖的纯函数:
// src/money.ts
export type Currency = "CNY" | "USD";
export interface Money {
amount: number;
currency: Currency;
}
export function convert(
amount: number,
rate: number,
from: Currency,
to: Currency,
): Money {
if (!Number.isFinite(amount)) throw new TypeError("金额必须是有限数字");
if (rate <= 0) throw new RangeError("汇率必须为正数");
if (from === to) return { amount, currency: to };
return { amount: Number((amount * rate).toFixed(2)), currency: to };
}
测试文件与被测文件同名,只加 .test.ts 后缀:
// src/money.test.ts
import { describe, expect, it } from "vitest";
import { convert } from "./money";
describe("convert", () => {
it("同币种直接返回原值", () => {
expect(convert(100, 7.2, "CNY", "CNY")).toEqual({
amount: 100,
currency: "CNY",
});
});
it("跨币种按汇率换算并保留两位小数", () => {
expect(convert(100, 7.234, "USD", "CNY")).toEqual({
amount: 723.4,
currency: "CNY",
});
});
it("汇率为 0 时抛出 RangeError", () => {
expect(() => convert(100, 0, "USD", "CNY")).toThrow(RangeError);
});
});
describe 只做分组,真正的用例在 it 里。跑起来:
$ pnpm test
✓ src/money.test.ts (3 tests) 5ms
Test Files 1 passed (1)
Tests 3 passed (3)
Duration 328ms
注意 toThrow("有限数字") 这类写法:传字符串时 Vitest 做的是子串匹配,不是全等,要精确匹配得用正则 /^金额必须是有限数字$/。这个细节在断言第三方库报错时经常踩坑。
4.1.4 toBe 与 toEqual:判等的三个层次
这是单元测试里最高频的误用点:
| 匹配器 | 判等方式 | 典型用途 |
|---|---|---|
toBe | Object.is,引用/原始值严格相等 | 数字、字符串、布尔、同一引用 |
toEqual | 递归深比较,忽略 undefined 属性 | 对象、数组、嵌套结构 |
toStrictEqual | 深比较,检查 undefined 属性与原型 | 区分 {a: undefined} 与 {} |
toBeCloseTo | 按精度比较浮点数 | 0.1 + 0.2 这类浮点误差 |
最容易翻车的是把 toBe 用在对象上,Vitest 的提示会直接给出建议:
// ❌ 失败:两个字面量对象引用不同
expect(convert(100, 7.2, "CNY", "CNY")).toBe({ amount: 100, currency: "CNY" });
// AssertionError: expected { amount: 100, currency: 'CNY' } to be
// { amount: 100, currency: 'CNY' }
// If it should pass with deep equality, replace "toBe" with "toStrictEqual"
// 浮点数必须用 toBeCloseTo
expect(0.1 + 0.2).toBe(0.3); // ❌ 实际是 0.30000000000000004
expect(0.1 + 0.2).toBeCloseTo(0.3, 10); // ✅
4.1.5 用泛型约束替身:vi.fn 的类型参数
vi.fn() 不带类型参数时返回 Mock<(...args: any[]) => any>,什么都能塞,测试也就失去了编译期保护。正确做法是显式给出签名:
import { expect, it, vi } from "vitest";
it("回调收到的参数类型受约束", () => {
const onPaid = vi.fn<(orderId: string, amount: number) => void>();
onPaid("o-1", 199);
expect(onPaid).toHaveBeenCalledWith("o-1", 199);
onPaid("o-2", "199");
// ❌ TS2345: Argument of type 'string' is not assignable to parameter of type 'number'
});
这是 TypeScript 写测试的最大红利:测试代码本身也被类型检查。把金额单位从「分」改成「元」时所有断言会一起报错,逼你逐个确认语义。
4.1.6 三种替身:vi.fn、vi.spyOn、vi.mock
替身解决的是「被测代码依赖了外部世界」的问题。先看被测服务:
// src/order-service.ts
export interface PaymentGateway {
charge(orderId: string, amount: number): Promise<{ ok: boolean; txId: string }>;
}
export interface OrderRepository {
save(order: { id: string; total: number; paid: boolean }): Promise<void>;
}
export class OrderService {
constructor(
private readonly repo: OrderRepository,
private readonly gateway: PaymentGateway,
) {}
async checkout(id: string, total: number): Promise<string> {
const result = await this.gateway.charge(id, total);
if (!result.ok) throw new Error(`支付失败: ${id}`);
await this.repo.save({ id, total, paid: true });
return result.txId;
}
}
其一,vi.fn() 从零造替身,适合构造函数注入、接口驱动的场景:
import { describe, expect, it, vi } from "vitest";
import { OrderService } from "./order-service";
import type { OrderRepository, PaymentGateway } from "./order-service";
function makeService() {
const repo: OrderRepository = { save: vi.fn().mockResolvedValue(undefined) };
const gateway: PaymentGateway = {
charge: vi.fn().mockResolvedValue({ ok: true, txId: "tx-1" }),
};
return { service: new OrderService(repo, gateway), repo, gateway };
}
describe("OrderService.checkout", () => {
it("支付成功后落库并返回交易号", async () => {
const { service, repo, gateway } = makeService();
await expect(service.checkout("o-1", 199)).resolves.toBe("tx-1");
expect(gateway.charge).toHaveBeenCalledWith("o-1", 199);
expect(repo.save).toHaveBeenCalledWith({ id: "o-1", total: 199, paid: true });
});
it("支付失败时不落库", async () => {
const { service, repo, gateway } = makeService();
gateway.charge = vi.fn().mockResolvedValue({ ok: false, txId: "" });
await expect(service.checkout("o-2", 88)).rejects.toThrow("支付失败");
expect(repo.save).not.toHaveBeenCalled();
});
});
其二,vi.spyOn() 在真实对象上装监听,适合只关心「某方法被调了几次、参数是什么」而不想替换整个模块的场景:
import * as logger from "./logger";
it("结算失败时记录 error 日志", async () => {
const spy = vi.spyOn(logger, "error").mockImplementation(() => {});
await expect(service.checkout("o-3", 50)).rejects.toThrow();
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ orderId: "o-3" }), expect.any(Error));
spy.mockRestore(); // 恢复原实现,避免影响后续用例
});
mockImplementation(() => {}) 是必要的:不替换实现,真实日志会打到终端把测试输出冲乱。
其三,vi.mock() 替换整个模块,适合依赖是 import 进来的单例、无法从构造函数注入的情况:
import { expect, it, vi } from "vitest";
// 工厂函数会被提升到文件顶部执行
vi.mock("./db", () => ({
db: { order: { create: vi.fn(async () => ({ id: "o-1" })) } },
}));
import { db } from "./db";
import { createOrder } from "./order";
it("调用 db.order.create 并返回新订单", async () => {
await expect(createOrder({ total: 199 })).resolves.toEqual({ id: "o-1" });
expect(db.order.create).toHaveBeenCalledOnce();
});
vi.mock 的工厂会被提升(hoist)到文件顶部,所以它不能引用文件中定义的变量。写了 const fake = ... 再在工厂里用,运行时会得到 ReferenceError: Cannot access 'fake' before initialization。解决办法是用 vi.hoisted() 把变量一起提升:
const { fakeCreate } = vi.hoisted(() => ({
fakeCreate: vi.fn(async () => ({ id: "o-1" })),
}));
vi.mock("./db", () => ({ db: { order: { create: fakeCreate } } }));
4.1.7 假定时器:把时间变成可控变量
凡是和「过期」「重试间隔」「超时」有关的逻辑,都不要真的 sleep。假定时器可以随意拨动时钟:
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createToken } from "./token";
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-22T10:00:00+08:00"));
});
afterEach(() => vi.useRealTimers());
it("TTL 内未过期,超过 TTL 后过期", () => {
const token = createToken(30 * 60_000);
expect(token.isExpired()).toBe(false);
vi.advanceTimersByTime(30 * 60_000 + 1);
expect(token.isExpired()).toBe(true);
});
三个要点:useFakeTimers 必须和 useRealTimers 成对出现在钩子里,否则会污染同文件后续用例;setSystemTime 固定「现在」,让断言与真实日期解耦;advanceTimersByTime 同时推进 Date.now() 与定时器队列,所以能测「重试三次、每次退避 1 秒」而不用真等 3 秒。
4.1.8 异步测试最常见的两个坑
坑一:忘记 await,测试永远为真。
it("错误写法", () => {
expect(fetchUser("u-1")).resolves.toEqual({ id: "u-1" }); // 断言挂在未等待的 Promise 上
});
Vitest 会在控制台警告 Unhandled Rejection,但不会让用例失败。修复是给回调加 async 并 await,或直接 return 这个断言。
坑二:对同步抛错用 rejects。
// ❌ 同步函数抛错时 rejects 不生效,错误冒泡导致整个文件失败
expect(() => convert(1, 0, "USD", "CNY")).rejects.toThrow();
// ✅ 同步抛错用 toThrow,且必须包成箭头函数
expect(() => convert(1, 0, "USD", "CNY")).toThrow(RangeError);
规则很简单:返回 Promise 就用 resolves / rejects;同步抛错就用 toThrow 并包箭头函数——直接写 expect(convert(...)).toThrow() 会先执行函数,错误在 expect 之前就抛出来了。
4.1.9 常见报错速查
| 报错信息 | 原因 | 修复 |
|---|---|---|
No test files found, exiting with code 1 | include 模式不匹配 | 检查配置与文件命名(.test.ts / .spec.ts) |
Failed to resolve import "./money" | 路径别名未生效 | 确认 tsconfigPaths() 已加且别名写在 tsconfig.json |
Cannot find module 'vitest' | 漏装或用了全局命令 | pnpm add -D vitest,并用 pnpm test 运行 |
Cannot access 'x' before initialization | vi.mock 工厂引用了未提升的变量 | 用 vi.hoisted() 包装 |
小结
这一节我们从「为什么不满足于手写 if」出发,走完了 Vitest 的完整闭环:
- 选型理由:Vitest 复用 Vite 转换管线,TypeScript 与 ESM 开箱即用,路径别名与第 2 章配置单一来源,监听模式只重跑受影响文件。
- 最小配置:
vitest run用于 CI、vitest用于本地监听;globals: false换取显式依赖与完整类型支持。 - 断言语义:
toBe走Object.is,对象必须用toEqual/toStrictEqual,浮点数用toBeCloseTo。 - 替身三件套:接口注入用
vi.fn,监听真实对象用vi.spyOn,替换模块用vi.mock(注意 hoist 与vi.hoisted)。 - 时间与异步:假定时器把时间变成可控参数;
resolves/rejects必须await,同步抛错用toThrow包箭头函数。
不过你会发现,本节所有示例都在隔离掉外部世界:数据库是假的、支付网关是假的。这带来速度,也带来一个危险——如果假的 db.order.create 与真实 SQL 的约束不一致,测试全绿而线上照样炸。真实依赖不能永远靠 mock 想象。
下一节 4.2 集成测试与 Testcontainers
就来解决这个问题:用一次性容器拉起真实的 PostgreSQL 与 Redis,让集成测试跑在与生产同构的依赖上。如果还没建立严格的 tsconfig 分层,建议先回看 1.2 严格模式与 tsconfig 分层
,因为类型越严格,测试代码能替你发现的问题就越多。
阅读导航:上一节:3.3 结构化日志与脱敏 · 下一节:4.2 集成测试与 Testcontainers 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。