引言
测试是前端工程化的最后一公里。Vitest 由 Vite 团队打造,直接复用 Vite 的配置、转换与模块图——无需另起炉灶就能为你的 Vite 项目配好单元测试、组件测试与 E2E 测试。本文按「单测 → 组件测 → E2E」三层递进:先讲 Vitest 的核心能力(断言、参数化、快照、Mock),再讲 Vue/React 组件测试的挂载与交互,最后用 Playwright 打通浏览器端到端链路,并给出覆盖率与 CI 集成方案。
前置:/vite-scaffold-engineering/(项目工程化)、/vite-config-guide/(Vite 配置)。测试理论见 [[testing]]。
目录
- 1. 为什么是 Vitest:与 Vite 同源的优势
- 2. 快速上手:第一个单元测试
- 3. 断言、参数化与快照测试
- 4. Mock:模拟模块、函数与定时器
- 5. 组件测试:Vue 与 React 的挂载交互
- 6. E2E 测试:Playwright 浏览器链路
- 7. 覆盖率统计与阈值门禁
- 8. CI 集成:GitHub Actions 与缓存加速
- 9. 常见问题与最佳实践
- 10. 速查表
- 延伸阅读
1. 为什么是 Vitest:与 Vite 同源的优势
Vitest 直接复用 Vite 的转换管线——同一个 vite.config.ts 即可驱动开发与测试,无需 Jest 的 babel 转译配置:
传统 Jest: babel/jest 配置 → 双份转换 → 类型/别名/环境要同步维护
Vitest: vite.config.ts 一套配置 → esbuild 转换 → 开箱即用
| 特性 | Jest | Vitest |
|---|---|---|
| 配置 | jest.config + babel | 复用 vite.config |
| 转换 | Babel 生态 | esbuild(快 10-100×) |
| TS 支持 | 需 ts-jest | 原生 |
| ESM | 支持差 | 原生 |
| 别名 | 需映射 | 复用 resolve.alias |
| 组件测试 | 需额外配置 | @vue/test-utils / Testing Library 直挂 |
心智:Vite 项目选 Vitest 不是「二选一」,而是「同源免费」——配置、别名、转换全继承。
2. 快速上手:第一个单元测试
安装:
npm i -D vitest
# 组件测试可选
npm i -D @vue/test-utils jsdom # Vue
npm i -D @testing-library/react # React
测试目标(一个纯函数):
// src/utils/format.ts
export function formatPrice(n: number, currency = '¥'): string {
return `${currency}${n.toFixed(2)}`
}
测试文件:
// src/utils/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from './format'
describe('formatPrice', () => {
it('保留两位小数', () => {
expect(formatPrice(3.14159)).toBe('¥3.14')
})
it('支持自定义货币符号', () => {
expect(formatPrice(10, '$')).toBe('$10.00')
})
it('处理负数', () => {
expect(formatPrice(-5.5)).toBe('¥-5.50')
})
})
运行:
vitest # watch 模式
vitest run # 一次性
vitest run --coverage # 带覆盖率
package.json 脚本:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
记忆:Vitest 文件按
*.test.ts/*.spec.ts约定自动发现——测试放 src 旁(就近)或 test/ 目录皆可。
3. 断言、参数化与快照测试
常用断言(vitest 内置 chai 风格 + jest 兼容):
expect(sum(1, 2)).toBe(3) // 严格相等(Object.is)
expect(arr).toEqual([1, 2, 3]) // 深比较
expect(obj).toMatchObject({ a: 1 }) // 部分匹配
expect(n).toBeGreaterThan(0)
expect('abc').toMatch(/b/)
expect(fn).toHaveBeenCalledWith(1)
expect(() => risky()).toThrow('bad')
参数化测试(同一逻辑多组数据):
import { describe, it, expect } from 'vitest'
const cases = [
[2, 2, 4],
[3, 5, 8],
[-1, 1, 0],
] as const
describe.each(cases)('add(%i, %i)', (a, b, expected) => {
it(`返回 ${expected}`, () => {
expect(add(a, b)).toBe(expected)
})
})
快照测试(锁定大型对象/渲染输出):
it('组件快照', () => {
const wrapper = mount(MyComponent, { props: { name: 'Leeting' } })
expect(wrapper.html()).toMatchSnapshot()
})
// 更新快照:vitest -u
| 断言类型 | 场景 |
|---|---|
| toBe / toEqual | 基础值/对象深比较 |
| toMatch | 字符串正则 |
| toThrow | 异常路径 |
| toHaveBeenCalled | Mock 调用断言 |
| toMatchSnapshot | UI/序列化结果锁定 |
记忆:参数化消灭重复用例,快照防止意外回归——但快照别滥用,大而频繁变更的快照价值低。
4. Mock:模拟模块、函数与定时器
模拟模块(避免真实网络/副作用):
// src/services/api.ts
export async function fetchUser(id: number) { /* 真实请求 */ }
// 测试
import { vi } from 'vitest'
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}))
import { fetchUser } from './api'
const user = await fetchUser(1)
expect(fetchUser).toHaveBeenCalledWith(1)
expect(user.name).toBe('Alice')
局部 mock(spy):
import { getUser } from './user'
const spy = vi.spyOn(console, 'log') // 监听原生
getUser(1)
expect(spy).toHaveBeenCalled()
spy.mockRestore() // 还原
定时器 mock:
vi.useFakeTimers()
it('debounce 延迟触发', () => {
const fn = vi.fn()
debounce(fn, 300)()
vi.advanceTimersByTime(300)
expect(fn).toHaveBeenCalledTimes(1)
})
全局 mock 目录:src/__mocks__/ 或 testSetup.ts 里统一 vi.mock('@/utils/storage')。
| Mock 方式 | 用法 | 场景 |
|---|---|---|
| vi.mock(模块) | 整体替换 | 网络/环境依赖 |
| vi.fn() | 造函数 | 回调/事件 |
| vi.spyOn(obj, ’m') | 包裹原方法 | 监听/断言 |
| vi.useFakeTimers | 假定时器 | 防抖/延时 |
铁律:Mock 只该隔离「外部副作用」,不该掩盖被测逻辑本身——mock 过度会让测试失去意义。
5. 组件测试:Vue 与 React 的挂载交互
Vue 组件测试(@vue/test-utils + jsdom 环境):
// vitest.config 或 vite.config 指定环境
// export default defineConfig({ test: { environment: 'jsdom' } })
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
it('点击累加', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
React 组件测试(@testing-library/react):
import { render, screen, fireEvent } from '@testing-library/react'
import Counter from './Counter'
it('点击累加', () => {
render(<Counter />)
fireEvent.click(screen.getByRole('button', { name: '+' }))
expect(screen.getByText('count: 1')).toBeInTheDocument()
})
组件测试要点:
| 要点 | 说明 |
|---|---|
| jsdom 环境 | test.environment: 'jsdom' |
| 异步更新 | await flush(nextTick / findBy) |
| 选择器 | 优先 role/text(用户视角) |
| 触发事件 | fireEvent / userEvent |
| 挂载清理 | Testing Library 自动清理 |
心智:组件测试测「行为」不测「实现细节」——按用户能看到/交互到的角度断言,别锁死内部 DOM 结构。
6. E2E 测试:Playwright 浏览器链路
单元/组件测试在 Node 里模拟 DOM,E2E 在真实浏览器跑完整链路(登录 → 操作 → 跳转 → 断言):
npm i -D @playwright/test
npx playwright install chromium
配置:
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:5173', // Vite dev server
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev', // 自动起 dev server
port: 5173,
reuseExistingServer: true,
},
})
测试用例:
// e2e/cart.spec.ts
import { test, expect } from '@playwright/test'
test('加入购物车全链路', async ({ page }) => {
await page.goto('/products/1')
await page.getByRole('button', { name: '加入购物车' }).click()
await expect(page.getByText('购物车(1)')).toBeVisible()
await page.getByRole('link', { name: '结算' }).click()
await expect(page).toHaveURL(/\/checkout/)
})
E2E 分层建议:
| 层级 | 覆盖 | 工具 |
|---|---|---|
| 单元 | 函数/逻辑 | Vitest |
| 组件 | 组件行为 | @vue/test-utils |
| E2E | 关键用户旅程 | Playwright |
记忆:测试金字塔——单测多、组件中、E2E 精——E2E 慢且脆,只覆盖核心流程,别全站脚本化。
7. 覆盖率统计与阈值门禁
开启覆盖率:
npm i -D @vitest/coverage-v8
vitest run --coverage
配置阈值(低于则失败,CI 门禁):
// vite.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,vue,tsx}'],
exclude: ['src/**/*.test.*', 'src/main.ts'],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
},
})
覆盖率报告解读:
| 指标 | 含义 | 关注点 |
|---|---|---|
| Lines | 代码行执行比例 | 基础覆盖 |
| Functions | 函数调用比例 | 逻辑分支覆盖 |
| Branches | if/switch 分支 | 边界条件 |
| Statements | 语句执行比例 | 与 lines 近似 |
记忆:覆盖率是「兜底」不是「目标」——先保证关键路径测到位,再堆数值;阈值设 80/70 防劣化即可。
8. CI 集成:GitHub Actions 与缓存加速
GitHub Actions 示例:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test # vitest run
- run: pnpm test:e2e # playwright
加速技巧:
| 手段 | 效果 |
|---|---|
| pnpm cache | 依赖命中秒装 |
vitest --reporter=dot | 缩短日志 |
| 分片 | vitest run --shard=1/3 并行 Job |
| Playwright 只装需用浏览器 | --with-deps chromium |
| 跳过 E2E(仅核心 commit) | path 过滤 |
记忆:CI 的价值在「每次提交都跑」——把单测设为必过门禁、E2E 设为合并前必跑,守住主干质量。
9. 常见问题与最佳实践
常见坑:
| 问题 | 解法 |
|---|---|
| 别名解析失败 | 用 vite resolve.alias 统一,测试自动继承 |
| ESM 依赖报错 | test.server.deps.inline 或该依赖预构建 |
| jsdom 缺 API | 加 test.environmentOptions / 用 happy-dom |
| 异步组件不更新 | await wrapper.vm.$nextTick() |
| 快照频繁变化 | 减小快照粒度或改用断言 |
| CI 内存不足 | 限制 worker:--pool=forks --poolOptions.forks.maxForks=2 |
最佳实践:
1. 测试贴近源码(.test.ts 同目录),便于迁移与发现
2. 纯逻辑优先测(utils/hooks/状态机)
3. 组件测用户行为,E2E 测关键旅程
4. 覆盖率阈值防劣化,但不追求 100%
5. 依赖用真实行为 mock,避免过度 mock
一句话记忆:Vitest 与 Vite 同源零配置,单元测逻辑、组件测行为、Playwright 测旅程;Mock 只隔离副作用,覆盖率做门禁——测试金字塔守住工程质量。
10. 速查表
| 需求 | 做法 |
|---|---|
| 单元测试 | Vitest + vitest run |
| 组件测试 | @vue/test-utils / Testing Library + jsdom |
| E2E 测试 | Playwright + webServer 起 dev server |
| Mock 模块 | vi.mock('./mod', () => ...) |
| 假定时器 | vi.useFakeTimers() |
| 覆盖率 | @vitest/coverage-v8 + thresholds |
| 快照 | toMatchSnapshot() + vitest -u |
| CI | GitHub Actions + pnpm cache + shard |
| 异步断言 | await / findBy* / nextTick |
延伸阅读
- /vite-scaffold-engineering/ — 工程化起步与目录规范
- /vite-config-guide/ — test 相关配置与别名解析
- /vite-ci-cd-optimization/ — CI 构建优化与缓存
- [[testing]] — 测试方法论与测试金字塔
- [[nodejs]] — Node 工具链与脚本
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。