《TypeScript 测试入门:Vitest 与第一条用例》
本文做什么,不做什么
给一个纯 TypeScript 项目——CLI 工具、工具库、SDK 都算——从零配上测试:装 Vitest、写第一条用例、把异步和类型断言跑通,最后在 CI 里立起两道闸。边界先说死:UI 组件测试不在本文范围,那归 Vue、React 各自生态的测试体系;mock 只给一个最小可跑的例子,完整玩法不展开;浏览器环境(happy-dom/jsdom)本文用不到,示例全是 Node 环境跑纯逻辑。
为什么我选 Vitest
2025 年给 TypeScript 项目选测试框架,我的判断不再纠结:新项目默认 Vitest。理由三条:
- 转译快。Jest 要靠 ts-jest 或 Babel 才能吃 TS,Vitest 直接复用 Vite 的 esbuild 转译,watch 模式下改动后基本是秒级出结果。写测试的手感差别很大——反馈慢一秒,跑全量的意愿就少一分。
- API 对 Jest 几乎照搬。describe/it/expect/vi 这套名字和语义一致,从 Jest 项目迁过来,多数时候只改 import 来源。
- 配置少。一个 vitest.config.ts 搞定,还能和 Vite 应用共享同一份别名与插件配置。
补一句反向判断:存量的大型 Jest 项目,我不会为追新而迁移——迁移要动配置、动 import、动快照,换来的收益主要是启动快几秒,对跑了多年的项目不划算。新项目 Vitest,老项目等到非动不可再说,选型决策跟人走不如跟成本走。
代价也要说清楚:esbuild 只做转译,不做类型检查。类型写错了,测试照样绿。这个坑后面单独一节,它直接决定了 CI 必须两道闸并行。
安装与 tsconfig 双轨
找个现成项目,比如把 TypeScript 命令行实战里的那个工具翻出来,先装依赖:
npm install -D vitest
package.json 里加两条脚本,CI 和本地 watch 分开:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
然后是 tsconfig 双轨。我的做法:源码轨 tsconfig.json 只管 src,noEmit 开着——出产物是打包器(esbuild/tsup)的事,tsc 只负责检查;测试轨 tsconfig.test.json 继承源码轨,把 tests 目录和 Vitest 的全局类型加进来。
// tsconfig.json(源码轨)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
// tsconfig.test.json(测试轨)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src", "tests"]
}
为什么分两份:如果只有一份并且开了 vitest/globals,业务代码里也能骗过类型检查用上全局的 expect——这不该被允许。分轨之后,vitest/globals 只对测试文件生效,源码想用断言必须显式 import,边界干净。
其实不开 globals 也行——测试文件里 import { describe, it, expect } from 'vitest',跳转和补全反而更清楚。双轨的价值在于 include 划分清楚:哪份配置管哪些文件,一目了然,CI 里也能各查各的。
noEmit 为什么两轨都开:源码轨开了,是因为出产物归打包器管,tsc 出一份和自己不一致的产物纯属添乱;测试轨开了,是因为测试根本不需要产物——跑完就扔,落一份编译后的测试文件到磁盘上只会污染目录。管住”tsc 只检查、不生产”,两轨的职责就闭环了。
第一条用例
拿一个真实感强的小函数当靶子,比如字节格式化:
// src/format.ts
const UNITS = ['B', 'KB', 'MB', 'GB'] as const;
export function formatBytes(bytes: number): string {
if (bytes < 0) throw new RangeError('字节数不能为负');
let value = bytes;
let i = 0;
while (value >= 1024 && i < UNITS.length - 1) {
value /= 1024;
i++;
}
return `${i === 0 ? value : value.toFixed(1)} ${UNITS[i]}`;
}
测试文件我习惯集中放 tests/ 目录,文件名以 .test.ts 结尾。另一种流派是贴着源码叫 format.ts / format.test.ts,两种都常见,选定一种别混用就行——我选集中放的理由是纯逻辑库的源码目录保持干净,打包时也少一层”排除测试文件”的心智负担。
// tests/format.test.ts
import { describe, expect, it } from 'vitest';
import { formatBytes } from '../src/format';
describe('formatBytes', () => {
it('1024 以内原样输出', () => {
expect(formatBytes(512)).toBe('512 B');
});
it('跨过 1024 换 KB,保留一位小数', () => {
expect(formatBytes(1536)).toBe('1.5 KB');
});
it('负数直接拒绝', () => {
expect(() => formatBytes(-1)).toThrow(RangeError);
});
});
跑 npm test,预期输出大致长这样(时长每次不同,忽略):
RUN v3.1.2 D:/project/byte-tool
✓ tests/format.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
三条用例分别钉住了三件事:正常路径的输出值、换算边界的精度、非法输入的报错类型。写用例时我习惯先钉边界再补常规——1024 本身(恰好等于 1.0 KB)、单位到顶(比 GB 还大的数)都是值得再加的临界点,下一节用表格一次铺完。
日常怎么跑:watch、单文件与”先红后绿”
日常节奏靠 watch:npm run test:watch 进交互模式,改哪个文件就重跑相关用例。定位问题时别全量跑,把范围收窄:
npx vitest run tests/format.test.ts # 只跑一个文件
npx vitest run -t "负数" # 只跑名字匹配的用例
顺序上我倾向”先写失败用例再补实现”:新函数先写一条注定失败的用例,看着它红,再补实现看它绿。不是教条式 TDD,而是确认测试真的在跑——没见过红的用例,你不知道它绿的时候是不是空转。后面讲 expectTypeOf 时这一点会再次出现。
用 it.each 把边界值铺成表
临界点用表格铺出来比一条条 it 清晰。it.each 吃一个数组,逐行生成用例:
it.each([
[0, '0 B'],
[1023, '1023 B'],
[1024, '1.0 KB'],
[1536, '1.5 KB'],
[1024 ** 3, '1.0 GB'],
])('%d 会得到 %s', (input, expected) => {
expect(formatBytes(input)).toBe(expected);
});
1023、1024、1025 这类临界点一行一个,1024 ** 3 那行顺手钉住了”单位到顶”的行为。输出里每行是独立用例,挂了直接看到是哪个数不对。表驱动适合”同一逻辑多组输入”的场景;逻辑分叉大的(比如要 mock 不同环境的)就老老实实拆 it,别硬塞进表里。
失败时输出长什么样
健康与否一半靠看失败的样子。临时把 1536 的期望值改成 '1.6 KB',输出会精确指到用例名与 diff:
FAIL tests/format.test.ts > formatBytes > 跨过 1024 换 KB,保留一位小数
AssertionError: expected '1.5 KB' to be '1.6 KB'
Test Files 1 failed (1)
Tests 1 failed (3)
用例名、期望与实际的值、挂在哪一行,三样都有。哪天你看到失败输出里这三个信息缺了一样(比如只有退出码非零),说明用例写得还不够”会说话”——先改用例再修代码,别让红灯只当信号灯用。
异步测试:最容易忘的那个 await
异步函数直接 async/await,没有新语法。真正容易翻车的是 rejection 断言忘写 await:
// tests/config.test.ts
import { describe, expect, it } from 'vitest';
import { loadConfig } from '../src/config';
describe('loadConfig', () => {
it('读得到就返回解析后的对象', async () => {
const cfg = await loadConfig('fixtures/app.json');
expect(cfg.port).toBe(8080);
});
it('文件不存在时抛出带路径的错误', async () => {
await expect(loadConfig('no-such.json')).rejects.toThrow(/no-such.json/);
});
});
第二条例子里把 await 拿掉,用例照样显示绿——断言挂在一个未决的 Promise 上,真正的 rejection 变成一条 unhandled rejection 警告从旁边溜走。Vitest 会把这类警告报出来,但别赌运行环境替你兜底。我的经验法则:见到异步函数进 expect,先看前面有没有 await。
样例数据我放 tests/fixtures/ 目录,测试读固定文件而不是现造——loadConfig 这类读盘函数的用例,fixture 文件还能顺便充当”合法配置长什么样”的活文档。
类型层面的断言:expectTypeOf
Vitest 自带 expectTypeOf,能把”这个函数的返回类型必须是 string”写成测试:
// tests/format.test-d.ts
import { expectTypeOf, test } from 'vitest';
import { formatBytes } from '../src/format';
test('formatBytes 的入参与出参', () => {
expectTypeOf(formatBytes).parameters.toEqualTypeOf<[number]>();
expectTypeOf(formatBytes(1024)).toEqualTypeOf<string>();
});
关键认知:普通 vitest run 里 expectTypeOf 是空转的。它在转译阶段就被擦掉了,不产生任何运行时断言,类型写错测试照样绿——和上一节”先红后绿”呼应:类型断言你连红都看不到,谈何保护。要让它真的干活,得跑 vitest --typecheck(文件名用 .test-d.ts 命名,typecheck 默认只认这个模式),或者交给 tsc --noEmit——expectTypeOf 的类型错误本来就会被 tsc 抓到。我在 CI 里用前者,因为它把类型断言当独立用例报告,哪条类型不匹配看得清清楚楚。
测试替身:一个最小例子就够
测到依赖外部的模块时才需要替身,最小可用的是 vi.fn——不给真实依赖,给一个会记录调用的假函数。假设有个函数签名长这样:fetchConfig(fetch: () => Promise<string>),接收一个取文本的函数并解析 JSON:
// tests/fetcher.test.ts
import { expect, test, vi } from 'vitest';
import { fetchConfig } from '../src/fetcher';
test('拿到配置文本后原样解析', async () => {
const fakeFetch = vi.fn().mockResolvedValue('{"port":8080}');
await expect(fetchConfig(fakeFetch)).resolves.toEqual({ port: 8080 });
expect(fakeFetch).toHaveBeenCalledTimes(1);
});
能靠参数注入解决的,就别用 vi.mock 整模块替换——注入让依赖显式出现在函数签名里,测试读起来不用猜。模块级副作用(import 那一刻就自动连数据库那种)才轮到 vi.mock 上场,那个话题单独够写一篇,本文到注入为止。
CI 两道闸,缺一道都白搭
前面说过 esbuild 不查类型,所以 Vitest 全绿不等于类型安全。CI 里必须两道闸并行:
# .github/workflows/ci.yml 的关键步骤
- run: npm ci
- run: npx tsc --noEmit -p tsconfig.json
- run: npx tsc --noEmit -p tsconfig.test.json
- run: npx vitest run
两份 tsconfig 各查一遍,因为 include 不同。我吃过亏:只查源码轨,测试文件里把 API 用错了,tsc 没吭声,测试先跑了错的——绿着上线,第二个星期才在同一处炸出来。
覆盖度,一行带过
装 @vitest/coverage-v8,跑 npx vitest run --coverage 就有报告。想让它有牙齿,在配置里加 test: { coverage: { thresholds: { lines: 80 } } },不达标直接红。默认 v8 provider 够快,要更精确的分支统计再换 istanbul,代价是慢一截。覆盖率只当底线用,别当 KPI 追——它管不住断言质量,管得住”这段代码从来没人碰过”。
验收清单
npm test全绿,输出里 Test Files 与 Tests 都是 passed;- 把
formatBytes(-1)那条用例里的 throw 临时注释掉,重跑必须变红——确认测试真的在拦; - it.each 表里挑一行改错期望值,重跑时输出直接指到那一行参数;
npx tsc --noEmit -p tsconfig.test.json零错误,测试轨类型过关;npx vitest --typecheck run下.test-d.ts里的类型断言有独立报告,故意把 string 改成 number 能看到它报错;- 故意写一个没 await 的 rejects 断言,能在输出里找到 unhandled rejection 的提示。
学完之后
这套流程对应 TypeScript 学习路线阶段四”类型安全实战”之后该补的一环:类型把错误挡在编译期,测试把逻辑错挡在上线前,两套网各管各的,expectTypeOf 正好是两套网的交点。给 命令行实战的工具函数补上第一批用例是最顺手的练习,泛型、类型收窄这类条目拿不准时翻 TypeScript 参考字典。下一步想测带状态的模块,去研究 vi.mock 与 vi.spyOn 的差别;想在浏览器环境测 DOM 代码,再考虑 happy-dom 或 jsdom 的 environment 配置,本文留的白也在这儿。
本文数据核对时间:2025-04-21:TypeScript 5.8.x(5.8 GA 2025-02-28,Microsoft 官方博客口径;5.7 为 2024-11 上一稳定线);Vitest 3.1.x(3.0 GA 2025-01-17,3.1 于 2025-03 底/04 初发布,官方渠道口径);Node.js 22 LTS(2024-10-29 进入 LTS,2025-04 时最新补丁为 22.14.x)。API 细节以 vitest.dev 与 tsconfig 官方文档为准。