主题
单元测试
单元测试(Unit Testing)是软件测试中最基础、最重要的一环。它针对程序中最小可测试单元——函数、类、模块——进行独立验证,确保每一个"零件"在隔离环境下都能正确工作。
前端工程化发展到今天,单元测试早已不是"锦上添花"的可选项,而是高质量项目的基础设施。本文将从底层原理到实战编写,从 Vitest 深度使用到面试高频题,全面拆解前端单元测试。
一、单元测试基础
1.1 什么是单元测试
单元测试是对软件中最小可测试单元进行隔离测试的过程。在前端语境下,"单元"通常指:
- 纯函数:工具函数、数据处理函数、格式化函数
- 类 / 对象方法:封装了特定逻辑的类实例方法
- 模块:一个独立导出接口的文件
- 自定义 Hook(React)/ Composable(Vue)
关键词是隔离。单元测试不关心数据库是否连通、API 是否可用、DOM 是否渲染正确——它只关心被测单元在给定输入下是否产生预期输出。
单元测试的本质:
┌─────────┐ ┌──────────────────┐ ┌──────────┐
│ Input │──────→ │ 被测单元(SUT) │──────→ │ Output │
│ (输入) │ │ System Under │ │ (输出) │
│ │ │ Test │ │ │
└─────────┘ └──────────────────┘ └──────────┘
↑
隔离外部依赖(Mock)1.2 为什么需要单元测试
很多开发者觉得"写测试浪费时间",实际上单元测试是一项高 ROI 投资:
保证代码质量
单元测试是最快发现 Bug 的手段。一个函数写完,立刻写测试验证边界条件和异常情况,问题在萌芽阶段就被消灭。
重构信心
没有测试的代码就是"遗产代码"(Legacy Code)。有了单元测试,你可以大胆重构——只要测试全部通过,功能就没有被破坏。
活文档作用
良好的测试用例本身就是最好的文档。通过阅读测试代码,新人可以快速理解函数的输入输出契约、边界行为和异常处理方式。
快速反馈循环
单元测试通常在毫秒级别执行完毕,配合 watch 模式,改一行代码立即知道结果。相比手动在浏览器中点击验证,效率提升数十倍。
降低集成成本
如果每个单元都经过充分测试,那么集成时出现问题的概率大大降低,定位问题的速度也会大幅提升。
1.3 测试金字塔
测试金字塔(Test Pyramid)是 Mike Cohn 在《Succeeding with Agile》中提出的经典模型,描述了不同层次测试之间的数量和成本关系:
╱╲
╱ ╲
╱ ╲
╱ E2E ╲ 少量 · 慢速 · 高成本
╱ Tests ╲ 验证完整用户流程
╱──────────╲
╱ ╲
╱ Integration ╲ 适量 · 中速 · 中成本
╱ Tests ╲ 验证模块间协作
╱──────────────────╲
╱ ╲
╱ Unit Tests ╲ 大量 · 快速 · 低成本
╱ (单元测试) ╲ 验证单个函数/模块
╱────────────────────────╲
╱ ╲
╱──────────────────────────────╲
←───────── 数量从多到少 ──────────→
←───────── 速度从快到慢 ──────────→
←───────── 成本从低到高 ──────────→各层测试的定位:
| 测试层次 | 测试目标 | 执行速度 | 维护成本 | 代表工具 |
|---|---|---|---|---|
| 单元测试 | 单个函数/类/模块 | 毫秒级 | 低 | Vitest, Jest |
| 集成测试 | 模块间协作 | 秒级 | 中 | Testing Library, Supertest |
| E2E 测试 | 完整用户流程 | 分钟级 | 高 | Playwright, Cypress |
黄金比例:业界推荐的比例约为 70% 单元测试 : 20% 集成测试 : 10% E2E 测试。单元测试是整座金字塔的基石——数量最多、速度最快、成本最低。
1.4 测试相关核心概念
在深入工具之前,先理解几个核心概念:
SUT(System Under Test)
被测系统,在单元测试中通常就是被测的那个函数或类。
Test Double(测试替身)
用来替代真实依赖的假对象,统称 Test Double,具体包括:
Test Double 家族:
┌─────────────────────────────────────────────────────┐
│ Test Double │
│ (测试替身) │
├──────────┬──────────┬──────────┬──────────┬─────────┤
│ Dummy │ Stub │ Spy │ Mock │ Fake │
│ 哑对象 │ 桩 │ 间谍 │ 模拟 │ 假实现 │
│ │ │ │ │ │
│ 仅占位 │ 返回固定 │ 记录调用 │ 预设期望 │ 简化的 │
│ 不使用 │ 值 │ 信息 │ 并验证 │ 真实实现 │
└──────────┴──────────┴──────────┴──────────┴─────────┘- Dummy:仅用于填充参数,不会被实际使用
- Stub:返回预设的固定值,不关心如何被调用
- Spy:包装真实函数,记录调用次数、参数等信息,同时保留原始行为
- Mock:预先设定期望行为和返回值,测试结束时验证是否被正确调用
- Fake:一个简化版的真实实现(如用内存数据库替代真实数据库)
测试覆盖率(Code Coverage)
衡量测试覆盖代码的程度,常见指标:
| 指标 | 含义 | 说明 |
|---|---|---|
| Line Coverage | 行覆盖率 | 被执行到的代码行比例 |
| Branch Coverage | 分支覆盖率 | if/else 等分支被覆盖的比例 |
| Function Coverage | 函数覆盖率 | 被调用到的函数比例 |
| Statement Coverage | 语句覆盖率 | 被执行到的语句比例 |
注意:覆盖率 100% 不等于零 Bug。覆盖率只能说明代码被执行到了,不能保证断言覆盖了所有场景。追求合理覆盖率(如核心逻辑 > 90%)比追求 100% 更务实。
二、Vitest
2.1 为什么选 Vitest
Vitest 是由 Vite 团队打造的下一代测试框架,从诞生之初就为现代前端项目而设计。
Vitest 核心优势:
┌──────────────────────────────────────────────────┐
│ Vitest │
├──────────────┬───────────────────────────────────┤
│ 基于 Vite │ 复用 Vite 的配置、插件、转换管道 │
│ │ 无需重复配置 TypeScript/JSX/CSS │
├──────────────┼───────────────────────────────────┤
│ 兼容 Jest │ 几乎 100% 兼容 Jest API │
│ │ 迁移成本极低 │
├──────────────┼───────────────────────────────────┤
│ 原生 ESM │ 原生支持 ES Modules │
│ │ 不需要 babel-jest 转换 │
├──────────────┼───────────────────────────────────┤
│ 极快速度 │ 基于 esbuild 转换 │
│ │ 多线程执行 (Worker Threads) │
├──────────────┼───────────────────────────────────┤
│ 开箱即用 │ TypeScript / JSX / CSS Modules │
│ │ 无需额外 Loader 配置 │
├──────────────┼───────────────────────────────────┤
│ 智能Watch │ 只重新运行受影响的测试文件 │
│ │ 基于 Vite 的模块依赖图 │
└──────────────┴───────────────────────────────────┘核心优势总结:
- 零配置集成 Vite 项目:与 Vite 共享配置文件(
vite.config.ts),别名、插件、transform 全部复用 - 兼容 Jest API:
describe、it、expect、vi.fn()等 API 几乎一一对应,迁移只需改 import - 原生 ESM:不再需要
babel-jest做 CJS 转换,直接支持import/export - 极致速度:底层使用 esbuild 做代码转换,Worker Threads 并行执行测试文件
- 智能 Watch 模式:基于 Vite 的模块依赖图,只重跑受变更影响的测试
- 内置 UI 面板:
vitest --ui提供一个可视化的测试结果界面
2.2 安装与配置
基础安装
bash
npm install -D vitestvitest.config.ts 配置文件
ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'],
thresholds: {
lines: 80,
branches: 80,
functions: 80,
statements: 80,
},
},
testTimeout: 10000,
hookTimeout: 10000,
},
})与 Vite 项目共享配置
如果项目已有 vite.config.ts,Vitest 会自动读取并合并配置。也可以在 vite.config.ts 中直接添加 test 字段:
ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': '/src',
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})package.json scripts
json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}TypeScript 类型支持(globals: true 时)
json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}2.3 核心 API
describe — 测试套件
describe 用于将相关的测试用例组织在一起,形成逻辑分组:
ts
describe('MathUtils', () => {
describe('add', () => {
it('should add two positive numbers', () => {
expect(add(1, 2)).toBe(3)
})
it('should handle negative numbers', () => {
expect(add(-1, -2)).toBe(-3)
})
})
describe('multiply', () => {
it('should multiply two numbers', () => {
expect(multiply(3, 4)).toBe(12)
})
})
})describe 可以嵌套,形成树状结构,使测试输出更加清晰有层次。
it / test — 测试用例
it 和 test 完全等价,只是语义表达不同:
ts
it('should return true when value is positive', () => {
expect(isPositive(5)).toBe(true)
})
test('returns true when value is positive', () => {
expect(isPositive(5)).toBe(true)
})常用修饰符:
ts
it.only('only this test will run', () => {})
it.skip('this test will be skipped', () => {})
it.todo('implement this later')
it.each([
[1, 2, 3],
[2, 3, 5],
[10, 20, 30],
])('add(%i, %i) should return %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected)
})it.each 是参数化测试的利器——用同一段测试逻辑验证多组输入输出,大幅减少重复代码。
expect — 断言
expect 是断言的入口,返回一个包含各种匹配器的对象:
ts
const result = calculate(10, 5)
expect(result).toBe(15)
expect(result).not.toBe(10)生命周期钩子
测试生命周期执行顺序:
beforeAll ──────────────────────────────────────┐
│ │
│ beforeEach ──────────────┐ │
│ │ │ │
│ │ it('test 1', ...) │ ← 第一个测试用例 │
│ │ │ │
│ afterEach ───────────────┘ │
│ │
│ beforeEach ──────────────┐ │
│ │ │ │
│ │ it('test 2', ...) │ ← 第二个测试用例 │
│ │ │ │
│ afterEach ───────────────┘ │
│ │
afterAll ───────────────────────────────────────┘ts
describe('UserService', () => {
let db: Database
beforeAll(async () => {
db = await Database.connect()
})
afterAll(async () => {
await db.disconnect()
})
beforeEach(async () => {
await db.clear()
await db.seed()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should create a user', async () => {
const user = await UserService.create({ name: 'Alice' })
expect(user.name).toBe('Alice')
})
})beforeAll/afterAll:在整个describe块的所有测试前/后执行一次beforeEach/afterEach:在每个it前/后执行一次
2.4 匹配器(Matchers)
Vitest 提供丰富的匹配器,覆盖各种断言场景:
基础匹配器
ts
expect(2 + 2).toBe(4)
expect({ name: 'Alice' }).toEqual({ name: 'Alice' })
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect(1).toBeDefined()
expect(1).toBeTruthy()
expect(0).toBeFalsy()toBe vs toEqual 的区别非常关键:
ts
const obj1 = { a: 1 }
const obj2 = { a: 1 }
expect(obj1).toBe(obj1)
expect(obj1).not.toBe(obj2)
expect(obj1).toEqual(obj2)toBe使用Object.is进行严格引用比较toEqual进行深度递归比较,只要结构和值一致就通过
数值匹配器
ts
expect(10).toBeGreaterThan(5)
expect(10).toBeGreaterThanOrEqual(10)
expect(5).toBeLessThan(10)
expect(5).toBeLessThanOrEqual(5)
expect(0.1 + 0.2).toBeCloseTo(0.3, 5)toBeCloseTo 专门解决浮点数精度问题,第二个参数指定精度位数。
字符串匹配器
ts
expect('Hello World').toContain('World')
expect('Hello World').toMatch(/hello/i)
expect('vitest').toHaveLength(6)数组 / 集合匹配器
ts
const fruits = ['apple', 'banana', 'cherry']
expect(fruits).toContain('banana')
expect(fruits).toHaveLength(3)
expect(fruits).toEqual(expect.arrayContaining(['apple', 'cherry']))对象匹配器
ts
const user = { name: 'Alice', age: 25, role: 'admin' }
expect(user).toHaveProperty('name')
expect(user).toHaveProperty('name', 'Alice')
expect(user).toMatchObject({ name: 'Alice', role: 'admin' })
expect(user).toEqual(expect.objectContaining({ name: 'Alice' }))异常匹配器
ts
function divide(a: number, b: number) {
if (b === 0) throw new Error('Division by zero')
return a / b
}
expect(() => divide(1, 0)).toThrow()
expect(() => divide(1, 0)).toThrow('Division by zero')
expect(() => divide(1, 0)).toThrow(/zero/)
expect(() => divide(1, 0)).toThrowError(Error)函数调用匹配器
ts
const fn = vi.fn()
fn('hello', 42)
fn('world')
expect(fn).toHaveBeenCalled()
expect(fn).toHaveBeenCalledTimes(2)
expect(fn).toHaveBeenCalledWith('hello', 42)
expect(fn).toHaveBeenLastCalledWith('world')
expect(fn).toHaveBeenNthCalledWith(1, 'hello', 42)非对称匹配器
非对称匹配器可以作为 toEqual 等匹配器的参数,提供灵活的部分匹配能力:
ts
expect({ name: 'Alice', createdAt: new Date() }).toEqual({
name: 'Alice',
createdAt: expect.any(Date),
})
expect('hello world').toEqual(expect.stringContaining('world'))
expect('hello world').toEqual(expect.stringMatching(/^hello/))
expect([1, 2, 3]).toEqual(expect.arrayContaining([1, 3]))
expect({ a: 1, b: 2 }).toEqual(expect.objectContaining({ a: 1 }))2.5 Mock 系统
Mock 是单元测试的核心技术,用于隔离被测单元与外部依赖。
vi.fn() — 创建模拟函数
ts
const mockFn = vi.fn()
mockFn(1, 2)
mockFn(3, 4)
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn.mock.calls).toEqual([[1, 2], [3, 4]])
expect(mockFn.mock.results).toEqual([
{ type: 'return', value: undefined },
{ type: 'return', value: undefined },
])设置返回值
ts
const mockFn = vi.fn()
.mockReturnValue('default')
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
console.log(mockFn())
console.log(mockFn())
console.log(mockFn())设置实现
ts
const mockFn = vi.fn().mockImplementation((a: number, b: number) => a + b)
expect(mockFn(1, 2)).toBe(3)
const mockAsync = vi.fn().mockResolvedValue({ id: 1, name: 'Alice' })
const result = await mockAsync()
expect(result).toEqual({ id: 1, name: 'Alice' })vi.spyOn() — 监听对象方法
vi.spyOn 在保留原始实现的同时,记录方法的调用信息:
ts
const calculator = {
add(a: number, b: number) {
return a + b
},
}
const spy = vi.spyOn(calculator, 'add')
const result = calculator.add(1, 2)
expect(result).toBe(3)
expect(spy).toHaveBeenCalledWith(1, 2)
expect(spy).toHaveBeenCalledTimes(1)
spy.mockRestore()也可以在 spy 上覆盖实现:
ts
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
doSomething()
expect(spy).toHaveBeenCalledWith('expected message')
spy.mockRestore()vi.mock() — 模块级 Mock
ts
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
fetchPosts: vi.fn().mockResolvedValue([]),
}))
import { fetchUser } from './api'
it('should use mocked api', async () => {
const user = await fetchUser(1)
expect(user).toEqual({ id: 1, name: 'Alice' })
})部分 Mock
只 Mock 模块的部分导出,保留其余真实实现:
ts
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils')>()
return {
...actual,
formatDate: vi.fn().mockReturnValue('2024-01-01'),
}
})Mock 原理揭秘
vi.mock() 看起来放在文件中间,但实际上 Vitest 会把它提升到文件顶部(hoisting),在所有 import 语句之前执行:
vi.mock 的执行顺序:
源代码中的书写顺序: 实际执行顺序:
┌──────────────────┐ ┌──────────────────┐
│ import { foo } │ │ vi.mock('./mod') │ ← 被提升
│ from './mod' │ │ │
│ │ │ import { foo } │
│ vi.mock('./mod') │ │ from './mod' │
│ │ ─────→ │ │
│ test('...', ...) │ │ test('...', ...) │
└──────────────────┘ └──────────────────┘这就是为什么 vi.mock 可以写在 import 下面却仍然能拦截模块加载。
2.6 异步测试
async/await
最推荐的异步测试写法:
ts
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error('User not found')
return response.json()
}
it('should fetch user by id', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' }),
})
const user = await fetchUser(1)
expect(user).toEqual({ id: 1, name: 'Alice' })
})
it('should throw when user not found', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
})
await expect(fetchUser(999)).rejects.toThrow('User not found')
})返回 Promise
也可以直接返回 Promise,Vitest 会等待它 resolve:
ts
it('should resolve with data', () => {
return fetchData().then(data => {
expect(data).toBeDefined()
})
})vi.useFakeTimers — 定时器测试
定时器(setTimeout、setInterval、Date)在测试中需要特殊处理:
ts
function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): T {
let timer: ReturnType<typeof setTimeout>
return ((...args: unknown[]) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}) as T
}
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should delay function execution', () => {
const fn = vi.fn()
const debounced = debounce(fn, 300)
debounced()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(299)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(fn).toHaveBeenCalledTimes(1)
})
it('should reset timer on subsequent calls', () => {
const fn = vi.fn()
const debounced = debounce(fn, 300)
debounced()
vi.advanceTimersByTime(200)
debounced()
vi.advanceTimersByTime(200)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
})
})常用的定时器控制 API:
| API | 作用 |
|---|---|
vi.useFakeTimers() | 启用假定时器 |
vi.useRealTimers() | 恢复真实定时器 |
vi.advanceTimersByTime(ms) | 快进指定毫秒 |
vi.advanceTimersToNextTimer() | 快进到下一个定时器触发 |
vi.runAllTimers() | 执行所有待运行的定时器 |
vi.runOnlyPendingTimers() | 只执行当前待运行的定时器 |
vi.setSystemTime(date) | 设置系统时间 |
测试 Date
ts
describe('date formatting', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-06-15T10:30:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('should format current date', () => {
const result = getCurrentDateString()
expect(result).toBe('2024-06-15')
})
it('should detect if date is today', () => {
expect(isToday(new Date('2024-06-15'))).toBe(true)
expect(isToday(new Date('2024-06-14'))).toBe(false)
})
})2.7 快照测试
快照测试(Snapshot Testing)是一种特殊的断言方式:首次运行时记录输出快照,后续运行时对比当前输出与快照是否一致。
toMatchSnapshot
ts
function generateConfig(env: string) {
return {
apiUrl: env === 'production'
? 'https://api.example.com'
: 'http://localhost:3000',
debug: env !== 'production',
version: '1.0.0',
}
}
it('should generate production config', () => {
expect(generateConfig('production')).toMatchSnapshot()
})
it('should generate development config', () => {
expect(generateConfig('development')).toMatchSnapshot()
})首次运行会创建 __snapshots__/xxx.test.ts.snap 文件:
exports[`should generate production config 1`] = `
{
"apiUrl": "https://api.example.com",
"debug": false,
"version": "1.0.0",
}
`;如果输出发生变化,测试会失败。使用 vitest -u 或 vitest --update 更新快照。
toMatchInlineSnapshot
内联快照直接嵌入测试文件,不需要外部 snap 文件:
ts
it('should generate config', () => {
expect(generateConfig('production')).toMatchInlineSnapshot(`
{
"apiUrl": "https://api.example.com",
"debug": false,
"version": "1.0.0",
}
`)
})首次运行时 Vitest 会自动填充内联快照内容。
快照测试的适用场景
| 适用场景 | 不适用场景 |
|---|---|
| 配置对象输出 | 频繁变化的数据(含时间戳) |
| 序列化数据结构 | 随机值 |
| 错误消息格式 | 大型复杂对象 |
| CLI 输出内容 | 需要精确断言的场景 |
| 组件渲染结果 | 敏感数据 |
2.8 覆盖率
Vitest 内置覆盖率支持,提供两种 provider:
v8 Provider(推荐)
基于 V8 引擎的原生代码覆盖率功能,速度快、无额外开销:
bash
npm install -D @vitest/coverage-v8Istanbul Provider
传统覆盖率方案,通过代码插桩(instrumentation)实现:
bash
npm install -D @vitest/coverage-istanbul| 对比维度 | v8 | istanbul |
|---|---|---|
| 原理 | V8 引擎原生支持 | 代码插桩 |
| 速度 | 更快 | 较慢 |
| 准确性 | 部分边界场景不够精确 | 更精确 |
| 配置复杂度 | 低 | 中 |
| 推荐场景 | 绝大多数项目 | 需要精确覆盖率报告 |
配置覆盖率阈值
在 vitest.config.ts 中设置阈值,低于阈值时测试会失败:
ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
thresholds: {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
},
all: true,
include: ['src/**/*.ts'],
exclude: [
'src/**/*.test.ts',
'src/**/*.d.ts',
'src/types/**',
],
},
},
})运行覆盖率
bash
npx vitest run --coverage输出示例:
% Coverage report from v8
-----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------|---------|----------|---------|---------|
All files | 85.32 | 78.45 | 90.12 | 84.67 |
src/utils/math.ts | 100 | 100 | 100 | 100 |
src/utils/string.ts | 92.31 | 85.71 | 100 | 91.67 |
src/services/user.ts | 73.68 | 60 | 80 | 72.22 |
-----------------------|---------|----------|---------|---------|三、Jest(对比了解)
3.1 Jest 核心特性
Jest 是 Facebook 开源的测试框架,曾是前端测试的事实标准。核心特性包括:
- 零配置:对 React 项目开箱即用
- 快照测试:首创快照测试机制
- 隔离执行:每个测试文件在独立的 worker 进程中运行
- 丰富生态:大量第三方插件和集成方案
- 代码覆盖率:内置 istanbul 覆盖率支持
Jest 的 API 与 Vitest 高度一致(Vitest 就是兼容 Jest API 设计的):
ts
const { describe, it, expect, jest } = require('@jest/globals')
describe('Calculator', () => {
it('should add numbers', () => {
expect(1 + 2).toBe(3)
})
it('should mock functions', () => {
const mockFn = jest.fn().mockReturnValue(42)
expect(mockFn()).toBe(42)
expect(mockFn).toHaveBeenCalled()
})
})Jest 配置通常在 jest.config.js 或 package.json 的 jest 字段中:
js
module.exports = {
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
}3.2 Vitest vs Jest 对比
| 对比维度 | Vitest | Jest |
|---|---|---|
| 启动速度 | ⚡ 极快(esbuild 转换) | 🐢 较慢(babel-jest 转换) |
| ESM 支持 | ✅ 原生支持 | ⚠️ 实验性支持,需配置 |
| TypeScript | ✅ 开箱即用(esbuild) | ❌ 需要 ts-jest 或 @swc/jest |
| 配置复杂度 | 低(复用 Vite 配置) | 中(独立配置体系) |
| Watch 模式 | 基于模块依赖图,精准 | 基于文件修改时间 |
| 并行执行 | Worker Threads(同进程更快) | Worker 进程(隔离性更好) |
| API 兼容性 | 兼容 Jest API | — |
| 社区生态 | 快速增长 | 成熟庞大 |
| Vite 项目集成 | ⭐ 无缝集成 | 需要额外配置 |
| Vue/React 支持 | 通过 Vite 插件 | 需要 preset 配置 |
| 快照测试 | ✅ 支持 | ✅ 支持(首创) |
| UI 面板 | ✅ 内置 vitest --ui | ❌ 需要第三方工具 |
| 覆盖率 | v8 / istanbul | istanbul |
| Mock 系统 | vi.fn() / vi.mock() | jest.fn() / jest.mock() |
| 成熟度 | 较新(2022年发布) | 成熟(2016年开源) |
选型建议
项目选型决策树:
使用 Vite 构建?
│
├── 是 ──→ 选 Vitest(无缝集成,零配置)
│
└── 否
│
├── 新项目 ──→ 倾向 Vitest(更现代、更快)
│
└── 已有 Jest 测试
│
├── 测试量少 ──→ 考虑迁移到 Vitest
│
└── 测试量大 ──→ 继续 Jest,按需迁移从 Jest 迁移到 Vitest
迁移通常非常简单,核心改动:
ts
// jest.fn() → vi.fn()
// jest.mock() → vi.mock()
// jest.spyOn() → vi.spyOn()
// jest.useFakeTimers() → vi.useFakeTimers()
// jest.config.js → vitest.config.ts
// ts-jest → 不需要(Vitest 原生支持 TS)
// babel-jest → 不需要(Vitest 原生支持 ESM)四、测试编写最佳实践
4.1 AAA 模式
AAA(Arrange-Act-Assert) 是编写清晰测试用例的标准模式:
AAA 模式结构:
┌─────────────────────────────────────────┐
│ Arrange(准备) │
│ 准备测试数据、创建对象、配置 Mock │
├─────────────────────────────────────────┤
│ Act(执行) │
│ 调用被测方法,执行目标操作 │
├─────────────────────────────────────────┤
│ Assert(断言) │
│ 验证结果是否符合预期 │
└─────────────────────────────────────────┘ts
it('should calculate total price with discount', () => {
const items = [
{ name: 'Book', price: 100, quantity: 2 },
{ name: 'Pen', price: 10, quantity: 5 },
]
const discount = 0.1
const total = calculateTotal(items, discount)
expect(total).toBe(225)
})第一段是 Arrange(准备数据),中间一行是 Act(调用函数),最后是 Assert(验证结果)。三段之间用空行分隔,一目了然。
4.2 测试命名规范
良好的测试命名应该清晰表达被测行为和预期结果:
推荐格式:should [expected behavior] when [condition]
ts
it('should return empty array when input is empty string', () => {})
it('should throw TypeError when argument is not a number', () => {})
it('should sort array in ascending order when no comparator provided', () => {})
it('should retry 3 times when request fails', () => {})反面示例
ts
it('test1', () => {})
it('works', () => {})
it('handles correctly', () => {})
it('add function', () => {})好的测试命名让你在 CI 报告中一眼就能看出哪个行为出了问题。
4.3 单一职责
一个测试用例只测试一件事。如果一个测试失败了,应该能立刻知道是哪个功能出了问题。
反面示例
ts
it('should handle user operations', () => {
const user = createUser('Alice')
expect(user.name).toBe('Alice')
user.updateName('Bob')
expect(user.name).toBe('Bob')
user.setAge(25)
expect(user.age).toBe(25)
expect(() => user.setAge(-1)).toThrow()
})正面示例
ts
it('should create user with given name', () => {
const user = createUser('Alice')
expect(user.name).toBe('Alice')
})
it('should update user name', () => {
const user = createUser('Alice')
user.updateName('Bob')
expect(user.name).toBe('Bob')
})
it('should set user age', () => {
const user = createUser('Alice')
user.setAge(25)
expect(user.age).toBe(25)
})
it('should throw when age is negative', () => {
const user = createUser('Alice')
expect(() => user.setAge(-1)).toThrow()
})4.4 避免测试实现细节
测试应该关注行为(What),而不是实现(How)。测试实现细节会导致测试脆弱——重构不改变外部行为的内部实现时,测试也会失败。
反面示例(测试实现细节)
ts
it('should use Map internally for caching', () => {
const cache = new Cache()
cache.set('key', 'value')
expect(cache._store instanceof Map).toBe(true)
expect(cache._store.size).toBe(1)
})正面示例(测试行为)
ts
it('should return cached value after setting', () => {
const cache = new Cache()
cache.set('key', 'value')
expect(cache.get('key')).toBe('value')
})
it('should return undefined for non-existent key', () => {
const cache = new Cache()
expect(cache.get('missing')).toBeUndefined()
})4.5 测试边界条件和异常情况
好的测试不只测试"Happy Path",更要覆盖各种边界条件:
ts
describe('divide', () => {
it('should divide two positive numbers', () => {
expect(divide(10, 2)).toBe(5)
})
it('should handle negative numbers', () => {
expect(divide(-10, 2)).toBe(-5)
expect(divide(10, -2)).toBe(-5)
expect(divide(-10, -2)).toBe(5)
})
it('should handle zero dividend', () => {
expect(divide(0, 5)).toBe(0)
})
it('should throw when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero')
})
it('should handle very large numbers', () => {
expect(divide(Number.MAX_SAFE_INTEGER, 1)).toBe(Number.MAX_SAFE_INTEGER)
})
it('should handle decimal numbers', () => {
expect(divide(1, 3)).toBeCloseTo(0.3333, 4)
})
})常见边界条件清单:
| 数据类型 | 边界条件 |
|---|---|
| 字符串 | 空字符串、超长字符串、特殊字符、Unicode |
| 数值 | 0、负数、小数、Infinity、NaN、MAX_SAFE_INTEGER |
| 数组 | 空数组、单元素、超大数组、嵌套数组 |
| 对象 | 空对象、null、undefined、嵌套对象、循环引用 |
| 异步 | 超时、网络错误、并发调用、重复调用 |
4.6 测试可维护性:DRY 与可读性的平衡
测试代码需要在DRY(Don't Repeat Yourself) 和可读性之间找到平衡。
过度 DRY 的反面示例
ts
function runTest(input: number, expected: number) {
it(`should return ${expected} for input ${input}`, () => {
expect(process(input)).toBe(expected)
})
}
runTest(1, 2)
runTest(2, 4)
runTest(3, 6)这种写法虽然没有重复,但当测试失败时,调试体验很差。
推荐做法:使用 it.each 进行参数化
ts
it.each([
[1, 2],
[2, 4],
[3, 6],
])('should return %i when input is %i', (input, expected) => {
expect(process(input)).toBe(expected)
})推荐做法:使用 Setup 函数抽取公共逻辑
ts
describe('UserService', () => {
function createTestUser(overrides = {}) {
return {
id: 1,
name: 'Alice',
email: 'alice@test.com',
role: 'user',
...overrides,
}
}
it('should format user display name', () => {
const user = createTestUser({ name: 'Bob' })
expect(formatDisplayName(user)).toBe('Bob')
})
it('should check admin permission', () => {
const admin = createTestUser({ role: 'admin' })
expect(hasPermission(admin, 'manage')).toBe(true)
})
})这种方式既避免了重复,又保持了每个测试用例的自描述性——直接看测试代码就能理解在测什么。
五、实战示例
5.1 测试纯函数
纯函数是最容易测试的——相同输入永远返回相同输出,无副作用。
被测代码:数据处理工具函数
ts
interface Product {
id: number
name: string
price: number
category: string
inStock: boolean
}
function filterProducts(
products: Product[],
filters: {
category?: string
minPrice?: number
maxPrice?: number
inStockOnly?: boolean
}
): Product[] {
return products.filter(product => {
if (filters.category && product.category !== filters.category) {
return false
}
if (filters.minPrice !== undefined && product.price < filters.minPrice) {
return false
}
if (filters.maxPrice !== undefined && product.price > filters.maxPrice) {
return false
}
if (filters.inStockOnly && !product.inStock) {
return false
}
return true
})
}
function sortProducts(
products: Product[],
sortBy: 'price' | 'name',
order: 'asc' | 'desc' = 'asc'
): Product[] {
return [...products].sort((a, b) => {
let comparison: number
if (sortBy === 'price') {
comparison = a.price - b.price
} else {
comparison = a.name.localeCompare(b.name)
}
return order === 'desc' ? -comparison : comparison
})
}
function calculateCartTotal(
items: Array<{ product: Product; quantity: number }>,
taxRate: number = 0
): { subtotal: number; tax: number; total: number } {
const subtotal = items.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
)
const tax = subtotal * taxRate
return {
subtotal,
tax: Math.round(tax * 100) / 100,
total: Math.round((subtotal + tax) * 100) / 100,
}
}测试代码
ts
import { describe, it, expect } from 'vitest'
import { filterProducts, sortProducts, calculateCartTotal } from './product'
const mockProducts: Product[] = [
{ id: 1, name: 'Laptop', price: 999, category: 'electronics', inStock: true },
{ id: 2, name: 'Book', price: 29, category: 'books', inStock: true },
{ id: 3, name: 'Phone', price: 699, category: 'electronics', inStock: false },
{ id: 4, name: 'Pen', price: 5, category: 'stationery', inStock: true },
{ id: 5, name: 'Tablet', price: 499, category: 'electronics', inStock: true },
]
describe('filterProducts', () => {
it('should return all products when no filters applied', () => {
const result = filterProducts(mockProducts, {})
expect(result).toHaveLength(5)
})
it('should filter by category', () => {
const result = filterProducts(mockProducts, { category: 'electronics' })
expect(result).toHaveLength(3)
expect(result.every(p => p.category === 'electronics')).toBe(true)
})
it('should filter by price range', () => {
const result = filterProducts(mockProducts, { minPrice: 100, maxPrice: 700 })
expect(result).toHaveLength(2)
expect(result.map(p => p.name)).toEqual(['Phone', 'Tablet'])
})
it('should filter in-stock only', () => {
const result = filterProducts(mockProducts, { inStockOnly: true })
expect(result).toHaveLength(4)
expect(result.every(p => p.inStock)).toBe(true)
})
it('should combine multiple filters', () => {
const result = filterProducts(mockProducts, {
category: 'electronics',
maxPrice: 700,
inStockOnly: true,
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('Tablet')
})
it('should return empty array when no products match', () => {
const result = filterProducts(mockProducts, { category: 'food' })
expect(result).toHaveLength(0)
})
it('should handle empty products array', () => {
const result = filterProducts([], { category: 'electronics' })
expect(result).toEqual([])
})
})
describe('sortProducts', () => {
it('should sort by price ascending by default', () => {
const result = sortProducts(mockProducts, 'price')
expect(result[0].price).toBe(5)
expect(result[result.length - 1].price).toBe(999)
})
it('should sort by price descending', () => {
const result = sortProducts(mockProducts, 'price', 'desc')
expect(result[0].price).toBe(999)
expect(result[result.length - 1].price).toBe(5)
})
it('should sort by name ascending', () => {
const result = sortProducts(mockProducts, 'name')
expect(result.map(p => p.name)).toEqual([
'Book', 'Laptop', 'Pen', 'Phone', 'Tablet',
])
})
it('should not mutate original array', () => {
const original = [...mockProducts]
sortProducts(mockProducts, 'price')
expect(mockProducts).toEqual(original)
})
})
describe('calculateCartTotal', () => {
it('should calculate subtotal correctly', () => {
const items = [
{ product: mockProducts[0], quantity: 1 },
{ product: mockProducts[1], quantity: 3 },
]
const result = calculateCartTotal(items)
expect(result.subtotal).toBe(999 + 29 * 3)
expect(result.tax).toBe(0)
expect(result.total).toBe(1086)
})
it('should apply tax rate', () => {
const items = [{ product: mockProducts[0], quantity: 1 }]
const result = calculateCartTotal(items, 0.1)
expect(result.subtotal).toBe(999)
expect(result.tax).toBe(99.9)
expect(result.total).toBe(1098.9)
})
it('should handle empty cart', () => {
const result = calculateCartTotal([])
expect(result).toEqual({ subtotal: 0, tax: 0, total: 0 })
})
it('should round tax to 2 decimal places', () => {
const items = [
{ product: { ...mockProducts[1], price: 33 }, quantity: 1 },
]
const result = calculateCartTotal(items, 0.07)
expect(result.tax).toBe(2.31)
})
})5.2 测试异步函数
被测代码:API 服务层
ts
interface User {
id: number
name: string
email: string
}
class UserService {
private baseUrl: string
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
async getUser(id: number): Promise<User> {
if (id <= 0) throw new Error('Invalid user ID')
const response = await fetch(`${this.baseUrl}/users/${id}`)
if (response.status === 404) {
throw new Error(`User ${id} not found`)
}
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`)
}
return response.json()
}
async createUser(data: Omit<User, 'id'>): Promise<User> {
if (!data.name?.trim()) throw new Error('Name is required')
if (!data.email?.includes('@')) throw new Error('Invalid email')
const response = await fetch(`${this.baseUrl}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Failed to create user')
}
return response.json()
}
async getUsersWithRetry(
retries: number = 3,
delay: number = 1000
): Promise<User[]> {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(`${this.baseUrl}/users`)
if (response.ok) return response.json()
} catch {
if (i === retries - 1) throw new Error('Max retries exceeded')
await new Promise(resolve => setTimeout(resolve, delay))
}
}
throw new Error('Max retries exceeded')
}
}测试代码
ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
describe('UserService', () => {
let service: UserService
beforeEach(() => {
service = new UserService('https://api.example.com')
global.fetch = vi.fn()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('getUser', () => {
it('should fetch user by id', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@test.com' }
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockUser),
} as Response)
const user = await service.getUser(1)
expect(user).toEqual(mockUser)
expect(fetch).toHaveBeenCalledWith('https://api.example.com/users/1')
})
it('should throw when id is invalid', async () => {
await expect(service.getUser(0)).rejects.toThrow('Invalid user ID')
await expect(service.getUser(-1)).rejects.toThrow('Invalid user ID')
expect(fetch).not.toHaveBeenCalled()
})
it('should throw when user not found', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 404,
} as Response)
await expect(service.getUser(999)).rejects.toThrow('User 999 not found')
})
it('should throw on server error', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 500,
} as Response)
await expect(service.getUser(1)).rejects.toThrow('HTTP Error: 500')
})
})
describe('createUser', () => {
it('should create user with valid data', async () => {
const newUser = { name: 'Bob', email: 'bob@test.com' }
const createdUser = { id: 2, ...newUser }
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve(createdUser),
} as Response)
const result = await service.createUser(newUser)
expect(result).toEqual(createdUser)
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/users',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(newUser),
})
)
})
it('should throw when name is empty', async () => {
await expect(
service.createUser({ name: '', email: 'test@test.com' })
).rejects.toThrow('Name is required')
})
it('should throw when email is invalid', async () => {
await expect(
service.createUser({ name: 'Alice', email: 'invalid' })
).rejects.toThrow('Invalid email')
})
})
describe('getUsersWithRetry', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should return users on first success', async () => {
const mockUsers = [{ id: 1, name: 'Alice', email: 'a@t.com' }]
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUsers),
} as Response)
const result = await service.getUsersWithRetry()
expect(result).toEqual(mockUsers)
expect(fetch).toHaveBeenCalledTimes(1)
})
it('should retry on failure', async () => {
vi.mocked(fetch)
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response)
const promise = service.getUsersWithRetry(3, 100)
await vi.advanceTimersByTimeAsync(100)
await vi.advanceTimersByTimeAsync(100)
const result = await promise
expect(result).toEqual([])
expect(fetch).toHaveBeenCalledTimes(3)
})
it('should throw after max retries', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'))
const promise = service.getUsersWithRetry(3, 100)
await vi.advanceTimersByTimeAsync(100)
await vi.advanceTimersByTimeAsync(100)
await expect(promise).rejects.toThrow('Max retries exceeded')
})
})
})5.3 测试类 / 模块
被测代码:事件发射器
ts
type EventHandler = (...args: unknown[]) => void
class EventEmitter {
private events: Map<string, Set<EventHandler>> = new Map()
on(event: string, handler: EventHandler): () => void {
if (!this.events.has(event)) {
this.events.set(event, new Set())
}
this.events.get(event)!.add(handler)
return () => this.off(event, handler)
}
off(event: string, handler: EventHandler): void {
this.events.get(event)?.delete(handler)
}
emit(event: string, ...args: unknown[]): void {
this.events.get(event)?.forEach(handler => handler(...args))
}
once(event: string, handler: EventHandler): () => void {
const wrappedHandler = (...args: unknown[]) => {
handler(...args)
this.off(event, wrappedHandler)
}
return this.on(event, wrappedHandler)
}
listenerCount(event: string): number {
return this.events.get(event)?.size ?? 0
}
removeAllListeners(event?: string): void {
if (event) {
this.events.delete(event)
} else {
this.events.clear()
}
}
}测试代码
ts
import { describe, it, expect, vi } from 'vitest'
import { EventEmitter } from './event-emitter'
describe('EventEmitter', () => {
let emitter: EventEmitter
beforeEach(() => {
emitter = new EventEmitter()
})
describe('on / emit', () => {
it('should register and trigger event handler', () => {
const handler = vi.fn()
emitter.on('click', handler)
emitter.emit('click')
expect(handler).toHaveBeenCalledTimes(1)
})
it('should pass arguments to handler', () => {
const handler = vi.fn()
emitter.on('data', handler)
emitter.emit('data', 'hello', 42)
expect(handler).toHaveBeenCalledWith('hello', 42)
})
it('should support multiple handlers for same event', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()
emitter.on('click', handler1)
emitter.on('click', handler2)
emitter.emit('click')
expect(handler1).toHaveBeenCalledTimes(1)
expect(handler2).toHaveBeenCalledTimes(1)
})
it('should not trigger handlers for different events', () => {
const handler = vi.fn()
emitter.on('click', handler)
emitter.emit('hover')
expect(handler).not.toHaveBeenCalled()
})
})
describe('off', () => {
it('should remove event handler', () => {
const handler = vi.fn()
emitter.on('click', handler)
emitter.off('click', handler)
emitter.emit('click')
expect(handler).not.toHaveBeenCalled()
})
it('should return unsubscribe function from on()', () => {
const handler = vi.fn()
const unsubscribe = emitter.on('click', handler)
unsubscribe()
emitter.emit('click')
expect(handler).not.toHaveBeenCalled()
})
})
describe('once', () => {
it('should trigger handler only once', () => {
const handler = vi.fn()
emitter.once('click', handler)
emitter.emit('click')
emitter.emit('click')
emitter.emit('click')
expect(handler).toHaveBeenCalledTimes(1)
})
it('should pass arguments on single invocation', () => {
const handler = vi.fn()
emitter.once('data', handler)
emitter.emit('data', 'payload')
expect(handler).toHaveBeenCalledWith('payload')
})
})
describe('listenerCount', () => {
it('should return 0 for event with no listeners', () => {
expect(emitter.listenerCount('click')).toBe(0)
})
it('should return correct count', () => {
emitter.on('click', vi.fn())
emitter.on('click', vi.fn())
emitter.on('hover', vi.fn())
expect(emitter.listenerCount('click')).toBe(2)
expect(emitter.listenerCount('hover')).toBe(1)
})
})
describe('removeAllListeners', () => {
it('should remove all listeners for specific event', () => {
emitter.on('click', vi.fn())
emitter.on('click', vi.fn())
emitter.on('hover', vi.fn())
emitter.removeAllListeners('click')
expect(emitter.listenerCount('click')).toBe(0)
expect(emitter.listenerCount('hover')).toBe(1)
})
it('should remove all listeners when no event specified', () => {
emitter.on('click', vi.fn())
emitter.on('hover', vi.fn())
emitter.removeAllListeners()
expect(emitter.listenerCount('click')).toBe(0)
expect(emitter.listenerCount('hover')).toBe(0)
})
})
})5.4 测试错误处理
被测代码:表单验证器
ts
interface ValidationResult {
valid: boolean
errors: string[]
}
interface UserInput {
username: string
email: string
password: string
age: number
}
function validateUserInput(input: Partial<UserInput>): ValidationResult {
const errors: string[] = []
if (!input.username || input.username.trim().length < 3) {
errors.push('Username must be at least 3 characters')
}
if (input.username && input.username.length > 20) {
errors.push('Username must not exceed 20 characters')
}
if (input.username && !/^[a-zA-Z0-9_]+$/.test(input.username)) {
errors.push('Username can only contain letters, numbers, and underscores')
}
if (!input.email || !input.email.includes('@')) {
errors.push('Valid email is required')
}
if (!input.password || input.password.length < 8) {
errors.push('Password must be at least 8 characters')
}
if (input.password && !/[A-Z]/.test(input.password)) {
errors.push('Password must contain at least one uppercase letter')
}
if (input.password && !/[0-9]/.test(input.password)) {
errors.push('Password must contain at least one number')
}
if (input.age !== undefined && (input.age < 0 || input.age > 150)) {
errors.push('Age must be between 0 and 150')
}
return { valid: errors.length === 0, errors }
}测试代码
ts
import { describe, it, expect } from 'vitest'
import { validateUserInput } from './validator'
describe('validateUserInput', () => {
const validInput = {
username: 'alice_01',
email: 'alice@example.com',
password: 'SecurePass1',
age: 25,
}
it('should pass with valid input', () => {
const result = validateUserInput(validInput)
expect(result.valid).toBe(true)
expect(result.errors).toHaveLength(0)
})
describe('username validation', () => {
it('should fail when username is empty', () => {
const result = validateUserInput({ ...validInput, username: '' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Username must be at least 3 characters')
})
it('should fail when username is too short', () => {
const result = validateUserInput({ ...validInput, username: 'ab' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Username must be at least 3 characters')
})
it('should fail when username is too long', () => {
const result = validateUserInput({
...validInput,
username: 'a'.repeat(21),
})
expect(result.valid).toBe(false)
expect(result.errors).toContain('Username must not exceed 20 characters')
})
it('should fail when username has special characters', () => {
const result = validateUserInput({ ...validInput, username: 'alice@!' })
expect(result.valid).toBe(false)
expect(result.errors).toContain(
'Username can only contain letters, numbers, and underscores'
)
})
it('should accept username with exactly 3 characters', () => {
const result = validateUserInput({ ...validInput, username: 'abc' })
expect(result.valid).toBe(true)
})
})
describe('email validation', () => {
it('should fail when email is missing', () => {
const result = validateUserInput({ ...validInput, email: '' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Valid email is required')
})
it('should fail when email has no @ symbol', () => {
const result = validateUserInput({ ...validInput, email: 'invalid' })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Valid email is required')
})
})
describe('password validation', () => {
it('should fail when password is too short', () => {
const result = validateUserInput({ ...validInput, password: 'Ab1' })
expect(result.valid).toBe(false)
expect(result.errors).toContain(
'Password must be at least 8 characters'
)
})
it('should fail when password has no uppercase', () => {
const result = validateUserInput({
...validInput,
password: 'lowercase1',
})
expect(result.valid).toBe(false)
expect(result.errors).toContain(
'Password must contain at least one uppercase letter'
)
})
it('should fail when password has no number', () => {
const result = validateUserInput({
...validInput,
password: 'NoNumberHere',
})
expect(result.valid).toBe(false)
expect(result.errors).toContain(
'Password must contain at least one number'
)
})
})
describe('age validation', () => {
it('should fail when age is negative', () => {
const result = validateUserInput({ ...validInput, age: -1 })
expect(result.valid).toBe(false)
expect(result.errors).toContain('Age must be between 0 and 150')
})
it('should fail when age exceeds 150', () => {
const result = validateUserInput({ ...validInput, age: 200 })
expect(result.valid).toBe(false)
})
it('should pass when age is omitted', () => {
const { age, ...inputWithoutAge } = validInput
const result = validateUserInput(inputWithoutAge)
expect(result.valid).toBe(true)
})
})
describe('multiple errors', () => {
it('should collect all validation errors', () => {
const result = validateUserInput({})
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(1)
})
})
})5.5 测试环境设置与 Setup 文件
对于需要全局配置的场景(如 DOM 环境、全局 Mock),可以使用 setup 文件:
ts
import { vi, afterEach } from 'vitest'
afterEach(() => {
vi.restoreAllMocks()
vi.clearAllTimers()
})
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
Object.defineProperty(window, 'localStorage', {
value: (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (index: number) => Object.keys(store)[index] ?? null,
}
})(),
})在配置文件中引用:
ts
export default defineConfig({
test: {
setupFiles: ['./src/test/setup.ts'],
},
})六、面试高频问题
问题 1:单元测试和集成测试的区别是什么?什么时候该写哪种?
回答思路
单元测试隔离测试单个函数/模块,所有外部依赖都被 Mock 替换;集成测试则测试多个模块协作的正确性,通常不 Mock 或只 Mock 外部系统(如 API、数据库)。
选择策略:纯逻辑(工具函数、数据处理)→ 单元测试;模块交互、数据流转 → 集成测试;用户操作流程 → E2E 测试。遵循测试金字塔,单元测试占大多数。
问题 2:什么是 Mock?vi.fn()、vi.spyOn()、vi.mock() 三者有什么区别?
回答思路
| API | 作用域 | 典型用途 |
|---|---|---|
vi.fn() | 创建独立的模拟函数 | 作为回调参数传入、验证是否被调用 |
vi.spyOn() | 监听/替换已有对象的方法 | 监听 console.log、替换实例方法 |
vi.mock() | 模块级别替换 | Mock 整个文件的导出(如 API 模块) |
vi.fn() 创建一个全新的空函数;vi.spyOn() 包装已存在的函数,默认保留原实现,也可覆盖;vi.mock() 在模块系统层面拦截 import,替换整个模块的导出。
问题 3:为什么 vi.mock() 可以写在 import 下面却仍然能 Mock 成功?
回答思路
Vitest(和 Jest)会对测试文件进行静态分析,将 vi.mock() 调用提升(hoist)到文件顶部,在所有 import 语句之前执行。这样当 import 执行时,模块系统已经被替换成了 Mock 版本。这就是为什么 vi.mock() 的回调里不能引用外部变量(除非用 vi.hoisted() 提前声明)。
问题 4:快照测试有什么优缺点?什么时候适合使用?
回答思路
优点:编写简单(一行断言搞定),能快速捕获意外变更,对大型输出结构很方便。
缺点:快照容易过时,开发者可能盲目更新而不审查变化;大型快照难以审阅 diff;测试意图不明确。
适用场景:配置对象、序列化数据、错误消息格式、组件渲染结果。不适用:包含时间戳/随机值的数据、频繁变化的输出、需要精确逻辑断言的场景。
问题 5:如何测试异步函数?遇到定时器怎么处理?
回答思路
异步函数测试使用 async/await + rejects/resolves 匹配器。关键是要 Mock 外部依赖(如 fetch),确保测试不依赖网络。
定时器使用 vi.useFakeTimers() 接管 setTimeout/setInterval/Date,然后用 vi.advanceTimersByTime() 手动推进时间。测试结束后务必 vi.useRealTimers() 恢复。
问题 6:测试覆盖率 100% 就代表没有 Bug 吗?
回答思路
绝对不是。覆盖率只能衡量代码被执行到了多少比例,但无法保证:
- 断言覆盖了所有场景(代码跑了但没有 assert)
- 边界条件被测到了(如空值、极端值、并发)
- 业务逻辑正确性(逻辑本身就写错了,测试也按错误逻辑写的)
- 非功能性问题(性能、安全、兼容性)
覆盖率是"必要不充分条件"——覆盖率低说明测试不够,覆盖率高不说明测试充分。合理目标是核心模块 > 90%,整体 > 80%。
问题 7:什么是 AAA 模式?测试中 DRY 原则应该怎么运用?
回答思路
AAA = Arrange(准备数据和环境)→ Act(执行被测操作)→ Assert(验证结果)。这个模式让每个测试用例结构清晰,三段用空行分隔,可读性强。
关于 DRY:测试代码中不应过度追求 DRY。过度抽象会降低测试的可读性和调试体验。推荐做法是用 beforeEach 处理重复的 setup,用工厂函数(如 createTestUser)生成测试数据,用 it.each 做参数化测试。但每个测试用例的 Act 和 Assert 应该保持内联,一眼就能看出在测什么。
问题 8:Vitest 相比 Jest 有哪些优势?为什么越来越多项目选择 Vitest?
回答思路
核心优势:
- 速度:基于 esbuild 转换,Worker Threads 并行执行,比 Jest 快数倍
- ESM 原生支持:不需要 babel-jest 转换,直接运行 ESM 代码
- Vite 集成:与 Vite 共享配置和插件,对 Vite 项目零配置
- TypeScript 开箱即用:不需要 ts-jest 或额外配置
- 智能 Watch:基于 Vite 的模块依赖图,只重跑受影响的测试
- 内置 UI:
vitest --ui提供可视化界面
局限:相比 Jest 社区生态稍年轻,部分 Jest 生态插件还没有 Vitest 适配版本。但 API 高度兼容,迁移成本很低。
七、延伸阅读
- Vitest 官方文档 — 最权威的 API 参考和配置说明
- Jest 官方文档 — Jest 的完整指南
- Testing Library — 组件测试的最佳实践
- Kent C. Dodds - Testing JavaScript — 前端测试体系的系统课程
- Martin Fowler - Test Pyramid — 测试金字塔的经典文章
- xUnit Test Patterns — 测试模式与反模式的百科全书
- Vitest 源码 — 深入理解 Vitest 的工作原理
- The Art of Unit Testing — 单元测试的经典著作