Vitest 的 Mock 系统深度指南:vi.mock、vi.spyOn 与 MSW 的配合使用

围绕 Vitest 的 Mock 体系,详解 vi.mock、vi.spyOn 与 MSW 三种模拟方式的作用层次和配合场景,帮助你在单元测试与前端集成测试中选择合适的 mock 策略,避开 hoisting、只读导出等常见坑点。

很多团队从 Jest 迁移到 Vitest 后,第一感觉是快,但写 mock 的时候却容易卡壳。明明照着 Jest 的语法写 vi.mock(),结果变量引用报错;换个场景用 vi.spyOn(),又发现 mock 没生效。这种困惑并不来自 Vitest 本身,而是因为我们在不同测试层级里搞混了 mock 的对象。本文想把 vi.mockvi.spyOn 和 MSW 三条 mock 路线放在一起,理清各自的边界和配合方式。

AI technology illustration

简单说,vi.mock 解决的是“模块依赖怎么替换”,vi.spyOn 解决的是“对象的某个方法怎么观测和替换”,MSW 解决的是“网络请求怎么模拟”。它们服务的目标不同,但在实际测试中可以组合使用,也可以各自独立。搞清楚这三者的区别,很多 mock 难题会迎刃而解。

vi.mock:站在模块加载层的确定性

vi.mock 的工作位置在模块加载层。当被测代码通过 importrequire 引入一个模块时,Vitest 会拦截这个模块的加载,用你提供的工厂函数生成的模块替换掉真实模块。举个例子:

vi.mock('@/api/user', () => ({
  fetchUser: vi.fn(() => Promise.resolve({ name: 'Alice' }))
}))

当这一行执行后,被测模块里所有对 @/api/user 的引用都会拿到这个 mock 出来的对象。fetchUser 被替换成一个 mock 函数,返回固定数据。这样测试既不会触网,也不会因为用户详情模块的异常而倒掉。

但这里有一个 Vitest 特有的陷阱:vi.mock 会被提升到文件顶部执行,所以你在工厂函数里引用外部变量时,变量可能还没初始化。Vitest 为此提供了 vi.hoisted。看下面这个错误写法:

const mockName = 'Alice'
vi.mock('@/api/user', () => ({
  fetchUser: () => Promise.resolve({ name: mockName })
}))

运行时通常会报错说 mockName is not defined。原因就是 vi.mock 先于 const mockName 执行。正确做法是用 vi.hoisted 把变量提升到 mock 之前:

const mockName = vi.hoisted(() => 'Alice')
vi.mock('@/api/user', () => ({
  fetchUser: () => Promise.resolve({ name: mockName })
}))

很多人从 Jest 迁移过来第一次遇到这个问题,就开始觉得 Vitest 行为怪异。其实回到原理上,Vite 处理动态 import 时也会进行类似的提升,vi.hoisted 只是把这个机制显式地暴露出来。理解了这一点,就不会再踩坑。

除了直接返回一个全新的 mock 对象,vi.mock 也可以先加载真实模块,然后替换你关心的导出。比如:

vi.mock('@/api/user', async () => {
  const actual = await vi.importActual('@/api/user')
  return {
    ...actual,
    fetchUser: vi.fn()
  }
})

这种方式保留了真实模块的其他能力,只把需要用到的函数替换掉,在部分 mock 时很有用。但注意 vi.importActual 也是异步的,你需要处理 await 层级。

这里还要提一个工程上的建议:别把业务 API client 当成常规依赖随手 mock。很多团队习惯把所有 API 模块用 vi.mock 全部替换,起初很省事,可一旦服务端返回结构变化,你得去所有测试文件里修改 mock 数据。这种维护成本比想象中高得多。vi.mock 更适合用在那些“我们并不关心它的细节”的第三方依赖上,比如 SDK、工具函数,而不是你自己维护的数据层。

vi.spyOn:更细粒度的观测与替换

如果说 vi.mock 是从“整个模块”下手,那 vi.spyOn 就是从“现有对象的某个属性”下手。它不会替换模块,而是在原对象的方法上包装一层 spy,记录调用参数、次数,并可以临时改变实现。典型用法:

const spy = vi.spyOn(authService, 'login')
spy.mockImplementation(async (name, pwd) => ({ token: 'mock-token' }))

expect(spy).toHaveBeenCalledWith('user', '123')

这看似简单,但有两个容易误用的地方。第一,vi.spyOn 要求目标对象上的属性是可写的。对于 ES Module 的命名导出,比如 export const login = ...,直接 vi.spyOn(authService, 'login') 会抛错,因为这种导出在模块 namespace 上是只读的。解决办法是先 import * as authService,再在 namespace 对象上 spy。虽然本质上依然是模拟函数,但至少能工作。

