Vitest 单元测试实战:TDD 流程与覆盖率配置
凌晨三点,我盯着终端里那行 Test Suites: 1 failed, 47 passed 发呆。改一行代码,等 28 秒测试跑完。再改一行,又是 28 秒。我的咖啡早就凉透了。
这大概是我去年从 Jest 迁移到 Vitest 的真实写照。
那时候项目里有将近 500 个测试用例,每次 npm test 都够我刷两页 Hacker News。后来换到 Vitest,同样的测试跑完只要 3 秒出头。说实话,那一刻我差点哭出来。
所以今天想聊聊两件事:怎么用 Vitest 跑出这套快速测试体验,以及更有意思的——怎么用 TDD(测试驱动开发)的方式,让写测试这件事变得没那么痛苦。我会用一个完整的价格格式化函数做例子,带你走一遍 Red-Green-Refactor 循环,然后聊聊覆盖率配置、Mock 技巧,还有 Vitest UI 这个调试神器。
嗯,准备好了吗?
为什么选择 Vitest + TDD
先说个数据。
SitePoint 在 2026 年做过一次对比:5 万个测试用例,Vitest 跑完 3 秒,Jest 呢?28 到 34 秒。这不是差一点点,是差了一个数量级。
速度只是其中一个原因。如果你用过 Jest 处理 ESM 模块,大概也踩过那个坑——得装 babel,得配 transformers,还得祈祷 jest.config.js 里的各种魔法字符串能生效。Vitest 不一样,它原生支持 ESM,不需要任何转译配置。你的代码怎么写的,测试就怎么跑,简单明了。
还有一点可能更适合 Vite 用户:Vitest 直接复用 Vite 的配置。你在 vite.config.ts 里配好的别名、环境变量、插件,测试环境自动继承。不需要像 Jest 那样再写一份 moduleNameMapper。我第一次发现这个的时候,整个人愣了好几秒——原来测试配置可以这么省心?
说完工具,聊聊方法。TDD 这玩意儿很多人听过,真正坚持下来的不多。它的核心是个叫 Red-Green-Refactor 的循环:先写一个失败的测试(红),再写刚好能通过的代码(绿),最后重构优化(重构)。听起来有点反直觉,对吧?先写测试再写代码?
但这里有个好处:你写的每一行代码,都是为了让测试通过。没有多余的逻辑,没有”以防万一”的代码。而且因为测试先行,你被迫先想清楚这个函数该干什么、返回什么、边界在哪。这种约束反而让设计变得更清晰。
Vitest 的 watch 模式让这个循环变得特别顺手。文件一保存,测试秒跑,结果直接显示在终端里。你不用切换窗口,不用手动执行命令,就像有个副驾驶一直在帮你检查——“诶,这里改错了,测试挂了”、“好了,现在全绿了”。这种即时反馈,会让你不知不觉进入一种心流状态。
TDD 实战:从零开发一个函数
光说不练假把式。我们来用 TDD 的方式开发一个 formatPrice() 函数,把数字格式化成货币显示。比如把 1234.5 变成 ¥1,234.50。
Red 阶段:先写一个失败的测试
打开你的项目,新建一个 formatPrice.test.ts:
// formatPrice.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from './formatPrice'
describe('formatPrice', () => {
it('应该将数字格式化为人民币货币格式', () => {
expect(formatPrice(1234.5)).toBe('¥1,234.50')
})
})
这时候运行 npx vitest,终端会给你一个大大的红色报错:Cannot find module './formatPrice'。因为函数还没写呢。
这就对了。这就是 Red 阶段——测试失败,说明你定义了一个还没实现的需求。很多人觉得先写测试很奇怪,但你想想,如果先写代码再写测试,你怎么知道测试真的在检验你想检验的东西?
Green 阶段:写最少的代码通过测试
现在创建 formatPrice.ts,写刚好能让测试通过的代码:
// formatPrice.ts
export function formatPrice(value: number): string {
return '¥1,234.50' // 先硬编码返回值
}
再次运行测试。绿了!
等等,你可能会说:“这不就是作弊吗?” 其实不是。TDD 强调的是写”刚好”能通过测试的代码,不多也不少。硬编码也好,最简逻辑也罢,只要测试通过,你就有了一个可验证的基础。接下来再加新测试,再改代码,一步一步来。
再加一个测试用例:
it('应该正确处理不同的数值', () => {
expect(formatPrice(0)).toBe('¥0.00')
expect(formatPrice(99.99)).toBe('¥99.99')
})
测试又红了。这时候你没法再硬编码,得写真正的逻辑了:
export function formatPrice(value: number): string {
return `¥${value.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
}
跑测试,全绿。这个正则表达式有点丑,但现在能跑就行。
Refactor 阶段:优化代码结构
测试通过了,但代码还能更清晰。这时候可以放心重构,因为测试会帮你守着——改错了立刻就报红。
// 重构后的版本
export function formatPrice(value: number): string {
// 使用 Intl.NumberFormat 更健壮
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
}).format(value)
}
跑一下测试,还是全绿。重构完成。
你看,这就是一轮完整的 Red-Green-Refactor。从失败开始,写最简代码,再优化结构。整个过程你都有测试兜底,不怕改坏。而且因为每一步都很小,你的大脑负担也很轻——不需要一下子想清楚所有边界情况,测试会提醒你。
在实际项目中,我通常会在 watch 模式下跑这个循环。保存文件 → 测试自动运行 → 看结果 → 改代码 → 保存 → 再跑。整个过程几秒钟,根本不用离开编辑器。那种”改点东西立刻知道对不对”的感觉,真的很爽。
覆盖率配置与 CI 集成
写了测试,总得知道测了多少。覆盖率报告就是干这个的。
基础配置
在 vitest.config.ts 里加上覆盖率配置:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8', // 或 'istanbul',v8 默认更快
reporter: ['text', 'html', 'json-summary'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/types/**'],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
})
provider 有两个选择:v8 和 istanbul。v8 用的是 V8 引擎的原生覆盖率 API,速度更快;istanbul 是老牌方案,兼容性更好。如果你的项目是纯 Vite/Node 环境,v8 就够了。
reporter 指定输出格式:text 打印到终端,html 生成可视化报告,json-summary 给 CI 工具读取。
阈值设定
thresholds 这块需要解释一下。它有四个维度:
- statements:语句覆盖率,多少代码行被执行过
- branches:分支覆盖率,if/else 的每个分支有没有都测到
- functions:函数覆盖率,多少函数被调用过
- lines:行覆盖率,和 statements 类似但计算方式略有不同
我一般把 thresholds 设在 75%-85% 之间。太低没意义,太高会让团队疲于奔命——有些代码(比如边界检查、错误处理)确实很难测到 100%。
GitHub Actions 集成
覆盖率最有价值的地方,是能在 CI 里自动阻断不达标的 PR。在 .github/workflows/test.yml 里加上:
- name: Run tests with coverage
run: npm run test -- --coverage
- name: Check coverage threshold
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage $COVERAGE% is below threshold 80%"
exit 1
fi
这样如果覆盖率低于 80%,PR 就没法合并。团队成员在提交前就得确保测试写够了。
报告解读
运行 npx vitest --coverage 后,终端会输出类似这样的结果:
% Stmts % Branch % Funcs % Lines Uncovered Line
----------|----------|----------|----------|----------------
82.45 | 76.32 | 85.71 | 82.45 | 23-25, 67
Uncovered Line 列出了没被测到的代码行。打开 coverage/index.html 可以看到更详细的可视化报告——绿的是测过的,红的是没测的。
说实话,刚开始追覆盖率的时候,我也有点强迫症,恨不得把每行都测满。后来发现没必要。80% 的覆盖率通常已经覆盖了核心逻辑和主要分支。剩下的 20% 往往是极端边界情况,硬测反而浪费时间。
Mock 三剑客:vi.fn、vi.spy、vi.mock
测试里最头疼的,大概就是处理外部依赖——API 请求、定时器、第三方库。Vitest 提供了三种 Mock 方式,各有各的用途。
vi.fn():创建假函数
当你需要一个”假的”函数,不在乎它原本是什么,只关心它被怎么调用,就用 vi.fn()。
test('回调函数应该被调用一次', () => {
const callback = vi.fn()
callMeMaybe(callback)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('hello')
})
这里 callback 是一个全新的函数,它记录自己被调用的次数、参数、返回值。你可以用 mockReturnValue 指定返回值,用 mockImplementation 指定行为。
vi.spy():监听真函数
有时候你不想替换函数,只想看看它有没有被调用、被传了什么参数。这时候用 vi.spyOn。
test('应该调用 console.log', () => {
const logSpy = vi.spyOn(console, 'log')
greet('World')
expect(logSpy).toHaveBeenCalledWith('Hello, World!')
logSpy.mockRestore() // 别忘了恢复
})
spy 会保留原函数的行为,只是顺带监听。用完记得 mockRestore(),不然会影响其他测试。
vi.mock():替换整个模块
当你需要模拟 API 响应、替换第三方库,就得用 vi.mock。它会把整个模块替换掉。
// 模拟 axios
vi.mock('axios', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: { name: 'test' } }))
}
}))
test('getUser 应该返回用户数据', async () => {
const user = await getUser(1)
expect(user.name).toBe('test')
expect(axios.get).toHaveBeenCalledWith('/users/1')
})
vi.mock 有个坑:它会被提升到文件顶部,不管你写在函数里还是 if 语句里。所以 mock 的内容不能依赖其他变量。
选择哪一种?
简单说:
- 只需要假函数?用
vi.fn() - 想监听真函数?用
vi.spy() - 要替换整个模块?用
vi.mock()
我之前老是搞混这三者,后来发现一个记忆方法:fn 是”造假的”,spy 是”偷看的”,mock 是”全换掉的”。好像还挺顺口的。
清理很重要
测试之间互不影响,是测试可靠性的基础。每条测试结束后,记得清理 mock:
afterEach(() => {
vi.restoreAllMocks()
})
或者在 vitest 配置里全局开启:
test: {
restoreMocks: true
}
Vitest UI 与调试技巧
命令行看测试结果够用,但如果你想要更直观的体验,可以试试 Vitest UI。
启动可视化界面
npx vitest --ui
浏览器会自动打开一个界面,左边是测试列表,右边是详情。点击任意测试,能看到完整的输出、错误堆栈、执行时间。还有个覆盖率按钮,点开就是刚才说的 HTML 报告。
这玩意儿最适合调试。测试挂了,不用在终端里翻日志,直接在 UI 里看错误信息,旁边就是代码,改完保存,界面自动刷新。
watch 模式:只跑受影响的测试
平常开发时,我会用 npx vitest 跑 watch 模式。它的增量测试很聪明——你改了 utils/formatPrice.ts,它只会跑和这个文件相关的测试,不会全量重跑。
测试多了以后,这个差异重跑能省下大量时间。我有个项目 800 多个测试,全量跑 4 秒,增量跑通常只要几百毫秒。
调试技巧
测试挂了怎么办?几个常用招数:
只跑单个测试:在 it 后面加 .only
it.only('这个测试有问题,单独跑', () => {
// ...
})
跳过测试:加 .skip
it.skip('先跳过这个', () => {
// ...
})
更新快照:改了组件结构,快照测试挂了
npx vitest -u # -u 是 --update 的缩写
console.log 调试:是的,老办法最管用。Vitest 会把 console 输出完整显示在测试结果里。
常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
Cannot find module | 路径别名没配好 | 检查 vitest.config 的 alias |
vi.mock is not a function | 导入方式错误 | 用 import { vi } from 'vitest' |
| 测试时区不对 | 默认是 UTC | 在 setup 里设 process.env.TZ = 'Asia/Shanghai' |
这些坑我都踩过。尤其是时区那个,CI 里本地时间测试全挂,排查了大半天才发现是时区问题。
Vitest TDD 实战流程
从零开始用 TDD 方式开发一个函数,配置覆盖率报告
⏱️ 预计耗时: 30 分钟
- 1
步骤1: 安装 Vitest
在 Vite 项目中添加 Vitest:
npm add -D vitest
无需额外配置,Vitest 自动复用 Vite 配置 - 2
步骤2: Red 阶段:写失败测试
创建测试文件,写一个必然失败的测试:
import { describe, it, expect } from 'vitest'
import { formatPrice } from './formatPrice'
describe('formatPrice', () => {
it('应该格式化货币', () => {
expect(formatPrice(1234.5)).toBe('¥1,234.50')
})
})
运行 npx vitest,确认测试失败(红色) - 3
步骤3: Green 阶段:写最小代码
创建实现文件,写刚好能通过测试的代码:
export function formatPrice(value: number): string {
return '¥1,234.50' // 先硬编码
}
运行测试,确认通过(绿色) - 4
步骤4: Refactor 阶段:优化代码
用 Intl.NumberFormat 重构:
export function formatPrice(value: number): string {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(value)
}
确认测试仍然通过 - 5
步骤5: 配置覆盖率
在 vitest.config.ts 中添加:
test: {
coverage: {
provider: 'v8',
thresholds: { statements: 80, branches: 75 }
}
}
运行 npx vitest --coverage 查看报告
结论
说了这么多,其实就一件事:Vitest + TDD 能让写测试这件事变得没那么痛苦。
速度上,从 Jest 的几十秒降到几秒,这种改变不是数字上的,而是体验上的——你不用在改代码和等测试之间反复切换,不用忍受那种”改一行等半天”的折磨。ESM 原生支持、Vite 配置复用,这些都是实打实的省心。
TDD 的 Red-Green-Refactor 循环,听起来有点反直觉,但试过几次你就会发现它的好处:每一步都很小,每一步都有验证,大脑负担很轻。你不用一下子设计出完美方案,测试会帮你发现问题,迭代优化。
覆盖率配置和 Mock 技术是工具层面的东西,掌握了能帮你写出更健壮的测试。但更重要的是养成写测试的习惯——不是为了追数字,而是为了代码更有信心。
如果你的项目已经在用 Vite,从 Jest 迁移到 Vitest 其实成本很低。加一行 npm add -D vitest,把 Jest 的测试语法改成 Vitest 的(几乎一样),就能跑起来了。如果还在犹豫,可以先在一个小模块试试,体验一下 watch 模式的即时反馈。
现在就用 Vitest 把你的第一个测试跑起来,试试 TDD 的循环。感受一下那种”改完立刻知道对不对”的踏实感。说不定你也会像我一样,从此爱上写测试。
常见问题
Vitest 和 Jest 的主要区别是什么?
TDD 的 Red-Green-Refactor 循环怎么做?
• Red:先写一个失败的测试,定义需求
• Green:写最小代码让测试通过,可以硬编码
• Refactor:在测试保护下优化代码结构
每一步都很小,大脑负担轻,测试全程兜底。
覆盖率阈值设多少合适?
vi.fn、vi.spy、vi.mock 怎么选?
• vi.fn():创建全新假函数,记录调用次数和参数
• vi.spy():监听现有函数,保留原行为,用完需 mockRestore()
• vi.mock():替换整个模块,用于模拟 API 或第三方库
记忆口诀:fn 是造假的,spy 是偷看的,mock 是全换掉的。
Vitest watch 模式有什么优势?
测试时区问题怎么解决?
12 分钟阅读 · 发布于: 2026年4月29日 · 修改于: 2026年4月29日
相关文章
GitHub Actions Matrix 矩阵构建:多平台多版本并行测试实战指南
GitHub Actions Matrix 矩阵构建:多平台多版本并行测试实战指南
Nginx 负载均衡实战:upstream 配置与健康检查
Nginx 负载均衡实战:upstream 配置与健康检查
Supabase Realtime 实战:WebSocket 连接管理与断线重连策略

评论
使用 GitHub 账号登录后即可评论