《TypeScript编程入门》15.1 单元测试(Vitest/Jest)

本节从零为 TypeScript 项目搭起单元测试:先比较 Vitest 与 Jest 的取舍,再手写第一个 describe/it/expect 用例,讲清测试代码本身同样受 tsc 检查、mock 如何保持类型安全、异步与假定时器该怎么测。随后给出两者的 API 对照表,并总结 globals 类型缺失、vi.mock 提升等常见报错,读完你能为真实工程补上一套可维护的测试。

本节目标:读完这一节,你能为任意一个 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,配置量小得多。

维度VitestJest
TypeScript 支持原生(esbuild 转译)需 ts-jest / babel-jest
ESM 支持原生需实验性配置
配置文件vitest.config.tsjest.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 与配置:

能力VitestJest
造 mock 函数vi.fn()jest.fn()
监视对象方法vi.spyOn(o, "m")jest.spyOn(o, "m")
模块 mockvi.mock("m", factory)jest.mock("m", factory)
假定时器vi.useFakeTimers()jest.useFakeTimers()
清除全部vi.clearAllMocks()jest.clearAllMocks()
类型定义vitest/globals@types/jest
配置文件vitest.config.tsjest.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) 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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