第二,vi.spyOn 默认保留原实现,只有你调用 mockImplementationmockReturnValue 后才会替换。如果你只是想知道某个方法是否被调用,而调用的代价是发网络请求,那必须显式 mockImplementation 或者采用 MSW 来拦截。很多测试看似通过了,实际上网络请求并没有被拦住,反而在测试环境里留下了脏数据。

一个实用的习惯是,使用完 spy 后要 mockRestore() 还原原实现,避免快照污染。对于 mockReset()mockRestore() 的区别,很多老手也容易搞混。简单记住:mockReset 清空实现和调用记录,mockRestore 把 spy 恢复成原始未包装状态。

另外,如果你测试的是一个类实例的方法,vi.spyOn 也可以直接使用。但记住,它包装的是当前这个对象,如果被测代码创建的是新实例,你 mock 的方法不会出现在新实例上。这种隐性假设会让测试时好时坏,需要格外注意。

MSW:把网络请求变成可控的后端

MSW(Mock Service Worker)是另一个层级——网络请求层。它不在模块系统里做任何替换,而是直接拦截浏览器或 Node 环境中的 fetchXMLHttpRequest。你在测试里定义 URL 转发规则,MSW 会返回你指定的响应。测试代码不用关心业务模块用了哪个 HTTP 客户端,因为最终都会落到网络协议层。

典型的 setup 如下:

import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

const server = setupServer(
  http.get('/api/user/me', () => HttpResponse.json({ name: 'Alice' }))
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

这段代码在 Node 环境下启动了一个 mock server,所有对 /api/user/me 的 GET 请求都会命中并返回 { name: 'Alice' }。结合 @testing-library/react,你可以让组件真实地触发加载、成功、失败等状态,而不用改写组件内部的任何调用逻辑。

vi.mock 相比,MSW 的收益在集成测试里特别明显。当你的测试范围足够大,比如多个组件共享一个数据请求层时,用 vi.mock 得 mock 所有相关的 client 模块,维护成本高;MSW 只需要维护一份路由表,而且这份路由表还可以在 storybook 或者开发环境复用。

MSW 在 Node 环境里并不是真的启动一个 service worker,而是通过拦截 fetch 和 XHR 来实现同样的效果。因此,它受运行环境的影响比 vi.mock 小,但如果你依赖一些浏览器特有的 API,比如 EventSource 或者 WebSocket,就需要额外处理。好在绝大多数 HTTP 场景是够用的。

MSW 也支持按测试覆盖 handler,比如测试错误态时:

server.use(
  http.get('/api/user/me', () => HttpResponse.json({ error: 'unauthorized' }, { status: 401 }))
)

这种覆盖方式比在组件里传入一个 mock 函数要自然得多。

三种 mock 方式如何配合?

既然三种方式作用在不同层次,它们之间并不是互斥关系。在一个真实项目的测试文件里,它们经常会同时出现。我见过最典型的一个场景是登录后获取用户信息的页面:组件在 mount 时调用 authService.getCurrentUser(),拿到用户 ID 后,再通过 fetch 请求获取用户详情。

如果用纯 vi.mockauthService 整个 mock 掉,组件里“先调用 getCurrentUser 再凭结果发请求”的流程就被完全切断了,测试只能验证渲染结果,无法证明“确实拿到了 ID 并用了它”。如果用 MSW 两次请求都 mock,虽然能模拟完整链路,但你无法便捷地断言 getCurrentUser 的入参。

这时候组合一下:

import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import UserProfile from './UserProfile'
import * as authService from '@/api/auth'

const server = setupServer(
  http.get('/api/user/me', () => HttpResponse.json({ name: 'Alice' }))
)

beforeAll(() => server.listen())
afterEach(() => {
  server.resetHandlers()
  vi.restoreAllMocks()
})
afterAll(() => server.close())

describe('UserProfile', () => {
  it('should render user name after fetching profile', async () => {
    const spy = vi.spyOn(authService, 'getCurrentUser')
      .mockResolvedValue({ id: 1 })

    render(<UserProfile />)

    expect(spy).toHaveBeenCalled()
    await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument())
  })

  it('should show error when request fails', async () => {
    server.use(
      http.get('/api/user/me', () => HttpResponse.json({ error: 'unauthorized' }, { status: 401 }))
    )

    render(<UserProfile />)

    await waitFor(() => expect(screen.getByText('unauthorized')).toBeInTheDocument())
  })
})

在这个例子里,vi.spyOn 保证了第一个依赖的确定性,MSW 保证了第二个依赖的确定性。两者合在一起,测试既真实又可控。注意 vi.restoreAllMocks() 会在每个用例后把 spy 恢复,避免用例间互相污染。

