主题
Mock 与测试替身
在前端测试中,我们经常面临一个核心挑战:如何在隔离环境中测试代码? 被测代码往往依赖网络请求、数据库、第三方库、浏览器 API 等外部因素,而这些因素在测试环境中要么不可用,要么不可控。测试替身(Test Double)就是为了解决这个问题而生的——它们是"真实依赖的替代品",让我们能在可控的环境中验证代码逻辑。
本文将从测试替身的理论基础出发,深入 Vitest Mock 系统、API Mock(MSW)、模块 Mock 进阶技巧、最佳实践,最终以面试高频问题收尾。
一、测试替身概念
什么是测试替身
"Test Double" 这个术语源自电影行业的"替身演员"(Stunt Double)——在危险场景中,替身代替真正的演员完成动作。在软件测试中,Test Double 代替真实的依赖对象,让测试更快、更稳定、更可控。
Gerard Meszaros 在《xUnit Test Patterns》中系统定义了五种测试替身类型。理解它们的区别,是写好 Mock 的前提。
五种测试替身
┌─────────────────────────────────────────────────────────────┐
│ Test Double 家族 │
├──────────┬──────────┬──────────┬──────────┬─────────────────┤
│ Dummy │ Stub │ Spy │ Mock │ Fake │
│ │ │ │ │ │
│ 占位对象 │ 返回预设值 │ 记录调用 │ 预设期望 │ 简化实现 │
│ 不参与 │ 不验证 │ 可验证 │ 自动验证 │ 可运行 │
│ 逻辑 │ 调用情况 │ 调用情况 │ 调用情况 │ 但不用于生产 │
└──────────┴──────────┴──────────┴──────────┴─────────────────┘
←────────── 复杂度递增 ──────────────→各类型详解
1. Dummy(哑对象)
仅用于满足参数列表要求,不会被实际使用。
typescript
function createOrder(user: User, logger: Logger) {
return { userId: user.id, createdAt: new Date() }
}
const dummyLogger = {} as Logger
const order = createOrder(realUser, dummyLogger)2. Stub(存根)
返回预设的固定值,不关心调用方式,只控制输入。
typescript
const stubbedFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' }),
})3. Spy(间谍)
包装真实对象,记录调用信息,同时保留原始行为。
typescript
const spy = vi.spyOn(console, 'log')
doSomething()
expect(spy).toHaveBeenCalledWith('expected message')
spy.mockRestore()4. Mock(模拟对象)
预先设定期望行为和断言,测试结束时自动验证。
typescript
const mockNotify = vi.fn()
userService.onUpdate(mockNotify)
userService.updateName('Bob')
expect(mockNotify).toHaveBeenCalledTimes(1)
expect(mockNotify).toHaveBeenCalledWith({ name: 'Bob' })5. Fake(伪造对象)
具有简化但可工作的实现,适合替代重量级依赖。
typescript
class FakeUserRepository {
private users = new Map<string, User>()
async save(user: User) {
this.users.set(user.id, user)
}
async findById(id: string) {
return this.users.get(id) ?? null
}
async findAll() {
return Array.from(this.users.values())
}
}对比总结
| 类型 | 有实现逻辑 | 控制返回值 | 记录调用 | 验证行为 | 典型用途 |
|---|---|---|---|---|---|
| Dummy | ❌ | ❌ | ❌ | ❌ | 填充参数列表 |
| Stub | ❌ | ✅ | ❌ | ❌ | 控制依赖的输出 |
| Spy | ✅(保留原始) | ❌(可选) | ✅ | ✅ | 观察真实行为 |
| Mock | ❌ | ✅ | ✅ | ✅ | 验证交互行为 |
| Fake | ✅(简化版) | ❌ | ❌ | ❌ | 替代复杂基础设施 |
决策流程
需要测试替身?
│
├── 只需要填参数? ──→ Dummy
│
├── 需要控制返回值? ──→ Stub
│
├── 需要观察调用但保留原实现? ──→ Spy
│
├── 需要验证交互行为? ──→ Mock
│
└── 需要一个可工作的简化版? ──→ Fake二、Vitest Mock 系统
Vitest 提供了一套完整的 Mock 工具链,涵盖函数级别、对象方法级别、模块级别和全局级别的 Mock 能力。
vi.fn() — 创建模拟函数
vi.fn() 是 Vitest 中最基础的 Mock 工具,创建一个全新的模拟函数。它的核心能力有两个:控制返回值 和 追踪调用。
基本用法
typescript
const fn = vi.fn()
fn('hello')
fn('world')
expect(fn).toHaveBeenCalledTimes(2)
expect(fn).toHaveBeenCalledWith('hello')
expect(fn).toHaveBeenLastCalledWith('world')返回值控制
typescript
const getPrice = vi.fn()
getPrice.mockReturnValue(100)
expect(getPrice()).toBe(100)
getPrice.mockReturnValueOnce(200).mockReturnValueOnce(300)
expect(getPrice()).toBe(200)
expect(getPrice()).toBe(300)
expect(getPrice()).toBe(100)mockReturnValueOnce 队列:
┌─────┐ ┌─────┐ ┌─────────────────┐
│ 200 │→ │ 300 │→ │ fallback: 100 │
└─────┘ └─────┘ └─────────────────┘
1st 2nd 3rd 及以后异步返回值
typescript
const fetchUser = vi.fn()
fetchUser.mockResolvedValue({ id: 1, name: 'Alice' })
const user = await fetchUser()
expect(user.name).toBe('Alice')
fetchUser.mockRejectedValue(new Error('Network Error'))
await expect(fetchUser()).rejects.toThrow('Network Error')
fetchUser
.mockResolvedValueOnce({ id: 1, name: 'Alice' })
.mockRejectedValueOnce(new Error('Timeout'))
.mockResolvedValue({ id: 2, name: 'Bob' })自定义实现
typescript
const calculate = vi.fn((a: number, b: number) => a + b)
expect(calculate(2, 3)).toBe(5)
expect(calculate).toHaveBeenCalledWith(2, 3)
calculate.mockImplementationOnce((a, b) => a * b)
expect(calculate(2, 3)).toBe(6)
expect(calculate(2, 3)).toBe(5)调用追踪 API 全景
typescript
const fn = vi.fn()
fn('a', 1)
fn('b', 2)
fn('c', 3)
expect(fn).toHaveBeenCalled()
expect(fn).toHaveBeenCalledTimes(3)
expect(fn).toHaveBeenCalledWith('a', 1)
expect(fn).toHaveBeenNthCalledWith(1, 'a', 1)
expect(fn).toHaveBeenNthCalledWith(2, 'b', 2)
expect(fn).toHaveBeenLastCalledWith('c', 3)
expect(fn.mock.calls).toEqual([
['a', 1],
['b', 2],
['c', 3],
])
expect(fn.mock.results).toEqual([
{ type: 'return', value: undefined },
{ type: 'return', value: undefined },
{ type: 'return', value: undefined },
])mock 属性深入
fn.mock
├── calls: any[][] 所有调用的参数列表
├── results: { type, value }[] 每次调用的返回值
├── instances: any[] 通过 new 调用时的 this
├── contexts: any[] 每次调用的 this 上下文
└── lastCall: any[] 最后一次调用的参数typescript
const fn = vi.fn().mockReturnValue(42)
fn('x')
fn('y')
expect(fn.mock.calls.length).toBe(2)
expect(fn.mock.calls[0]).toEqual(['x'])
expect(fn.mock.results[0]).toEqual({ type: 'return', value: 42 })
expect(fn.mock.lastCall).toEqual(['y'])重置与恢复
typescript
const fn = vi.fn().mockReturnValue(1)
fn()
fn.mockClear()
expect(fn.mock.calls.length).toBe(0)
expect(fn.mockReturnValue).toBeDefined()
fn.mockReset()
expect(fn()).toBeUndefined()
fn.mockReturnValue(2)
fn.mockRestore()┌──────────────┬────────────────┬────────────────┬────────────────┐
│ │ mockClear() │ mockReset() │ mockRestore() │
├──────────────┼────────────────┼────────────────┼────────────────┤
│ 清除调用记录 │ ✅ │ ✅ │ ✅ │
│ 清除返回值 │ ❌ │ ✅ │ ✅ │
│ 恢复原实现 │ ❌ │ ❌ │ ✅ │
└──────────────┴────────────────┴────────────────┴────────────────┘vi.spyOn() — 监视已有对象方法
vi.spyOn() 不是创建新函数,而是包装已有的对象方法,在保留原始实现的同时追踪调用。
基本用法
typescript
const cart = {
items: [],
getTotal() {
return this.items.reduce((sum, item) => sum + item.price, 0)
},
}
const spy = vi.spyOn(cart, 'getTotal')
cart.getTotal()
expect(spy).toHaveBeenCalledTimes(1)
spy.mockRestore()监视 getter / setter
typescript
const user = {
_name: 'Alice',
get name() {
return this._name
},
set name(val: string) {
this._name = val
},
}
const getSpy = vi.spyOn(user, 'name', 'get')
const setSpy = vi.spyOn(user, 'name', 'set')
user.name
user.name = 'Bob'
expect(getSpy).toHaveBeenCalledTimes(1)
expect(setSpy).toHaveBeenCalledWith('Bob')覆盖实现
typescript
const mathUtils = {
random() {
return Math.random()
},
}
vi.spyOn(mathUtils, 'random').mockReturnValue(0.5)
expect(mathUtils.random()).toBe(0.5)
expect(mathUtils.random()).toBe(0.5)Spy vs fn 对比
| 特性 | vi.fn() | vi.spyOn() |
|---|---|---|
| 创建方式 | 全新函数 | 包装已有方法 |
| 是否保留原实现 | ❌(默认返回 undefined) | ✅(默认调用原方法) |
| 可否恢复 | mockRestore 无意义 | mockRestore 恢复原方法 |
| 典型场景 | 回调函数、事件处理器 | 监视模块方法、对象方法 |
vi.mock() — 模块级别 Mock
vi.mock() 是 Vitest 中最强大也最复杂的 Mock 工具,它在模块系统层面进行拦截。
工作原理
┌─────────────┐ import ┌─────────────┐
│ 被测模块 │ ──────────────→ │ 真实模块 │
│ (SUT) │ │ │
└─────────────┘ └─────────────┘
vi.mock() 介入后:
┌─────────────┐ import ┌─────────────┐
│ 被测模块 │ ──────────────→ │ Mock 模块 │ ←── vi.mock() 拦截
│ (SUT) │ │ (替代品) │
└─────────────┘ └─────────────┘
│
┌───────┴───────┐
│ 自动 Mock │
│ 或 │
│ 手动 Factory │
└───────────────┘需要理解的关键机制:vi.mock() 调用会被**自动提升(hoisted)**到文件顶部,在所有 import 之前执行。这意味着无论你把 vi.mock() 写在文件的哪个位置,它都会在 import 之前生效。
自动 Mock
typescript
vi.mock('./userService')
import { userService } from './userService'
test('auto mock', () => {
expect(vi.isMockFunction(userService.getUser)).toBe(true)
userService.getUser.mockResolvedValue({ id: 1, name: 'Alice' })
})自动 Mock 会将模块的所有导出替换为 vi.fn(),对象结构保持不变。
手动 Factory Mock
typescript
vi.mock('./config', () => ({
default: {
apiUrl: 'http://test-api.example.com',
timeout: 1000,
},
getFeatureFlag: vi.fn().mockReturnValue(true),
}))
import config, { getFeatureFlag } from './config'
test('uses mock config', () => {
expect(config.apiUrl).toBe('http://test-api.example.com')
expect(getFeatureFlag()).toBe(true)
})__mocks__ 目录
对于多个测试文件共享的 Mock,可以在模块同级目录下创建 __mocks__ 文件夹:
src/
services/
__mocks__/
analytics.ts ← 手动 Mock 文件
analytics.ts ← 真实模块
components/
Dashboard.test.tstypescript
vi.mock('../services/analytics')
import { track } from '../services/analytics'当调用 vi.mock() 时,Vitest 会自动查找 __mocks__ 目录中的对应文件作为替代实现。
vi.mock 的提升(Hoisting)机制
typescript
import { fetchData } from './api'
vi.mock('./api', () => ({
fetchData: vi.fn().mockResolvedValue({ data: 'mocked' }),
}))
test('hoisting demo', async () => {
const result = await fetchData()
expect(result.data).toBe('mocked')
})虽然 vi.mock() 写在 import 之后,但 Vitest 会将其自动提升到文件最顶部。编译后的实际执行顺序是:
1. vi.mock('./api', ...) ← 先执行 Mock 注册
2. import { fetchData } ← 再执行 import(获取的是 Mock 版本)
3. test(...) ← 最后执行测试注意:由于 hoisting 机制,在 vi.mock() 的 factory 函数内不能引用外部变量(因为那些变量在 hoisting 后还没有被声明)。如果需要引用外部变量,使用 vi.hoisted():
typescript
const { mockFetch } = vi.hoisted(() => ({
mockFetch: vi.fn().mockResolvedValue({ data: 'test' }),
}))
vi.mock('./api', () => ({
fetchData: mockFetch,
}))vi.stubGlobal() — 全局变量 Mock
用于替换全局对象上的属性,非常适合模拟浏览器 API。
Mock fetch
typescript
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ message: 'success' }),
})
const response = await fetch('/api/data')
const data = await response.json()
expect(data.message).toBe('success')
expect(mockFetch).toHaveBeenCalledWith('/api/data')Mock localStorage
typescript
const store: Record<string, string> = {}
const mockLocalStorage = {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach((key) => delete store[key])
}),
get length() {
return Object.keys(store).length
},
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
}
vi.stubGlobal('localStorage', mockLocalStorage)Mock window 属性
typescript
vi.stubGlobal('innerWidth', 1024)
vi.stubGlobal('innerHeight', 768)
vi.stubGlobal('matchMedia', vi.fn((query: string) => ({
matches: query === '(min-width: 768px)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})))
vi.stubGlobal('location', {
href: 'http://localhost:3000/test',
pathname: '/test',
search: '?id=1',
hash: '#section',
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
})vi.useFakeTimers() — 模拟定时器
JavaScript 中大量异步行为依赖定时器(setTimeout、setInterval、Date),在测试中等待真实时间流逝是不可接受的。Vitest 提供了时间控制能力。
基本用法
typescript
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
test('setTimeout', () => {
const callback = vi.fn()
setTimeout(callback, 1000)
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(1)
})setInterval
typescript
test('setInterval polling', () => {
const poll = vi.fn()
setInterval(poll, 500)
vi.advanceTimersByTime(500)
expect(poll).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(500)
expect(poll).toHaveBeenCalledTimes(2)
vi.advanceTimersByTime(1500)
expect(poll).toHaveBeenCalledTimes(5)
})定时器 API 全景
typescript
vi.useFakeTimers()
vi.advanceTimersByTime(1000)
vi.advanceTimersToNextTimer()
vi.runAllTimers()
vi.runOnlyPendingTimers()
vi.getTimerCount()
vi.useRealTimers()模拟 Date
typescript
test('mock Date', () => {
const mockDate = new Date('2025-01-01T00:00:00.000Z')
vi.setSystemTime(mockDate)
expect(new Date().toISOString()).toBe('2025-01-01T00:00:00.000Z')
expect(Date.now()).toBe(mockDate.getTime())
vi.useRealTimers()
})实战:测试 debounce
typescript
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
describe('debounce', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
test('delays execution', () => {
const fn = vi.fn()
const debounced = debounce(fn, 300)
debounced('a')
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(fn).toHaveBeenCalledWith('a')
})
test('resets timer on repeated calls', () => {
const fn = vi.fn()
const debounced = debounce(fn, 300)
debounced('a')
vi.advanceTimersByTime(200)
debounced('b')
vi.advanceTimersByTime(200)
debounced('c')
vi.advanceTimersByTime(300)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith('c')
})
})三、API Mock
在前端开发中,几乎所有应用都需要与后端 API 通信。测试这些涉及网络请求的代码时,我们不希望真正发出 HTTP 请求。API Mock 提供了在测试中模拟网络层的能力。
为什么需要 API Mock
┌────────────┐ HTTP ┌────────────┐
│ 前端应用 │ ──────────→ │ 后端服务 │
│ │ ←────────── │ │
└────────────┘ └────────────┘
测试中的问题:
× 后端服务可能不可用
× 网络延迟不可控
× 测试数据难以管理
× 无法模拟错误场景
× 测试速度慢
API Mock 解决方案:
┌────────────┐ HTTP ┌────────────┐
│ 前端应用 │ ──────────→ │ Mock 拦截 │ ← 不会到达真实后端
│ │ ←────────── │ │
└────────────┘ └────────────┘MSW(Mock Service Worker)
MSW 是目前前端 API Mock 的事实标准。它通过 Service Worker(浏览器环境)或拦截器(Node 环境)在网络层拦截请求,让被测代码完全不知道请求被 Mock 了。
MSW 架构
浏览器环境:
┌──────────┐ fetch() ┌──────────────┐ 拦截 ┌──────────┐
│ 应用代码 │ ───────────→ │ Service Worker│ ─────────→ │ MSW │
│ │ ←─────────── │ (mockServiceWorker.js) │ Handlers │
└──────────┘ └──────────────┘ └──────────┘
Node 环境 (测试):
┌──────────┐ fetch() ┌──────────────┐ 拦截 ┌──────────┐
│ 测试代码 │ ───────────→ │ 拦截器 │ ─────────→ │ MSW │
│ │ ←─────────── │ (@mswjs/interceptors) │ Handlers │
└──────────┘ └──────────────┘ └──────────┘安装与配置
bash
npm install msw --save-devtypescript
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
])
}),
http.get('/api/users/:id', ({ params }) => {
const { id } = params
return HttpResponse.json({ id: Number(id), name: `User ${id}` })
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json()
return HttpResponse.json(
{ id: 3, ...body },
{ status: 201 }
)
}),
]
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())REST API Mock 实战
typescript
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
http.get('/api/products', ({ request }) => {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page') ?? '1')
const limit = Number(url.searchParams.get('limit') ?? '10')
const products = Array.from({ length: limit }, (_, i) => ({
id: (page - 1) * limit + i + 1,
name: `Product ${(page - 1) * limit + i + 1}`,
price: Math.floor(Math.random() * 10000) / 100,
}))
return HttpResponse.json({
data: products,
total: 100,
page,
limit,
})
}),
http.put('/api/products/:id', async ({ params, request }) => {
const { id } = params
const updates = await request.json()
return HttpResponse.json({ id: Number(id), ...updates })
}),
http.delete('/api/products/:id', ({ params }) => {
return new HttpResponse(null, { status: 204 })
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('fetch products with pagination', async () => {
const response = await fetch('/api/products?page=2&limit=5')
const data = await response.json()
expect(data.page).toBe(2)
expect(data.limit).toBe(5)
expect(data.data).toHaveLength(5)
expect(data.data[0].id).toBe(6)
})模拟错误场景
typescript
test('handles server error', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
)
})
)
const response = await fetch('/api/users')
expect(response.status).toBe(500)
})
test('handles network error', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.error()
})
)
await expect(fetch('/api/users')).rejects.toThrow()
})
test('handles timeout', async () => {
server.use(
http.get('/api/users', async () => {
await new Promise((resolve) => setTimeout(resolve, 10000))
return HttpResponse.json([])
})
)
})验证请求内容
typescript
test('sends correct request body', async () => {
let capturedBody: any
server.use(
http.post('/api/users', async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json({ id: 1, ...capturedBody }, { status: 201 })
})
)
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Charlie', email: 'charlie@test.com' }),
})
expect(capturedBody).toEqual({
name: 'Charlie',
email: 'charlie@test.com',
})
})
test('sends correct headers', async () => {
let capturedHeaders: Headers
server.use(
http.get('/api/protected', ({ request }) => {
capturedHeaders = request.headers
return HttpResponse.json({ data: 'secret' })
})
)
await fetch('/api/protected', {
headers: { Authorization: 'Bearer test-token' },
})
expect(capturedHeaders.get('Authorization')).toBe('Bearer test-token')
})GraphQL Mock
typescript
import { graphql, HttpResponse } from 'msw'
const handlers = [
graphql.query('GetUser', ({ variables }) => {
const { id } = variables
return HttpResponse.json({
data: {
user: {
id,
name: 'Alice',
email: 'alice@example.com',
},
},
})
}),
graphql.mutation('CreateUser', ({ variables }) => {
const { input } = variables
return HttpResponse.json({
data: {
createUser: {
id: '123',
...input,
},
},
})
}),
graphql.query('GetUser', ({ variables }) => {
return HttpResponse.json({
data: null,
errors: [
{
message: 'User not found',
path: ['user'],
},
],
})
}),
]MSW vs 直接 Mock fetch
| 维度 | MSW | 直接 Mock fetch |
|---|---|---|
| 拦截层级 | 网络层(Service Worker / 拦截器) | 函数层(替换全局 fetch) |
| 被测代码感知 | 完全无感(像真实网络请求) | 可能受 Mock 实现影响 |
| 支持的 HTTP 客户端 | fetch、axios、XMLHttpRequest 等全部 | 仅 fetch(Mock 谁支持谁) |
| Request/Response API | 使用标准 Web API | 需要手动构造 |
| 浏览器开发调试 | 可在 DevTools Network 面板看到请求 | 不可见 |
| 配置复杂度 | 稍高(需要 setup) | 低(直接 vi.fn()) |
| 可复用性 | handlers 可在测试和开发环境共享 | 通常仅用于测试 |
| 学习成本 | 需要学习 MSW API | 仅需 vi.fn() 基础 |
推荐策略:项目中有多个 API 调用场景时,优先使用 MSW;简单的单测中偶尔 Mock 一个 fetch 调用,直接用 vi.fn() 即可。
四、模块 Mock 进阶
实际项目中,我们经常需要 Mock 第三方库、部分模块、环境变量、静态资源等。这一章涵盖各种进阶场景。
Mock 第三方库
Mock axios
typescript
vi.mock('axios')
import axios from 'axios'
const mockedAxios = vi.mocked(axios)
test('fetches users', async () => {
mockedAxios.get.mockResolvedValue({
data: [{ id: 1, name: 'Alice' }],
status: 200,
})
const response = await axios.get('/api/users')
expect(response.data).toEqual([{ id: 1, name: 'Alice' }])
expect(mockedAxios.get).toHaveBeenCalledWith('/api/users')
})Mock dayjs
typescript
vi.mock('dayjs', () => {
const mockDayjs = () => ({
format: vi.fn().mockReturnValue('2025-01-01'),
add: vi.fn().mockReturnThis(),
subtract: vi.fn().mockReturnThis(),
isAfter: vi.fn().mockReturnValue(true),
isBefore: vi.fn().mockReturnValue(false),
toISOString: vi.fn().mockReturnValue('2025-01-01T00:00:00.000Z'),
})
mockDayjs.extend = vi.fn()
mockDayjs.locale = vi.fn()
return { default: mockDayjs }
})Mock next/router
typescript
vi.mock('next/router', () => ({
useRouter: vi.fn().mockReturnValue({
pathname: '/test',
query: { id: '1' },
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn().mockResolvedValue(undefined),
isReady: true,
}),
}))
import { useRouter } from 'next/router'
test('navigates on button click', () => {
const router = useRouter()
router.push('/dashboard')
expect(router.push).toHaveBeenCalledWith('/dashboard')
})Mock React Router
typescript
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return {
...actual,
useNavigate: vi.fn().mockReturnValue(vi.fn()),
useParams: vi.fn().mockReturnValue({ id: '123' }),
useSearchParams: vi.fn().mockReturnValue([
new URLSearchParams('?page=1'),
vi.fn(),
]),
}
})部分 Mock(Partial Mock)
有时候我们只想 Mock 模块中的某些导出,其余保持真实实现。
typescript
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils')
return {
...actual,
fetchData: vi.fn().mockResolvedValue({ data: 'mocked' }),
}
})部分 Mock 示意:
┌─────────────────────────────────┐
│ ./utils 模块 │
├─────────────────────────────────┤
│ formatDate() → 真实实现 ✅ │
│ validateEmail() → 真实实现 ✅ │
│ fetchData() → Mock 实现 🔶 │
│ parseJSON() → 真实实现 ✅ │
└─────────────────────────────────┘这个模式非常有用:我们只想控制 fetchData 的网络行为,但 formatDate、validateEmail 等纯函数没有必要 Mock。
Mock 环境变量
使用 vi.stubEnv
typescript
test('reads API URL from env', () => {
vi.stubEnv('VITE_API_URL', 'http://test-api.example.com')
expect(import.meta.env.VITE_API_URL).toBe('http://test-api.example.com')
vi.unstubAllEnvs()
})使用 vi.mock 模拟配置模块
typescript
vi.mock('./config', () => ({
config: {
apiUrl: 'http://test-api.example.com',
env: 'test',
debug: false,
},
}))process.env
typescript
test('NODE_ENV check', () => {
const originalEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
expect(process.env.NODE_ENV).toBe('production')
process.env.NODE_ENV = originalEnv
})Mock CSS Modules
CSS Modules 在 import 时返回一个类名映射对象,在测试环境中我们通常不需要真实的 CSS。
在 Vitest 配置中:
typescript
export default defineConfig({
test: {
css: false,
},
})或者使用 identity-obj-proxy 模式:
typescript
vi.mock('*.module.css', () => {
return new Proxy(
{},
{
get(_, property) {
return property
},
}
)
})这样 styles.container 会返回 'container' 字符串,在快照测试中产生有意义的输出。
Mock 静态资源
图片、SVG、字体等静态资源在测试中不需要真正加载。
在 Vitest 配置中:
typescript
export default defineConfig({
test: {
alias: {
'\\.(jpg|jpeg|png|gif|webp)$': '<rootDir>/test/__mocks__/fileMock.ts',
'\\.svg$': '<rootDir>/test/__mocks__/svgMock.ts',
},
},
})typescript
export default 'test-file-stub'typescript
const SvgMock = 'svg-mock'
export default SvgMock
export const ReactComponent = (props: any) => <span data-testid="svg-mock" {...props} />或者在 Vitest 配置中更简洁地处理:
typescript
export default defineConfig({
test: {
deps: {
inline: [/\.svg/, /\.png/],
},
},
})五、Mock 最佳实践
Mock 是一把双刃剑。用得好,它让测试快速、稳定、可控;用得不好,它让测试脆弱、误导、维护成本高。
最小化 Mock 原则
核心理念:只 Mock 外部边界依赖,不 Mock 内部实现细节。
被测系统边界
┌──────────────────┐
外部依赖 │ │ 外部依赖
┌──────────┐ │ Component A │ ┌──────────┐
│ HTTP API │←──│ ↓ │──→│ Storage │
└──────────┘ │ Component B │ └──────────┘
应该 Mock │ ↓ │ 应该 Mock
│ Util Function │
┌──────────┐ │ │ ┌──────────┐
│ 第三方库 │←──│ │──→│ 定时器 │
└──────────┘ └──────────────────┘ └──────────┘
视情况 Mock 应该 Mock
内部模块之间不要 Mock!typescript
vi.mock('../utils/formatDate')
vi.mock('../services/userService')
const result = calculateAge(user)
expect(result).toBe(25)typescript
const result = calculateAge({
birthDate: '2000-01-15',
})
expect(result).toBe(25)第一种方式 Mock 了 formatDate 和 userService,但 calculateAge 只是一个纯计算函数,完全不需要 Mock 依赖。第二种方式直接传入数据,更简洁、更有价值。
避免过度 Mock
当你发现测试中 Mock 的代码比实际测试逻辑还多时,应该警惕。
过度 Mock 的信号:
⚠️ 警告信号:
├── 测试文件顶部有超过 5 个 vi.mock()
├── 修改一行业务代码需要更新 3 个测试文件的 Mock
├── 所有测试都通过,但生产环境出 bug(Mock 掩盖了问题)
├── Mock 的行为与真实实现不一致
└── 测试只验证了"函数被调用了",而不验证结果反模式:
typescript
vi.mock('./database')
vi.mock('./cache')
vi.mock('./logger')
vi.mock('./validator')
vi.mock('./transformer')
vi.mock('./notifier')
test('creates user', async () => {
database.save.mockResolvedValue({ id: 1 })
cache.set.mockResolvedValue(undefined)
validator.validate.mockReturnValue(true)
transformer.transform.mockReturnValue({ name: 'ALICE' })
notifier.send.mockResolvedValue(undefined)
await createUser({ name: 'Alice' })
expect(database.save).toHaveBeenCalled()
expect(cache.set).toHaveBeenCalled()
expect(notifier.send).toHaveBeenCalled()
})这个测试 Mock 了几乎所有依赖,只验证了函数是否被调用,完全没有验证业务逻辑。即使 createUser 的实现被完全删除,只要调用了这些 Mock 函数,测试就能通过。
推荐做法:
typescript
test('creates user with correct data', async () => {
server.use(
http.post('/api/users', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ id: 1, ...body }, { status: 201 })
})
)
const user = await createUser({ name: 'Alice', email: 'alice@test.com' })
expect(user.id).toBe(1)
expect(user.name).toBe('Alice')
expect(user.email).toBe('alice@test.com')
})使用 MSW 只 Mock 网络层,让其他内部逻辑真实执行,验证最终结果而非中间过程。
Mock 的维护成本
Mock 维护成本与数量的关系:
成本
↑
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│╱
└──────────────→ Mock 数量
Mock 越多:
- 测试越脆弱(真实 API 改变时 Mock 不自动更新)
- 重构越困难(内部结构变化导致 Mock 失效)
- 误导性越强(Mock 通过 ≠ 真实通过)减少维护成本的策略:
- 集中管理 Mock:将常用 Mock 抽取到共享 fixtures 或 setup 文件
- 使用 MSW:网络层 Mock 与实现细节解耦
- Mock 数据工厂:使用工厂函数生成测试数据,而不是硬编码
- 定期审查 Mock:确保 Mock 行为与真实实现保持一致
何时使用 Mock vs 真实依赖
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 纯函数 | 真实依赖 | 无副作用,直接测试更可靠 |
| HTTP 请求 | MSW Mock | 网络不可控,需要隔离 |
| 数据库操作 | Fake / Docker 容器 | 复杂交互,Fake 更真实 |
| 第三方 SDK | Mock | 外部依赖不可控 |
| 定时器 | Fake Timers | 无法等待真实时间 |
| localStorage | Mock | Node 环境不存在 |
| 内部工具函数 | 真实依赖 | 不应 Mock 内部实现 |
| 随机数 / Date | Mock | 需要确定性结果 |
| Console / Logger | Spy | 只需验证是否调用 |
Mock 数据工厂(Factory Pattern)
硬编码测试数据的问题:测试之间耦合、数据冗余、修改字段时要改很多地方。工厂模式解决这些问题。
手动工厂函数
typescript
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
createdAt: string
}
function createMockUser(overrides: Partial<User> = {}): User {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user',
createdAt: '2025-01-01T00:00:00.000Z',
...overrides,
}
}
test('admin user has admin badge', () => {
const admin = createMockUser({ role: 'admin', name: 'Admin' })
expect(getDisplayBadge(admin)).toBe('🛡️ Admin')
})
test('regular user has no badge', () => {
const user = createMockUser()
expect(getDisplayBadge(user)).toBe('')
})使用 @faker-js/faker
bash
npm install @faker-js/faker --save-devtypescript
import { faker } from '@faker-js/faker'
function createMockUser(overrides: Partial<User> = {}): User {
return {
id: faker.number.int({ min: 1, max: 10000 }),
name: faker.person.fullName(),
email: faker.internet.email(),
role: faker.helpers.arrayElement(['admin', 'user']),
createdAt: faker.date.past().toISOString(),
...overrides,
}
}
function createMockProduct(overrides: Partial<Product> = {}): Product {
return {
id: faker.string.uuid(),
name: faker.commerce.productName(),
price: parseFloat(faker.commerce.price()),
description: faker.commerce.productDescription(),
category: faker.commerce.department(),
inStock: faker.datatype.boolean(),
...overrides,
}
}
function createMockUsers(count: number, overrides: Partial<User> = {}): User[] {
return Array.from({ length: count }, () => createMockUser(overrides))
}typescript
test('displays user list', () => {
const users = createMockUsers(50)
render(<UserList users={users} />)
expect(screen.getAllByRole('listitem')).toHaveLength(50)
})
test('filters admin users', () => {
const users = [
...createMockUsers(3, { role: 'user' }),
...createMockUsers(2, { role: 'admin' }),
]
const admins = filterAdmins(users)
expect(admins).toHaveLength(2)
admins.forEach((admin) => expect(admin.role).toBe('admin'))
})使用 faker 的确定性种子
typescript
import { faker } from '@faker-js/faker'
beforeEach(() => {
faker.seed(12345)
})设置种子后,每次运行生成的数据都相同,保证测试的可重复性。
Builder 模式
typescript
class UserBuilder {
private user: User = {
id: 1,
name: 'Default User',
email: 'default@test.com',
role: 'user',
createdAt: '2025-01-01T00:00:00.000Z',
}
withId(id: number) {
this.user.id = id
return this
}
withName(name: string) {
this.user.name = name
return this
}
withEmail(email: string) {
this.user.email = email
return this
}
asAdmin() {
this.user.role = 'admin'
return this
}
build(): User {
return { ...this.user }
}
}
test('admin dashboard access', () => {
const admin = new UserBuilder().withName('Alice').asAdmin().build()
expect(canAccessDashboard(admin)).toBe(true)
})
test('regular user cannot access dashboard', () => {
const user = new UserBuilder().withName('Bob').build()
expect(canAccessDashboard(user)).toBe(false)
})六、面试高频问题
1. Mock、Stub、Spy 三者有什么区别?实际开发中如何选择? ⭐⭐
回答思路:
从定义出发:Stub 只控制返回值不验证调用、Spy 包装真实实现并记录调用、Mock 预设期望并自动验证行为。
在 Vitest 中:
vi.fn()对应 Mock/Stub(取决于你是否验证调用)vi.spyOn()对应 Spy(保留原实现,记录调用)
选择策略:
- 需要控制函数返回值 → Stub(
vi.fn().mockReturnValue()) - 需要观察已有方法是否被调用,同时保留原逻辑 → Spy(
vi.spyOn()) - 需要替换整个函数并验证交互细节 → Mock(
vi.fn()+ 断言)
关键认知:在现代测试框架中,这三者的界限已经模糊。vi.fn() 既能做 Stub 也能做 Mock,vi.spyOn() 也可以通过 mockImplementation 变成 Mock。重要的是理解你的测试目的——是验证输出(state verification)还是验证交互(behavior verification)。
2. vi.mock() 的 hoisting 机制是怎么回事?为什么 factory 函数里不能访问外部变量? ⭐⭐⭐
回答思路:
Vitest 在编译阶段会将所有 vi.mock() 调用提升到文件顶部,在所有 import 语句之前执行。这是因为 ESM 模块的 import 是静态绑定的,要在模块被导入之前完成 Mock 注册。
源码顺序: 编译后执行顺序:
1. import { foo } 1. vi.mock('./foo', ...) ← 提升
2. vi.mock('./foo', ...) 2. import { foo }
3. test(...) 3. test(...)factory 函数里不能访问外部变量,是因为提升后,那些变量还没有被声明。解决方案是使用 vi.hoisted(),它会把变量声明也一起提升。
3. MSW 和直接 Mock fetch 相比有什么优势?什么场景下用哪个? ⭐⭐
回答思路:
MSW 在网络层拦截请求,fetch/axios/XHR 全部生效,被测代码无感知;直接 Mock fetch 只替换了一个全局函数,使用 axios 的代码不受影响。
MSW 优势:更接近真实行为、handlers 可在开发和测试间复用、支持 REST 和 GraphQL、Request/Response 使用标准 Web API。
直接 Mock fetch 优势:零依赖、配置简单、适合简单场景。
选择策略:项目有多个 API 端点、使用多种 HTTP 客户端、需要在开发环境也使用 Mock → MSW;只是偶尔测一个 fetch 调用 → 直接 Mock。
4. 什么是"过度 Mock"?如何判断 Mock 是否过度? ⭐⭐
回答思路:
过度 Mock 指 Mock 了太多依赖,导致测试只验证了 Mock 之间的交互,而不是真正的业务逻辑。
判断标准:
- 测试文件中
vi.mock()超过 3-5 个 - 修改内部实现(不改接口)导致测试大面积失败
- 所有测试通过但线上有 bug
- 测试只断言"函数被调用了"而不验证结果
解决方案:
- 只 Mock 外部边界(网络、存储、定时器)
- 不 Mock 内部模块之间的调用
- 多写集成测试,少写纯 Mock 单测
- 优先验证输出结果,而非调用过程
5. 如何测试使用了 setTimeout/setInterval 的代码? ⭐⭐
回答思路:
使用 vi.useFakeTimers() 接管定时器,然后通过 API 控制时间流逝:
vi.advanceTimersByTime(ms)— 前进指定毫秒vi.advanceTimersToNextTimer()— 前进到下一个定时器触发vi.runAllTimers()— 执行所有定时器vi.runOnlyPendingTimers()— 只执行当前挂起的定时器(避免无限递归)
关键注意点:
beforeEach启用、afterEach恢复,避免影响其他测试- 对于 debounce/throttle 这种嵌套定时器,用
advanceTimersByTime更精确 vi.setSystemTime()可以控制Date.now()的返回值
6. 部分 Mock 是什么?什么场景下需要用? ⭐⭐
回答思路:
部分 Mock 指只 Mock 模块中的部分导出,其余保持真实实现。
typescript
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils')
return { ...actual, fetchData: vi.fn() }
})典型场景:
- 模块既有纯函数(如
formatDate)又有副作用函数(如fetchData) - 纯函数不需要 Mock,副作用函数需要 Mock
- 你想测试模块内部函数之间的协作,但需要隔离外部 IO
7. vi.fn() 的 mockClear、mockReset、mockRestore 三者有什么区别? ⭐⭐
回答思路:
这三个方法是递进关系:
mockClear():只清除调用记录(mock.calls、mock.results),保留 Mock 实现mockReset():清除调用记录 + 清除 Mock 实现(回到返回undefined的状态)mockRestore():清除调用记录 + 清除 Mock 实现 + 恢复原始实现(仅对vi.spyOn有意义)
常用配置:在 vitest.config.ts 中设置 mockReset: true 或 restoreMocks: true,让每个测试自动重置,避免测试间互相影响。
8. 如何保证 Mock 数据和真实 API 返回的数据结构一致? ⭐⭐⭐
回答思路:
这是 Mock 最大的痛点之一——Mock 与真实 API 不同步。
解决方案:
- TypeScript 类型约束:为 API 响应定义严格类型,Mock 数据必须满足类型定义
- 契约测试(Contract Testing):使用 Pact 等工具,前后端共享 API 契约
- OpenAPI/Swagger Schema:从 API Schema 自动生成 Mock 数据和 TypeScript 类型
- Mock 数据工厂 + 类型检查:工厂函数返回类型化的数据,编译时发现不一致
- 少量 E2E 测试:用真实 API 跑端到端测试,作为最后一道防线
核心思想:类型是第一道防线,契约测试是第二道防线,E2E 是最后一道防线。三者结合才能确保 Mock 和真实环境的一致性。
七、延伸阅读
- Vitest 官方文档 - Mocking — Vitest Mock 系统完整参考
- MSW 官方文档 — Mock Service Worker 详细教程
- xUnit Test Patterns — Gerard Meszaros 的测试替身分类经典
- Testing Library 指南 — 以用户视角编写测试
- Kent C. Dodds - Stop Mocking Fetch — 为什么推荐 MSW 而非直接 Mock fetch
- Martin Fowler - Mocks Aren't Stubs — 经典文章,区分 state verification 和 behavior verification
- @faker-js/faker — 强大的测试数据生成库
- Vitest 官方文档 - Timer Mocks — 定时器 Mock 完整 API