切换语言
切换主题

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

先说个数据。

5万测试
Vitest 3秒 vs Jest 28-34秒

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 有两个选择:v8istanbul。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

    步骤1: 安装 Vitest

    在 Vite 项目中添加 Vitest:

    npm add -D vitest

    无需额外配置,Vitest 自动复用 Vite 配置
  2. 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

    步骤3: Green 阶段:写最小代码

    创建实现文件,写刚好能通过测试的代码:

    export function formatPrice(value: number): string {
    return '¥1,234.50' // 先硬编码
    }

    运行测试,确认通过(绿色)
  4. 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

    步骤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 的主要区别是什么?
Vitest 专为 Vite 设计,原生支持 ESM,速度比 Jest 快 5-10 倍。Vitest 自动复用 Vite 配置(别名、环境变量),无需像 Jest 那样额外配置 moduleNameMapper。
TDD 的 Red-Green-Refactor 循环怎么做?
三步循环:

• Red:先写一个失败的测试,定义需求
• Green:写最小代码让测试通过,可以硬编码
• Refactor:在测试保护下优化代码结构

每一步都很小,大脑负担轻,测试全程兜底。
覆盖率阈值设多少合适?
推荐 75%-85%。太低没意义,太高会疲于奔命。80% 通常已覆盖核心逻辑,剩下的 20% 往往是极端边界情况,硬测反而浪费时间。
vi.fn、vi.spy、vi.mock 怎么选?
按场景选择:

• vi.fn():创建全新假函数,记录调用次数和参数
• vi.spy():监听现有函数,保留原行为,用完需 mockRestore()
• vi.mock():替换整个模块,用于模拟 API 或第三方库

记忆口诀:fn 是造假的,spy 是偷看的,mock 是全换掉的。
Vitest watch 模式有什么优势?
增量测试只跑受影响的文件,800 个测试全量跑 4 秒,增量跑只需几百毫秒。文件保存后测试秒跑,不用切换窗口,进入心流状态。
测试时区问题怎么解决?
Vitest 默认用 UTC 时区。在 vitest.config.ts 的 setupFiles 里设置 process.env.TZ = 'Asia/Shanghai',或者在测试文件开头手动设置。

12 分钟阅读 · 发布于: 2026年4月29日 · 修改于: 2026年4月29日

评论

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