TypeScript 测试入门:Vitest 与第一条用例

《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 官方文档为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