《TypeScript编程实战》4.1 Vitest 单元测试

本节把单元测试落到 Vitest 上:先说明现代 TypeScript 项目为何更倾向它,再给出安装步骤与 vitest.config.ts 最小写法;随后用汇率换算与订单支付两个例子演示 describe/it/expect 写法与 toBe/toEqual 的判等差异,并展开 vi.fn、vi.spyOn、vi.mock 三种替身与假定时器的用法。读完你能写出跑得快、报错清晰的单元测试。

本节目标:把「测试」从一句口号变成你每天都会跑的命令。读完后,你会知道为什么现代 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 的转换管线。

维度JestVitest说明
TypeScript需要 ts-jest 或 babel-jest开箱即用,esbuild 转译少一层转译不一致的风险
ESM长期需要 --experimental-vm-modules原生支持现代包的 exports 字段直接生效
路径别名另配 moduleNameMapper复用 vite-tsconfig-paths与第 2 章的别名配置单一来源
监听模式以全量重跑为主基于 Vite 模块图增量重跑大项目里体验差异明显
APIdescribe/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:判等的三个层次

这是单元测试里最高频的误用点:

匹配器判等方式典型用途
toBeObject.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 1include 模式不匹配检查配置与文件命名(.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 initializationvi.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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes