ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

LobeHub 中 Zustand Store Action 测试实战:Vitest、renderHook 与 zustand/traditional 全局 Mock 机制

LobeHub 中 Zustand Store Action 测试实战:Vitest、renderHook 与 zustand/traditional 全局 Mock 机制 LobeHub 中 Zustand Store Action 测试实战Vitest、renderHook 与 zustand/traditional 全局 Mock 机制【免费下载链接】lobehub LobeHub is your Chief Agent Operator, organizing your agents into 7×24 operations by hiring, scheduling, and reporting on your entire AI team.项目地址: https://gitcode.com/GitHub_Trending/lo/lobehub本文以 LobeHublobehub仓库内的 Zustand store action 测试规范 .agents/skills/testing/references/zustand-store-action-test.md 为主体结合仓库中的全局 mock 实现mocks/zustand/traditional.ts、测试脚手架 tests/setup.ts 与真实 store 源码系统讲解如何为大型前端应用中的 Zustand 异步 action发消息、流式响应、SWR 数据获取等编写高隔离性、低耦合的单元测试读完后你可以直接照此模式为任意 store action 编写可维护的测试。背景LobeHub 的 Store 是怎么组织的理解测试写法之前先看被测对象的结构。LobeHub 的聊天 store 位于 src/store/chat/store.ts它把多个 slice 的 action 聚合成一个统一的 store// src/store/chat/store.ts节选 export const useChatStore createWithEqualityFnChatStore()( subscribeWithSelector(devtools(createStore)), shallow, );几个关键事实来自该文件store 通过zustand/traditional的createWithEqualityFn创建并传入shallow作为默认比较函数action 按 slice 拆分chatMessage、ChatThreadActionImpl、chatAgentRun、chatPlugin等再经flattenActions拍平合并每个 store 还带有ResetableStore能力如ChatStoreResetAction在 reset 前执行disposeVoiceMessages这类清理钩子store 数量庞大src/store下 agent、session、task、file、discover、global 等数十个目录都通过createWithEqualityFn创建 store。这意味着测试必须解决三个工程问题测试间状态泄漏全局单例 store、异步 action 的断言时序、外部服务消息、聊天流、文件上传如何隔离。规范文档给出的正是针对这三点的系统性方案。全局 zustand/traditional Mock让每个测试自动回到初始状态规范文档基础结构里的这一行是整个机制的核心vi.mock(zustand/traditional);它不带工厂函数Vitest 会按约定解析到仓库根目录的mocks/zustand/traditional.ts。该 mock 的完整实现如下import { act } from react-dom/test-utils; import { beforeEach } from vitest; import { createWithEqualityFn as actualCreate } from zustand/traditional; // a variable to hold reset functions for all stores declared in the app const storeResetFns new Set() void(); // when creating a store, we get its initial state, create a reset function and add it in the set const createImpl (createState: any) { const store actualCreate(createState, Object.is); const initialState store.getState(); storeResetFns.add(() store.setState(initialState, true)); return store; }; // Reset all stores after each test run beforeEach(() { act(() { for (const resetFn of storeResetFns) { resetFn(); } }); }); export const createWithEqualityFn (f: any) (f undefined ? createImpl : createImpl(f));其工作原理可以从源码结构拆解为三步拦截创建用createImpl替代真实createWithEqualityFn。每当某个模块调用createWithEqualityFn()(createFn, shallow)创建 store 时mock 记录store.getState()的初始快照并注册一个 reset 函数调用store.setState(initialState, true)把状态整体替换回去统一复位mock 文件内部注册了一个全局beforeEach在每个测试执行前遍历storeResetFns在act()内把所有已创建 store 复位到初始状态透明透传返回值与真实 API 完全一致业务代码和测试代码无需感知自己被 mock。值得注意的是 tests/setup.ts 第 88–90 行// Route zustand store creation through __mocks__/zustand/traditional.ts so every // store resets to its initial state between tests without per-file opt-in vi.mock(zustand/traditional);也就是说该 mock 是通过 vitest.config.mts 中setupFiles指向的全局 setup 文件安装的每个测试文件都自动获得 store 复位能力无需逐文件选择加入。这解释了为什么规范文档的“Basic Structure”里beforeEach仍会显式setState特定字段——那是为了构造特定测试夹具例如固定activeId而不是因为 mock 未生效。此外 vitest.config.mts 将__mocks__/**排除在覆盖率统计之外coverage.exclude保证 mock 代码不会污染覆盖率数据。仓库中真实使用此模式的测试可见 src/store/tool/slices/mcpStore/action.test.ts其开头即为vi.mock(zustand/traditional)。基础测试文件结构规范文档给出的标准骨架如下import 路径按真实 store 位置调整import { act, renderHook } from testing-library/react; import { beforeEach, describe, expect, it, vi } from vitest; import { useChatStore } from ../../store; vi.mock(zustand/traditional); beforeEach(() { vi.clearAllMocks(); useChatStore.setState( { activeId: test-session-id, messagesMap: {}, loadingIds: [], }, false, ); vi.spyOn(messageService, createMessage).mockResolvedValue(new-message-id); act(() { useChatStore.setState({ refreshMessages: vi.fn(), internal_coreProcessMessage: vi.fn(), }); }); }); afterEach(() { vi.restoreAllMocks(); });各部分的职责与设计意图代码片段职责说明vi.mock(zustand/traditional)启用 store 自动复位命中mocks/zustand/traditional.tsvi.clearAllMocks()清空调用记录保留 spy 但不累计断言计数避免用例间相互污染useChatStore.setState({...}, false)构造测试夹具第二个参数false表示合并replace false只覆盖关心的字段保留其余初始状态vi.spyOn(messageService, createMessage)mock 直接外部依赖让 action 走到真实分支但不出网act(() useChatStore.setState({refreshMessages: vi.fn(), ...}))把 store 内部回调替换为可控 stubsetState触发订阅者必须包裹在act()内避免 React 告警afterEach中vi.restoreAllMocks()恢复真实实现与全局 store 复位配合保证“双清理”这里体现了一个重要模式通过setState直接向 store 注入vi.fn()来替换 action 之间的协作边界如refreshMessages、internal_coreProcessMessage而不是深挖更下层的服务。这使测试只针对当前 action 的“输入—输出—副作用”契约。四条关键原则原则 1只 spy 直接依赖Spy Direct Dependencies Only// ✅ Good: Spy on direct dependency const fetchAIChatSpy vi.spyOn(result.current, internal_fetchAIChatMessage) .mockResolvedValue({ isFunctionCall: false, content: AI response }); // ❌ Bad: Spy on lower-level implementation const streamSpy vi.spyOn(chatService, createAssistantMessageStream) .mockImplementation(...);判断标准是依赖的层级是否与被测 action 直接相邻如果sendMessage内部调用internal_fetchAIChatMessage那么测sendMessage时把后者 stub 掉是正确的边界但越过这个边界去 mock 流式服务chatService.createAssistantMessageStream等于把被测逻辑“短路”进了下层实现细节一旦内部重构测试即失去意义且断言的不再是行为而是实现。原则 2最小化全局 spyMinimize Global Spies// ✅ Spy only when needed it(should process message, async () { const streamSpy vi.spyOn(chatService, createAssistantMessageStream) .mockImplementation(...); // test logic streamSpy.mockRestore(); }); // ❌ Dont setup all spies globally beforeEach(() { vi.spyOn(chatService, createAssistantMessageStream).mockResolvedValue({}); vi.spyOn(fileService, uploadFile).mockResolvedValue({}); });在beforeEach里无差别地挂满 spy 有两个代价一是掩盖了“某个用例其实根本没走到该依赖”的事实mock 与断言脱钩二是让测试代码膨胀成“服务目录”维护成本高。规范的建议是spy 的生命周期与用例对齐——在it内创建、用完即mockRestore()配合afterEach的vi.restoreAllMocks()双保险。原则 3异步操作必须使用 act()it(should send message, async () { const { result } renderHook(() useChatStore()); await act(async () { await result.current.sendMessage({ message: Hello }); }); expect(messageService.createMessage).toHaveBeenCalled(); });store action 通常是 async 函数内部setState会驱动 React 订阅组件更新。把异步 action 的调用放在await act(async () {...})内React Testing Library 会等待期间的状态更新与副作用落定既消除 “An update to ... inside a test was not wrapped in act(...)” 告警也保证断言执行时状态已收敛。这一点与mocks/zustand/traditional.ts 中“全局 store 复位也包在act()内”的做法完全一致——凡是触发 store 状态变更的地方都按 React 更新边界处理。原则 4按行为域组织测试Test Organizationdescribe(sendMessage, () { describe(validation, () { it(should not send when session is inactive); it(should not send when message is empty); }); describe(message creation, () { it(should create user message and trigger AI processing); }); describe(error handling, () { it(should handle message creation errors gracefully); }); });组织原则是外层describe对应一个 action内层describe对应该 action 的行为域参数校验、主流程、错误处理而不是按代码行序或内部变量命名。对于 src/store/chat/store.ts 这类聚合了十几个 slice 的大型 store这种结构让失败定位、覆盖度检查哪些行为域缺失和后续重构都保持可追踪。流式响应StreamingMock 模式聊天场景的核心难点是流式输出。store action 接收一个“回调式流”onMessageHandle逐块回调 onFinish收尾mock 时要手动模拟流的时序it(should handle streaming chunks, async () { const { result } renderHook(() useChatStore()); const streamSpy vi.spyOn(chatService, createAssistantMessageStream) .mockImplementation(async ({ onMessageHandle, onFinish }) { await onMessageHandle?.({ type: text, text: Hello } as any); await onMessageHandle?.({ type: text, text: World } as any); await onFinish?.(Hello World, {}); }); await act(async () { await result.current.internal_fetchAIChatMessage({...}); }); streamSpy.mockRestore(); });这个模式有三个值得注意的细节回调可选链onMessageHandle?.(...)、onFinish?.(...)防止真实实现中某回调未传时的误报分块 → 收尾先若干onMessageHandle增量块最后onFinish给出完整内容与真实 SSE/流接口的“增量 终态”契约一致能同时验证“增量拼接”与“终态落库”两条路径spy 作用域该 spy 在it内创建并在结尾mockRestore()正是原则 2最小化全局 spy在复杂依赖上的落地——流式服务属于被测 action 之下的实现只有当流本身是测试对象时才允许 mock 这一层。SWR Hook 测试不 mock useSWR只 mock fetcherstore 里嵌有通过 SWR 拉取远端数据的 hook 时如插件分类列表规范给出的模式是it(should fetch data, async () { const mockData [{ id: 1, name: Item 1 }]; vi.spyOn(discoverService, getPluginCategories).mockResolvedValue(mockData); const { result } renderHook(() useStore.getState().usePluginCategories(params)); await waitFor(() { expect(result.current.data).toEqual(mockData); }); });关键约定文档原文 Key points不要 mockuseSWR让它跑真实实现——SWR 的去重、缓存、重新验证逻辑正是需要被执行的代码只 mock service 方法fetcher把网络边界封在 spy 处用waitFor等待异步数据到位而非固定setTimeout。LobeHub 仓库还为这类渲染提供了配套工具tests/utils.tsx 导出的withSWR包裹组件时注入SWRConfigprovider: () new Map()保证 hook 在测试环境有合法的缓存 provider 而不会因缺少 context 崩溃。对于通过getState()取出的非 hook action如示例中的usePluginCategories配合renderHookwaitFor即可完成闭环。反模式Anti-Patterns文档最后明确列出三类应禁止的写法// ❌ Dont mock entire store vi.mock(../../store, () ({ useChatStore: vi.fn(() ({...})) })); // ❌ Dont test internal state structure expect(result.current.messagesMap).toHaveProperty(test-session); // ✅ Test behavior instead expect(result.current.refreshMessages).toHaveBeenCalled();整体 mock store等价于“什么都没测”——store 自身的状态转换逻辑被整体替换测试退化为对桩的断言全局 zustand mock 提供的“真实 store 自动复位”已经是隔离与复用的最佳平衡点断言内部 state 结构messagesMap这类字段名属于实现细节任何一次内部重构如 Map 换数组都会打爆一堆与行为无关的断言改为断言行为对外部可见的副作用refreshMessages被调用、消息服务被调用、loading 状态翻转做断言测试才能长期存活。总结LobeHub 的 Zustand store action 测试体系可以概括为三层基础设施层mocks/zustand/traditional.ts 拦截 store 创建并注册初始快照tests/setup.ts 全局安装该 mockvitest.config.mts 提供 happy-dom 环境、globals: true、setup 文件与覆盖率排除策略骨架层beforeEach中用setState(state, false)合并式构造夹具、vi.clearAllMocks()清零、afterEach中vi.restoreAllMocks()兜底模式层只 spy 直接依赖、spy 随用例生灭、异步一律await act、流式按“增量回调 收尾”手动模拟、SWR 只 mock fetcher、按行为域组织describe、断言行为而非 state 结构。这套写法直接面向 src/store/chat/store.ts 这类多 slice 聚合的大型 store既保证了测试间的完全隔离又让每个 action 的契约校验、主流程、错误处理、流式与错误路径可独立验证、可长期维护。【免费下载链接】lobehub LobeHub is your Chief Agent Operator, organizing your agents into 7×24 operations by hiring, scheduling, and reporting on your entire AI team.项目地址: https://gitcode.com/GitHub_Trending/lo/lobehub创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表