再举一个纯粹的单元测试场景:你写了一个计算函数,它依赖一个工具模块 utils/logger。你只想测计算逻辑,不关心 logger 能不能打印。这时直接用 vi.mock('@/utils/logger', () => ({ info: vi.fn() })) 就好,不需要 MSW。如果你还想验证 logger.info 的确切参数,可以在工厂函数里返回一个 vi.fn(),再在测试中 import 真实模块获取 mock 对象。不过更简单的做法是先用 vi.spyOn,如果 logger 是单例对象的话。

所以,配合的原则可以归纳为:一层测试一层 mock 边界。先想清楚被测单元的边界在哪里,再去决定用哪种工具。

常见误区与小陷阱

下面是我们在实际迁移过程中反复遇到的一些问题,也是容易让新手困惑的地方:

  • 误区:用 vi.mock 后,拿不到 mock 函数来断言调用。 通常是因为工厂函数里没有把 vi.fn() 存到外部变量,而外部变量在 hoisting 下又不可用。解法是用 vi.hoisted 或者把 mock 函数导入到模块内部再访问。
  • 误区:vi.spyOn 对 ESM 的命名导出失效。 如上文所说,命名导出在 namespace 上是只读的。要么改成对象导出,要么使用 namespace import 再 spy。
  • 误区:同时使用 vi.mock 和 MSW 导致行为异常。 比如你 mock 掉了 API client,但 MSW 也拦了同样的请求,结果 client 返回的数据是 mock 的,MSW 的 handler 永远不触发。需要问自己:这一层到底要不要走真实逻辑?
  • 误区:MSW 的 server 没有 reset,导致用例之间响应互相污染。 务必在 afterEach 里调用 server.resetHandlers(),否则后一个测试会拿到前一个测试设置的特殊响应。

如果你在这几个地方多留个心眼,Vitest 的 mock 体验会顺畅很多。

三种 mock 方式对比

用一个表格来总结它们的特点,方便在写测试前快速选择:

方式 作用层次 适用场景 主要注意点
vi.mock 模块加载层 单元测试中隔离依赖模块 hoisting 陷阱,需要 vi.hoisted 传外部变量
vi.spyOn 对象方法层 观测/替换某个对象的方法 只读导出不能直接 spy,需要 namespace import
MSW 网络请求层 组件/集成测试模拟 HTTP 响应 需要正确启停 server,区分 Node 与浏览器环境

表格只给了大方向。具体到代码里,你还会遇到 mock 顺序、mock 清理、异步竞态等问题,这些都需要实际踩坑才能积累起肌肉记忆。

落地建议:从单元测试到集成测试的 mock 策略

给正在搭建测试体系的团队几个参考建议:

  1. 单元测试优先用 vi.spyOn,少用 vi.mock。 spyOn 的作用面小,不影响模块内部其他导出;改动范围越小,测试越可控。
  2. 组件测试优先用 MSW。 组件真正的行为是渲染和交互,网络请求应该被当成外部条件。MSW 能让你轻松模拟成功、失败、超时等状态。
  3. 遇到第三方 SDK 时用 vi.mock 做整体替换。 比如支付 SDK、分析 SDK,不用关心它的内部实现,直接 mock 掉更省事。
  4. 不要把三种方式堆在同一个文件里。 如果同一个测试需要同时用三种,说明它可能已经超出单元测试的范畴,更适合放到集成测试层去。

如果团队刚刚开始用 Vitest,我建议先从最小的工具链开始:vi.spyOn + vi.fn 解决大多数单测,MSW 慢慢引入到组件测试中。等测试多了,再逐步统一 request handler 的编写规范。这样演进比一开始就上一个复杂的 mock 体系要平滑很多。

实践中,从零搭建 Vitest 测试体系时,可以先用 MSW,因为它的收益最快。然后用 vi.spyOn 替换那些函数级 mock。最后才考虑 vi.mock 做模块级隔离。这个顺序比反过来要平滑,因为前两者更接近真实行为。

回到开头的问题:为什么 Vitest 的 mock 会让人困惑?因为前端测试的边界正在变宽,从函数到组件再到完整页面流程,单一 mock 工具已经不够了。理解 vi.mockvi.spyOn 和 MSW 各自的位置,你就能在正确的地方做正确的事。

测试不是把代码覆盖全就结束了,mock 也不是越强越好。你有你的业务模块结构,这些工具只是帮助你稳定地确认行为。希望这篇文章能帮你在 Vitest 项目中建立起一套清晰的 mock 心智模型。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/728/

(0)
上一篇 32分钟前
下一篇 25分钟前

相关推荐