
Airi 项目 Vue 3 测试指南用 createTestingPinia 与 setActivePinia 正确配置 Pinia Store【免费下载链接】airi Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-samas altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi适用技能文档testing-pinia-store-setup.md属仓库.agents/skills/vue-testing-best-practices技能包本文是一份针对 Vue 3 Vitest Pinia 项目的测试配置实战指南核心解决两类最典型的测试失败场景组件测试报injection Symbol(pinia) not found以及Store 单元测试报no active Pinia。在 airi 这类由大量 Vue 3 页面、组合式函数与多仓库模块monorepo组成的项目中几乎所有组件与 composable 都依赖 Pinia 注入的 store 实例掌握本文的配置模式即可让测试在隔离、稳定、可断言的前提下运行。读完你将能够正确安装与使用pinia/testing区分组件测试与Store 直接单测两套初始化范式并灵活控制 action 是被 stub 还是真实执行。问题根源缺失 Pinia 实例导致的注入错误Pinia 的 store 在使用时通过 Vue 的依赖注入机制从组件树或当前 active pinia中获取实例。一旦测试环境里没有提供任何 Pinia 实例就会触发如下两类经典错误组件挂载时报错[Vue warn]: injection Symbol(pinia) not found——因为组件内部调用了useXxxStore()而 mount 时没有通过global.plugins注入 Pinia直接调用 store 时报错no active Pinia——因为在模块顶层直接调用useXxxStore()而测试进程里从未调用过setActivePinia()。错误示例 1组件缺少 Pinia 插件import { mount } from vue/test-utils import UserProfile from ./UserProfile.vue // BAD: Missing Pinia - causes injection error test(displays user name, () { const wrapper mount(UserProfile) // ERROR: injection Symbol(pinia) not found expect(wrapper.text()).toContain(John) })错误示例 2Store 直接使用但未激活 Piniaimport { useUserStore } from /stores/user // BAD: No active Pinia instance test(user store actions, () { const store useUserStore() // ERROR: no active Pinia store.login(john, password) })核心对策Task Checklist针对上述两种场景最佳实践遵循以下清单安装pinia/testing作为 devDependency组件测试使用createTestingPinia并通过global.plugins注入Store 单元测试在beforeEach中使用setActivePinia(createPinia())当 Vitest未开启globals: true时必须配置createSpy: vi.fn在每个测试内部初始化 store以获得全新、干净的状态需要 action 真实执行集成验证时设置stubActions: false正确姿势一组件测试注入createTestingPinia组件测试的本质是挂载整个组件 其依赖上下文。做法是在mount的global.plugins中加入由createTestingPinia()创建的测试版 Pinia。默认行为下store 的actions 会被自动 stub 成 spy便于只关心组件渲染与交互而不被副作用网络请求、本地持久化等打扰。正确示例——利用 initialState 预置状态import { mount } from vue/test-utils import { createTestingPinia } from pinia/testing import { vi } from vitest import UserProfile from ./UserProfile.vue import { useUserStore } from /stores/user // CORRECT: Provide testing pinia with stubbed actions test(displays user name, () { const wrapper mount(UserProfile, { global: { plugins: [ createTestingPinia({ createSpy: vi.fn, // Required if not using globals: true initialState: { user: { name: John, email: johnexample.com } } }) ] } }) expect(wrapper.text()).toContain(John) })正确示例——触发被 stub 的 action 并断言// CORRECT: Test with stubbed actions (default behavior) test(calls logout action, async () { const wrapper mount(UserProfile, { global: { plugins: [createTestingPinia({ createSpy: vi.fn })] } }) // Get store AFTER mounting with createTestingPinia const store useUserStore() await wrapper.find([data-testidlogout]).trigger(click) // Actions are stubbed and wrapped in spies expect(store.logout).toHaveBeenCalled() })关键细节说明initialState对象键对应 store 的id即defineStore(id, …)的第一个参数值是该 store 的初始 state。注意在 store 定义采用 setup 语法defineStore(id, () { … })时initialState是按 ref 键名匹配注入的因此注入的键必须与 setup 内定义的 ref 名称严格一致createSpy: vi.fn仅当 Vitest 全局开启了globals: true测试文件可直接用vi、describe等而无需 import时才能省略。airi 的各测试文件均显式import { vi } from vitest例如 character.test.ts即采用非全局模式故必须显式传入createSpy: vi.fn否则会抛出vi is not defined一类的报错取 store 的时机必须在组件mount完成之后调用useUserStore()这样拿到的才是createTestingPinia注入的那一个实例。正确姿势二Store 单元测试用setActivePinia(createPinia())当测试对象就是 store 本身状态、getter、action 逻辑时不需要挂载任何组件只需在beforeEach中为每个测试重建一个全新的 Pinia 实例并激活。这样既可保证测试间状态完全隔离也避免了createTestingPinia默认 stub 掉 action 而无法验证真实逻辑的问题——这里使用的是真实的createPinia()。正确示例——标准 Store 单测骨架import { describe, it, expect, beforeEach, vi } from vitest import { setActivePinia, createPinia } from pinia import { useUserStore } from /stores/user describe(User Store, () { beforeEach(() { // Create fresh Pinia instance for each test setActivePinia(createPinia()) }) it(initializes with empty user, () { const store useUserStore() expect(store.user).toBeNull() expect(store.isLoggedIn).toBe(false) }) it(updates user on login, async () { const store useUserStore() // Real action executes - not stubbed await store.login(john, password) expect(store.user).toEqual({ name: John }) expect(store.isLoggedIn).toBe(true) }) it(clears user on logout, () { const store useUserStore() store.user { name: John } // Set initial state store.logout() expect(store.user).toBeNull() }) })三个测试共享一个要点每个it内部都重新调用一次useUserStore()。由于beforeEach已重建 active piniastore 内部状态必然是最新且干净的因此第二个测试的store.user …不会污染第三个测试的初始状态。真实 action 与 stub action 的取舍pinia/testing的核心开关是stubActionsstubActions: true默认action 全部被替换成 spy不执行任何真实副作用适合单元隔离测试——你只验证组件点击后调用了 actionaction 内部的 API 请求、$patch等由对应 store 的测试另行覆盖stubActions: falseaction 真实执行适合跨 store 的集成式组件测试前提是 action 内部依赖网络、外部模块能被vi.mock等手段隔离。import { createTestingPinia } from pinia/testing // Stubbed actions (default) - for isolation const wrapper mount(Component, { global: { plugins: [ createTestingPinia({ createSpy: vi.fn, // stubActions: true (default) - actions are mocked }) ] } }) // Real actions - for integration testing const wrapper mount(Component, { global: { plugins: [ createTestingPinia({ createSpy: vi.fn, stubActions: false // Actions execute normally }) ] } })airi 仓库的真实取舍在 character.test.ts 的beforeEach中仓库使用createTestingPinia({ createSpy: vi.fn, stubActions: false })再配合setActivePinia(pinia)随后在同一套测试里直接操作 store 的真实 action如recordSparkNotifyReaction并断言状态变化在 settings/general.test.ts 中同样以createTestingPinia({ createSpy: vi.fn, stubActions: false })组合setActivePinia并用vi.stubGlobal(localStorage, …)隔离环境依赖、模拟 Electron 重启场景。可以看到即便用createTestingPinia只要传入stubActions: false它就能充当隔离环境 真实 action的 store 单测底座此时再显式调用setActivePinia(pinia)可以保证被引用的其他 store跨 store 交互也能解析到同一实例——这正是 character.test.ts 中useSpeechRuntimeStore(pinia)显式传入 pinia 参数的原因。精确 Mock 单个 action覆盖 stub 的实现stubActions: true后每个 action 都是一个vi.fnspy因此可以直接用 mock API 重写它的行为——非常适合构造网络请求失败支付失败等分支场景无需真正调用 action 内部实现。import { mount } from vue/test-utils import { createTestingPinia } from pinia/testing import { vi } from vitest import { useCartStore } from /stores/cart test(handles checkout failure, async () { const wrapper mount(Checkout, { global: { plugins: [createTestingPinia({ createSpy: vi.fn })] } }) const cartStore useCartStore() // Mock specific action behavior cartStore.checkout.mockRejectedValue(new Error(Payment failed)) await wrapper.find([data-testidcheckout]).trigger(click) await flushPromises() expect(wrapper.find(.error).text()).toContain(Payment failed) })这里await flushPromises()必不可少点击事件引发的异步 action 链Promise需要先排空微任务队列才能稳定断言.error中的文案。关于异步刷新的更多细节可参考同技能包的 testing-async-await-flushpromises.md。需要说明的是直接替换 action 实现并不依赖 stub 特性——airi 仓库在 character.test.ts 中同样用speechRuntimeStore.openIntent openSpeechIntentSpy直接覆盖了 store 上的 action 方法替代依赖的流式语音实现说明对具体某次测试场景而言直接覆写方法往往比配置庞大的vi.mock更直观、更局部化。用vi.spyOn观察与拦截 action 调用若希望保留 action 的可调用、可断言语义又不想完全替换它可以使用vi.spyOn。它既能记录调用参数也能用mockResolvedValue等控制返回值适合验证传参是否正确这一类断言。import { setActivePinia, createPinia } from pinia import { vi } from vitest import { useUserStore } from /stores/user test(tracks action calls, async () { setActivePinia(createPinia()) const store useUserStore() const loginSpy vi.spyOn(store, login) loginSpy.mockResolvedValue({ success: true }) await store.login(john, password) expect(loginSpy).toHaveBeenCalledWith(john, password) })要点先setActivePinia(createPinia())再获取 store随后对实例方法执行vi.spyOn若后续其他测试需要真实执行login记得在afterEach中loginSpy.mockRestore()该方法在 character.test.ts 也有印证——仓库用vi.spyOn(Date, now).mockReturnValue(123456)冻结时间戳后再断言reactions[0].createdAt的确定性。测试 store 的$subscribe订阅机制Pinia 的$subscribe用于监听 state 变化常用于持久化副作用。其测试策略是先用vi.fn()注册回调再主动修改 state最后断言回调确实被触发。import { setActivePinia, createPinia } from pinia import { useUserStore } from /stores/user test(subscription triggers on state change, () { setActivePinia(createPinia()) const store useUserStore() const callback vi.fn() store.$subscribe(callback) store.user { name: John } expect(callback).toHaveBeenCalled() })注意$subscribe默认只在 state 的$patch或直接赋值时触发action 内部若使用$patch或直接赋值均可命中。若 store 在 action 中通过$state整体替换需留意触发语义。实际业务中airi 这类 PWA 桌面端 app 常在 store 中把localStorage读写与$subscribe结合做持久化参见 stores/pwa.ts 对defineStoresetup 风格的使用此时 Store 单测中对localStorage做 stub 就能完整覆盖整条订阅 → 持久化链路。仓库实战印证从通用示例到 monorepo 落地文档中的通用UserStore、CartStore只是抽象模板airi 仓库把上述全部模式落在了真实 store 上packages/stage-ui/src/stores/character.test.tsbeforeEach内createTestingPinia({ createSpy: vi.fn, stubActions: false })setActivePinia(pinia)配合vi.mock(vue-i18n, …)与vi.spyOn(Date, now)覆盖角色卡AiriCard名称暴露、系统提示词读取、事件反应记录与截断上限 200 条、流式事件结束后的 flush 顺序等 5 个用例packages/stage-ui/src/stores/settings/general.test.ts针对 Electron 重启后localStorage未落盘导致语言设置丢失的真实缺陷issue #1658用同一套 Pinia 配置 vi.stubGlobal(navigator|localStorage)验证了空存储回退到navigator.language并映射为zh-Hans与有持久值时尊重持久值zh-Hant两条回归路径packages/stage-ui/src/stores/settings/audio-device.test.ts同技能模式用于音频设备相关设置 store。这些用例至少揭示了三条 monorepo 落地经验store 单测不必二选一createTestingPinia与setActivePinia可组合使用——前者提供受控环境后者保证被依赖的兄弟 store 能解析到同一实例外部依赖用vi.mockvi.stubGlobal双通道隔离i18n、localStorage、navigator、时间 API 都可被稳定桩替stub让测试既不触网也不触真实浏览器环境测试即回归文档为真实 issue如 #1658书写的用例其注释里完整记录了根因ROOT CAUSE与复现条件是理解 store 设计动机的第一手材料。快速自查清单写完 Pinia 相关测试后按以下顺序自查可避免 90% 的配置类失败pinia/testing是否已加入 devDependencies组件测试是否在mount的global.plugins中注入了createTestingPinia()取 store 的语句是否放在 mount 之后Store 单测beforeEach中是否执行了setActivePinia(createPinia())或createTestingPiniasetActivePinia每个测试是否重新useXxxStore()未开启globals: true时createTestingPinia是否传入createSpy: vi.fn需要真实执行 action 时是否设置了stubActions: false只想隔离验证时是否保持了默认 stub若引用了flushPromises、vi.waitFor等异步辅助是否已从vue/test-utils/vitest正确导入对照上述清单逐项核验后injection Symbol(pinia) not found与no active Pinia这两类错误即可被系统性消除。技能包入口 SKILL.md 中列出的 testing-component-blackbox-approach.md、testing-async-await-flushpromises.md 等姊妹篇可继续补充组件查询策略与异步测试细节共同构成完整的 Vue 3 测试知识体系。【免费下载链接】airi Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-samas altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考