ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vitest vi Mocking 全解:从函数打桩到模块替换,结合 Supabase Monorepo 实战案例

Vitest vi Mocking 全解:从函数打桩到模块替换,结合 Supabase Monorepo 实战案例 Vitest vi Mocking 全解从函数打桩到模块替换结合 Supabase Monorepo 实战案例【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase本文以 Vitest 的vi系列 Mock API 为主线系统讲解vi.fn、vi.spyOn、vi.mock、vi.doMock、Fake Timers、vi.stubGlobal/vi.stubEnv等核心能力的用法、陷阱与底层行为约定并结合 Supabase monorepo 中apps/studio、apps/docs的真实测试文件展示这些 API 在一个大型前端项目中如何被组合使用。读完本文你可以掌握在任意 TypeScript 项目中编写可重复、无污染的单测所需的完整 Mock 技术栈并理解 Supabase 团队实际的 Mock 组织方式如__mocks__目录约定、全局 setup 中的模块替换。一、Mock 函数vi.fn() 与返回值控制一切 Mock 的起点是vi.fn()——创建一个带调用记录的函数替身import { expect, vi } from vitest // 创建 mock 函数 const fn vi.fn() fn(hello) expect(fn).toHaveBeenCalled() expect(fn).toHaveBeenCalledWith(hello) // 带实现 const add vi.fn((a, b) a b) expect(add(1, 2)).toBe(3)vi.fn()的价值在于它把“被调用了没、用什么参数调用、返回了什么”变成了可断言的数据。返回值控制分为同步/异步、一次性/持续性两组 API// Mock 返回值 fn.mockReturnValue(42) // 持续返回 42 fn.mockReturnValueOnce(1).mockReturnValueOnce(2) // 仅前两次调用生效 fn.mockResolvedValue({ data: true }) // 持续 resolve 该值 fn.mockRejectedValue(new Error(fail)) // 持续 reject // Mock 实现 fn.mockImplementation((x) x * 2) // 持续使用新实现 fn.mockImplementationOnce(() first call) // 仅第一次调用生效mock*Once系列在测试“重试逻辑”“首次/后续行为不同”的场景时尤其有用mockImplementation则用于需要保留参数依赖关系的情况如上面x * 2。二、Spy在真实对象上打桩vi.spyOn针对“已有对象上的方法”而非独立函数。它在保留对象引用的前提下替换方法行为测试结束后可恢复const cart { getTotal: () 100, } const spy vi.spyOn(cart, getTotal) cart.getTotal() expect(spy).toHaveBeenCalled() // 替换实现 spy.mockReturnValue(200) expect(cart.getTotal()).toBe(200) // 恢复原始实现 spy.mockRestore()与vi.fn()的关键区别spy 必须在对象引用仍可达时才能mockRestore()回原实现而纯 mock 函数没有“原始实现”这一概念。三、模块 Mockvi.mock 与自动提升Hoistingvi.mock用于替换整个模块的导出。最核心的规则是vi.mock会被 Vitest 自动提升到文件顶部先于任何import执行——这是它能拦截模块解析的原因// vi.mock 被提升到文件顶部 vi.mock(./api, () ({ fetchUser: vi.fn(() ({ id: 1, name: Mock })), })) import { fetchUser } from ./api test(mocked module, () { expect(fetchUser()).toEqual({ id: 1, name: Mock }) })3.1 部分 MockPartial Mock多数情况下不想 mock 整个模块只替换其中一两个导出。官方模式是用importOriginal取回真实模块再合并vi.mock(./utils, async (importOriginal) { const actual await importOriginal() return { ...actual, specificFunction: vi.fn(), } })Supabase 仓库全局测试 setup 中就是这种模式vitestSetup.ts 里对next/navigation的 mock 只替换useRouter/usePathname/useSearchParams三个导航 hook其余导出全部通过vi.importActual(next/navigation)展开保留原样——这正是 Partial Mock 在一个 2600 组件的 Studio 应用中避免“mock 一个模块、破坏所有其他导出”的标准写法vi.mock(next/navigation, async () { const actual await vi.importActual(next/navigation) return { ...actual, useRouter: () ({ push: vi.fn(), replace: vi.fn() }), usePathname: () vi.fn(), useSearchParams: () ({ get: vi.fn() }), } })3.2{ spy: true }保留实现但追踪调用// 保留真实实现但让导出变成 spy vi.mock(./calculator, { spy: true }) import { add } from ./calculator test(spy on module, () { const result add(1, 2) // 真实实现 expect(result).toBe(3) expect(add).toHaveBeenCalledWith(1, 2) })这个选项适合“行为要真的跑但我想断言它被谁以什么参数调用过”的灰盒测试。3.3 手动 Mocksmocks目录约定当调用vi.mock(path)不传工厂函数时Vitest 会去对应的__mocks__目录查找同名手动 mock 文件src/ __mocks__/ axios.ts # Mock axios api/ __mocks__/ client.ts # Mock ./client client.ts// 不传工厂自动使用 __mocks__ 下的实现 vi.mock(axios) vi.mock(./api/client)Supabase 的 Studio 应用正是这样组织 Hook 级 Mock 的mocks目录 下按hooks/analytics/结构存放useLogsQuery.ts、useFillTimeseriesSorted.ts等文件。以 useLogsQuery.ts 为例整个手动 mock 只有一行核心逻辑——用vi.fn().mockReturnValue(...)固定返回一个空日志数据的形状让依赖该 hook 的组件测试完全脱离后端查询import { vi } from vitest const useLogsQuery vi.fn().mockReturnValue({ logData: [], params: { iso_timestamp_start: }, }) export default useLogsQuery这个目录结构__mocks__/hooks/analytics/镜像hooks/analytics/就是上一节“相对路径 mock”约定的实际落地。四、动态 Mockvi.doMock 与 vi.doUnmockvi.mock的自动提升带来一个副作用它对整个文件生效无法在单个测试内“临时切换 mock 内容”。vi.doMock不提升注册时机完全由你控制因此专为await import()动态加载设计。Supabase 的 getCustomContent.test.ts 是绝佳范例——同一个测试文件里两个用例分别 doMock 出不同的custom-content.json内容再动态导入被测模块it(should return null if content is not found, async () { vi.doMock(./custom-content.json, () ({ default: { navigation:logo: null }, })) const { getCustomContent } await import(./getCustomContent) const result getCustomContent([navigation:logo]) expect(result.navigationLogo).toEqual(null) })完整的官方模式含解除 mocktest(dynamic mock, async () { vi.doMock(./config, () ({ apiUrl: http://test.local, })) const { apiUrl } await import(./config) expect(apiUrl).toBe(http://test.local) vi.doUnmock(./config) })注意该文件中还有一个配套习惯beforeEach里调用vi.clearAllMocks()与vi.resetModules()。vi.resetModules()清空模块注册表保证下一次await import()拿到的是“重新解析”后的模块——这是 doMock 多用例交替生效的前提。五、Fake Timers 与系统时间测试“时间的流逝”定时器与时间是单测中最难复现的变量。vi.useFakeTimers()接管setTimeout等 API使时间推进完全可控import { afterEach, beforeEach, vi } from vitest beforeEach(() { vi.useFakeTimers() }) afterEach(() { vi.useRealTimers() }) test(timers, () { const fn vi.fn() setTimeout(fn, 1000) expect(fn).not.toHaveBeenCalled() // 虚拟时间还没到回调未触发 vi.advanceTimersByTime(1000) expect(fn).toHaveBeenCalled() }) // 其他推进方式 vi.runAllTimers() // 执行所有 pending 定时器 vi.runOnlyPendingTimers() // 只跑当前已注册的不追连锁回调 vi.advanceTimersToNextTimer() // 推进到下一个定时器beforeEach/afterEach成对出现是硬性纪律假计时器若泄漏到其他测试会引发难以定位的连锁失败。5.1 异步定时器推进当定时器回调里又产生微任务/Promise 时同步推进可能观察不到最终状态应使用异步版本test(async timers, async () { vi.useFakeTimers() let resolved false setTimeout(() Promise.resolve().then(() { resolved true }), 100) await vi.advanceTimersByTimeAsync(100) expect(resolved).toBe(true) })5.2 实战用假时钟测试 API Key 有效期Supabase Studio 的 temp-api-keys-utils.test.ts 展示了 Fake Timers vi.setSystemTime()的组合拳。被测函数createTemporaryApiKey(apiKey, expiryInSeconds)会基于“当前时间”计算过期时刻测试通过固定系统时间来断言毫秒级精确的过期值const now Date.now() vi.setSystemTime(now) const result createTemporaryApiKey(test-api-key-123, 3600) expect(result.expiryTimeMs).toBe(now 3600 * 1000)同一文件中还有边界场景——“key 剩余恰好 30 秒应判定为失效”const key: TemporaryApiKey { apiKey: test-key, expiryTimeMs: now 30000, // 恰好 30 秒 } expect(isTemporaryApiKeyValid(key)).toBe(false)没有vi.setSystemTime这类“差一秒即翻转”的边界条件根本无法稳定断言。文件中还有vi.advanceTimersByTime(89000)之类的推进调用用于让“key 从创建到临近过期”的整条时间线在瞬间完成。六、Stub 全局变量与环境变量浏览器/Node 内置的全局 API如fetch与import.meta.env环境变量可以用vi.stubGlobal/vi.stubEnv精确替换且支持细粒度恢复vi.stubGlobal(fetch, vi.fn(() Promise.resolve({ json: () ({ data: mock }) }) )) vi.unstubAllGlobals() // 恢复所有被 stub 的全局 vi.stubEnv(API_KEY, test-key) expect(import.meta.env.API_KEY).toBe(test-key) vi.unstubAllEnvs() // 恢复所有环境变量Studio 的 logs-sql-rewrite.test.ts 里就能看到vi.stubGlobal(fetch, fetchMock)的成功路径与失败路径mockResolvedValue({ ok: false, text: async () boom })各用一个用例覆盖route.test.ts 则在文件级 stub 全局 fetchoctokit.auth.test.ts 使用vi.stubEnv(name, env[name] ?? )为每个密钥变量注入空值模拟“未配置凭据”的环境。七、清理 MockmockClear / mockReset / mockRestore单实例方法三者的语义差异必须精确掌握const fn vi.fn() fn() fn.mockClear() // 只清空调用历史实现保留 fn.mockReset() // 清空历史 移除实现含 mockImplementation fn.mockRestore() // 恢复原始实现仅对 spyOn 有效 // 全局版本 vi.clearAllMocks() vi.resetAllMocks() vi.restoreAllMocks()mockReset的“连实现一起清”经常是新手踩坑点如果你在测试里mockResolvedValue后又在beforeEach里mockReset后续用例拿到的就是无实现函数。7.1 通过配置实现自动清理手工清理易遗漏更稳妥的方式是让 Vitest 配置层兜底。在 vitest.config.ts 等配置文件defineConfig({ test: {...} })中// vitest.config.ts defineConfig({ test: { clearMocks: true, // 每个测试前清空调用历史 mockReset: true, // 每个测试前重置含实现 restoreMocks: true, // 每个测试后恢复 spy 原始实现 unstubEnvs: true, // 恢复被 stub 的环境变量 unstubGlobals: true, // 恢复被 stub 的全局变量 }, })五个开关覆盖了前文所有“手动恢复”场景useRealTimers由 runner 自动处理不在其中。开启后单测代码里的vi.useRealTimers()等收尾动作可以视情况省略——但 Studio 等成熟项目仍习惯在beforeEach/afterEach里显式配对因为显式代码对新读者自解释。八、vi.hoisted在 mock 工厂里引用外部变量vi.mock被提升后工厂函数执行时外层的const还没初始化直接在工厂里引用会触发暂时性死区错误。vi.hoisted的解法是把变量声明本身也提升上去const mockFn vi.hoisted(() vi.fn()) vi.mock(./module, () ({ getData: mockFn, })) import { getData } from ./module test(hoisted mock, () { mockFn.mockReturnValue(test) expect(getData()).toBe(test) })Supabase 的 AiSkills.utils.test.ts 是该模式的生产级范例——mock 掉node:fs/promises的readFile让被测函数不真正读盘const { readFileMock } vi.hoisted(() ({ readFileMock: vi.fn(), })) vi.mock(node:fs/promises, () ({ readFile: readFileMock, })) // 各用例中 readFileMock.mockResolvedValue(JSON.stringify(skills)) // 成功路径 readFileMock.mockRejectedValue(new Error(ENOENT)) // 错误透传 // beforeEach 中 readFileMock.mockReset() 防止用例间串味这里vi.hoistedvi.mock工厂 beforeEach里的mockReset()三者配合正是“模块级 mock 引用共享变量并保证用例隔离”的完整闭环。九、要点速查vi.mock自动提升调用时机早于任何import——不要试图“在 import 之后 mock”动态、非提升场景配合await import()vi.resetModules()使用vi.doMock/vi.doUnmock永远恢复 mockmockRestore/vi.restoreAllMocks/unstubAllEnvs/unstubAllGlobals或用restoreMocks/unstubEnvs/unstubGlobals配置兜底避免测试污染{ spy: true }保留真实实现但追踪调用适合灰盒断言vi.hoisted让你在提升后的 mock 工厂里安全引用外层变量__mocks__目录 无参vi.mock(path)是复用性手动 mock 的官方约定Supabase 在 apps/studio/mocks中以此组织 hook 级替身时间相关断言优先组合vi.useFakeTimers()vi.setSystemTime()vi.advanceTimersByTime()把“过期”“到期前一秒”这类边界变成可精确复现的确定性输入。参考文件mocking 特性文档、Studio Vitest 配置、docs Vitest 配置、全局 setup 与模块 mock。【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表