Skip to content

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.ts
typescript
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-dev
typescript
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 的网络行为,但 formatDatevalidateEmail 等纯函数没有必要 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 了 formatDateuserService,但 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 通过 ≠ 真实通过)

减少维护成本的策略

  1. 集中管理 Mock:将常用 Mock 抽取到共享 fixtures 或 setup 文件
  2. 使用 MSW:网络层 Mock 与实现细节解耦
  3. Mock 数据工厂:使用工厂函数生成测试数据,而不是硬编码
  4. 定期审查 Mock:确保 Mock 行为与真实实现保持一致

何时使用 Mock vs 真实依赖

场景推荐方案原因
纯函数真实依赖无副作用,直接测试更可靠
HTTP 请求MSW Mock网络不可控,需要隔离
数据库操作Fake / Docker 容器复杂交互,Fake 更真实
第三方 SDKMock外部依赖不可控
定时器Fake Timers无法等待真实时间
localStorageMockNode 环境不存在
内部工具函数真实依赖不应 Mock 内部实现
随机数 / DateMock需要确定性结果
Console / LoggerSpy只需验证是否调用

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-dev
typescript
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.callsmock.results),保留 Mock 实现
  • mockReset():清除调用记录 + 清除 Mock 实现(回到返回 undefined 的状态)
  • mockRestore():清除调用记录 + 清除 Mock 实现 + 恢复原始实现(仅对 vi.spyOn 有意义)

常用配置:在 vitest.config.ts 中设置 mockReset: truerestoreMocks: true,让每个测试自动重置,避免测试间互相影响。

8. 如何保证 Mock 数据和真实 API 返回的数据结构一致? ⭐⭐⭐

回答思路

这是 Mock 最大的痛点之一——Mock 与真实 API 不同步。

解决方案:

  1. TypeScript 类型约束:为 API 响应定义严格类型,Mock 数据必须满足类型定义
  2. 契约测试(Contract Testing):使用 Pact 等工具,前后端共享 API 契约
  3. OpenAPI/Swagger Schema:从 API Schema 自动生成 Mock 数据和 TypeScript 类型
  4. Mock 数据工厂 + 类型检查:工厂函数返回类型化的数据,编译时发现不一致
  5. 少量 E2E 测试:用真实 API 跑端到端测试,作为最后一道防线

核心思想:类型是第一道防线,契约测试是第二道防线,E2E 是最后一道防线。三者结合才能确保 Mock 和真实环境的一致性。


七、延伸阅读

用心学习,用代码说话 💻