本节目标:读完这一节,你能为任意一个 TypeScript 项目装上单元测试——说清 Vitest 与 Jest 该怎么选,写出带类型推断的
describe/it/expect用例,用vi.fn造出类型安全的测试替身,正确测试 Promise 与假定时器;并且在遇到Cannot find name 'describe'、vi.mock不生效这类报错时能立刻定位原因。
15.1 单元测试(Vitest/Jest)
第 13 章我们讲过「类型擦除」:类型只在编译期存在,运行时的数据长什么样,编译器一无所知。第 14 章又把异步与错误处理理了一遍。到这里,你已经能写出「编译通过」的代码;但编译通过不等于行为正确——补上这一环的,正是测试。
本节是第 15 章「测试与质量保障」的开篇,先解决最基础的问题:怎么在 TypeScript 项目里写并运行单元测试。
为什么 TypeScript 项目更需要测试
有一种误解是「有了静态类型,就不用写测试了」。事实恰恰相反:类型只能证明「形状对得上」,证明不了「算得对」。
function divide(a: number, b: number): number {
return a / b;
}
divide(1, 0); // 类型完全合法,运行时得到 Infinity
类型系统在这里毫无办法:number / number 就是 number。而下面这些错误,也全都逃得过编译器:
- 边界条件写反(
>=写成>); - 分支漏了一种状态(判别联合能挡住一部分,但挡不住逻辑遗漏);
- 排序方向反了、金额算错一位小数;
- 空数组、空字符串、
null走到一半才炸。
更现实的一点是:TypeScript 项目里的重构比 JavaScript 频繁得多。类型给了你敢改代码的底气,测试则给了你「改完还对不对」的答案。二者是互补的,不是二选一。
选型:Vitest 还是 Jest
历史上 Jest 是前端事实标准,但它的 TypeScript 支持一直靠外挂(ts-jest 或 babel-jest),而 ESM 支持也长期不完整。Vitest 复用 Vite 的转换管线,原生吃 TypeScript 与 ESM,配置量小得多。
| 维度 | Vitest | Jest |
|---|---|---|
| TypeScript 支持 | 原生(esbuild 转译) | 需 ts-jest / babel-jest |
| ESM 支持 | 原生 | 需实验性配置 |
| 配置文件 | vitest.config.ts | jest.config.ts |
| 断言 API | 兼容 Jest(expect 同源) | 自带 |
| 类型测试 | 内置 --typecheck | 需 tsd / jest-runner-tsd |
| 运行速度 | 快(Vite 按需转换) | 较慢(全量转换) |
| 生态成熟度 | 较新 | 最广 |
结论:新项目直接选 Vitest;已有 Jest 项目不必迁移,本文的断言写法两边通用,差异只在配置与少量 API(后文有对照表)。
延伸阅读:既有专题里有一篇 Vitest 实战,可配合本节阅读 /vite-vitest-testing/ 。
环境搭建:Vitest 最小配置
npm i -D vitest vite typescript @types/node
npx vitest # watch 模式
npx vitest run # 单次运行,CI 用这个
npx vitest --ui # 图形界面(需额外装 @vitest/ui)
配置文件几乎可以留空,因为 Vitest 直接复用 Vite 的解析规则:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true, // 让 describe/it/expect 免 import
environment: "node", // 前端项目改成 "jsdom"
include: ["src/**/*.{test,spec}.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
},
},
});
开了 globals: true 之后还要在 tsconfig.json 里补上类型,否则编辑器会红一片:
{
"compilerOptions": {
"types": ["vitest/globals", "node"]
}
}
这一步就是新手最常踩的第一个坑——配置写了,但 TS 不认识这些全局变量。
第一个测试:describe / it / expect
假设我们有一个纯函数模块:
// src/money.ts
export interface Money {
amount: number; // 以「分」为单位,避免浮点误差
currency: "CNY" | "USD";
}
export function addMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error(`币种不一致:${a.currency} vs ${b.currency}`);
}
return { amount: a.amount + b.amount, currency: a.currency };
}
对应的测试文件:
// src/money.test.ts
import { describe, it, expect } from "vitest";
import { addMoney, type Money } from "./money";
const cny = (amount: number): Money => ({ amount, currency: "CNY" });
describe("addMoney", () => {
it("同币种金额相加", () => {
const result = addMoney(cny(100), cny(250));
expect(result).toEqual({ amount: 350, currency: "CNY" });
});
it("币种不一致时抛错", () => {
expect(() => addMoney(cny(1), { amount: 1, currency: "USD" }))
.toThrow(/币种不一致/);
});
});
跑起来会看到:
✓ src/money.test.ts (2 tests) 3ms
Test Files 1 passed (1)
Tests 2 passed (2)
这里有个测试文件同样受 tsc 检查的关键点:addMoney 的入参是 Money,你不可能在测试里传一个 { amount: 1 } 少写字段还能编译过。测试代码和生产代码享受同一套类型保护,这正是 TypeScript 项目写测试比 JavaScript 舒服的地方。
断言里的类型:expect 是泛型函数
expect 不是「无类型」的黑箱。它返回一个带泛型的匹配器对象,写错匹配器或参数类型,编辑器会直接报错:
const result = addMoney(cny(100), cny(250));
expect(result.amount).toBe(350); // ✅ 参数是 number
expect(result.amount).toBe("350"); // ❌ 类型报错
// Argument of type 'string' is not assignable to parameter of type 'number'.
常用匹配器对照:
| 匹配器 | 判断方式 | 适用场景 |
|---|---|---|
toBe(v) | Object.is | 原始值、同一个引用 |
toEqual(v) | 递归结构比较 | 对象、数组 |
toStrictEqual(v) | 结构 + 类型 + undefined 键 | 严格对象比较 |
toContain(v) | 包含 | 字符串、数组 |
toThrow(e?) | 抛错(须传函数) | 异常路径 |
toMatchSnapshot() | 快照 | 大块输出 |
toBe 与 toEqual 的区别最容易出错:expect({ a: 1 }).toBe({ a: 1 }) 会失败,因为两个字面量不是同一个对象;换成 toEqual 才通过。
测试替身:让 mock 也带上类型
「测试替身(test double)」是用一个可控对象顶替真实依赖。JavaScript 里这很容易写飞,TypeScript 可以约束它必须长得像被替身的接口。
import { vi, expect, it } from "vitest";
interface UserRepo {
findById(id: number): Promise<{ id: number; name: string } | null>;
}
// vi.fn 支持传入实现,且泛型会从实现里推断出来
const repo: UserRepo = {
findById: vi.fn(async (id: number) =>
id === 1 ? { id: 1, name: "Ada" } : null,
),
};
it("命中缓存时直接返回", async () => {
const user = await repo.findById(1);
expect(user?.name).toBe("Ada");
expect(repo.findById).toHaveBeenCalledWith(1);
expect(repo.findById).toHaveBeenCalledTimes(1);
});
如果 vi.fn 的返回值不符合 UserRepo.findById 的签名(例如直接返回 "Ada" 而不是 Promise),赋值给 repo 那一行就会报错——这就是「类型安全的 mock」。
模块级替身用 vi.mock:
import { vi } from "vitest";
vi.mock("./logger", () => ({
logger: { info: vi.fn(), error: vi.fn() },
}));
注意 vi.mock 会被提升(hoist)到文件顶部,且它的工厂函数里不能引用外部变量(提升后那些变量还没初始化):
const fake = vi.fn();
vi.mock("./logger", () => ({ logger: { info: fake } })); // ❌ ReferenceError
// ✅ 用 vi.hoisted 显式提升
const { fake } = vi.hoisted(() => ({ fake: vi.fn() }));
vi.mock("./logger", () => ({ logger: { info: fake } }));
异步代码怎么测
有三种常见写法,推荐第一种:
it("async/await 写法(推荐)", async () => {
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: "Ada" });
});
it("断言 reject", async () => {
await expect(fetchUser(-1)).rejects.toThrow("id 必须为正数");
});
最常见的异步坑是「忘了 await」:测试会立刻通过,因为断言根本没执行。因此优先用 async 函数加 await,避免写成 return expect(...) 那种容易漏掉的形式;跑完顺手看一眼输出的 Test Files 与 Tests 数量是否和预期一致。
定时器则用假定时器,避免测试真的睡三秒:
import { vi, it, expect, beforeEach, afterEach } from "vitest";
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("3 秒后重试一次", async () => {
const task = retryAfter(3000);
await vi.advanceTimersByTimeAsync(3000);
await expect(task).resolves.toBe("ok");
});
用 advanceTimersByTimeAsync 而不是同步版 advanceTimersByTime,是因为回调里有 await,同步版不会等待微任务队列排空。
Jest 与 Vitest 的差异
两者断言 API 同源,主要差异在 mock 与配置:
| 能力 | Vitest | Jest |
|---|---|---|
| 造 mock 函数 | vi.fn() | jest.fn() |
| 监视对象方法 | vi.spyOn(o, "m") | jest.spyOn(o, "m") |
| 模块 mock | vi.mock("m", factory) | jest.mock("m", factory) |
| 假定时器 | vi.useFakeTimers() | jest.useFakeTimers() |
| 清除全部 | vi.clearAllMocks() | jest.clearAllMocks() |
| 类型定义 | vitest/globals | @types/jest |
| 配置文件 | vitest.config.ts | jest.config.ts |
Jest 侧最小配置(走 ts-jest 路线):
// jest.config.ts
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/*.test.ts"],
};
export default config;
只要记住 jest.* ↔ vi.* 这一条映射,绝大多数测试代码可以直接搬。
常见坑与报错
坑一:Cannot find name 'describe'。 开了 globals: true 却忘了在 tsconfig.json 的 types 里加 "vitest/globals"。要么补类型,要么老老实实 import { describe, it, expect } from "vitest"。
坑二:vi.mock 工厂里引用外部变量报 ReferenceError。 原因是提升,解法是 vi.hoisted。
坑三:toEqual 与 toStrictEqual 混用。 后者会区分 undefined 属性与「键不存在」,比较带可选字段的对象时更严格,也更贴近真实契约。
坑四:ESM 下 require 报错。 项目若在 package.json 里设了 "type": "module",就不要在测试里用 require,统一走 import。
坑五:测试通过但实际没跑。 忘了 await,或忘了在 include 里匹配到该文件。看一眼输出的 Test Files 数量,比看绿字更靠谱。
一个真实工程示例
把第 14 章的 Result 模式与本节结合起来——测试一个返回 Result<string, "NOT_FOUND" | "BAD_ID"> 的服务函数 getUserName(repo, id):它先校验 id 是否为正整数(不合法直接返回 BAD_ID 且不查库),再调 repo.findById,命中返回 ok: true,未命中返回 NOT_FOUND。
// src/user-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { getUserName, type UserRepo } from "./user-service";
function makeRepo(hit: boolean): UserRepo {
return { findById: vi.fn(async (id: number) => (hit ? { id, name: "Ada" } : null)) };
}
describe("getUserName", () => {
it("找到用户时返回 ok", async () => {
const result = await getUserName(makeRepo(true), 1);
expect(result).toEqual({ ok: true, value: "Ada" });
});
it("找不到时返回 NOT_FOUND", async () => {
expect(await getUserName(makeRepo(false), 1))
.toEqual({ ok: false, error: "NOT_FOUND" });
});
it("非法 id 不查库", async () => {
const repo = makeRepo(true);
const result = await getUserName(repo, -1);
expect(result).toEqual({ ok: false, error: "BAD_ID" });
expect(repo.findById).not.toHaveBeenCalled(); // 提前返回,不该打库
});
});
expect(repo.findById).not.toHaveBeenCalled() 这一行才是真正有价值的断言——它验证的是「行为」而不只是「结果」。判别联合 Result 让 result.error 在 ok: false 分支下自动收窄为字面量联合,写断言时能获得完整的自动补全。相关写法见 14.1 错误类型与 Result 模式
。
外部数据的运行时校验(比如接口返回的 JSON)不属于单元测试的职责,它应该在边界处用 schema 挡住,见 13.3 API 契约与边界数据校验 。
小结
- 类型只保证「形状对」,测试才保证「行为对」;TypeScript 项目重构频繁,测试是敢改代码的前提。
- 新项目选 Vitest(原生 TS/ESM、配置少),存量项目可继续用 Jest;两者断言 API 同源,
jest.*与vi.*一一对应。 describe/it/expect的写法两边通用;测试文件同样受 tsc 检查,mock 也要满足被替身接口的类型。toBe比引用、toEqual比结构;异步务必await,定时器用假定时器配advanceTimersByTimeAsync。- 三个高频报错:
Cannot find name 'describe'(补types)、vi.mock工厂ReferenceError(用vi.hoisted)、断言没生效(漏await)。
单测跑起来了,但它只覆盖运行时行为。如果被测试的是一个「类型层面」的工具函数,比如 Pick、Awaited 或自己写的 Unwrap<T>,运行时断言根本无从下手——下一节 15.2 类型测试(tsd/expect-type)
就来解决这个问题。
阅读导航:上一节:14.3 并发控制、取消与超时 · 下一节:15.2 类型测试(tsd/expect-type) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。