airi 中的 Pinia 测试实战:从 store 单元测试到 @pinia/testing 的完整使用指南
本篇基于 airi 仓库内置的 Pinia 测试最佳实践文档(best-practices-testing.md),系统讲解 Pinia store 与 Vue 组件的测试方法:如何用 createPinia + setActivePinia 做 store 单元测试、如何用 @pinia/testing 的 createTestingPinia() 做组件级 mock(含 initialState、action 打桩、getter 覆写、插件注入与类型安全 mock)。读完你可以直接在 airi 这类大型 pnpm monorepo(pinia ^4 + vitest)中编写可运行的 store 测试,并理解 packages/stage-ui 等包中真实测试文件的写法。
一、为什么需要专门的状态管理测试
Pinia 的 store 依赖一个“激活的 pinia 实例”才能工作:useXxxStore() 内部会从当前组件上下文或 setActivePinia 设置的实例中解析 pinia。如果在测试里直接调用 store 而不先创建并激活 pinia,要么报错,要么多个用例之间共享同一份状态导致互相污染。因此测试的第一原则是:每个用例都从一个干净、可控的 pinia 实例开始。
在 airi 中,这一套约定被大量采用。pnpm-workspace.yaml 中通过 catalog 统一了版本:
'@pinia/colada': ^1.4.2
'@pinia/testing': ^2.0.1
pinia: ^4.0.3
pinia-plugin-synced: ^0.1.4
packages/stage-ui/package.json 通过 catalog: 引用这些依赖,并提供 "test": "vitest" 脚本,所以 airi 的测试栈是 Vitest + @vue/test-utils + @pinia/testing。下面所有示例均可在该环境下直接运行。
二、Store 单元测试:每个用例一个全新 pinia 实例
官方推荐的最小模式是 beforeEach 中 setActivePinia(createPinia()):
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '../src/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('increments', () => {
const counter = useCounterStore()
expect(counter.n).toBe(0)
counter.increment()
expect(counter.n).toBe(1)
})
})
要点:
createPinia()返回一个全新的 pinia 实例,setActivePinia把它注册为“当前激活实例”,之后所有脱离组件上下文调用的useStore()都会拿到这个实例;- 放在
beforeEach而非beforeAll,保证每个用例状态互不泄漏; - 这种写法适合直接验证真实逻辑(真实 action、真实 getter 计算),因此 airi 中多数 store 逻辑测试走的是这条路线,而不是 mock。
airi 里的真实用例见 packages/stage-ui/src/stores/settings/general.test.ts,它测试的是 useSettingsGeneral 的语言回退逻辑(issue #1658:Electron 重启后 localStorage 未落盘导致语言回落到系统语言):
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsGeneral } from './general'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
describe('store settings-general', () => {
beforeEach(() => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
setActivePinia(pinia)
// …用 vi.stubGlobal('localStorage', localStorageMock) 打桩 localStorage
})
it('issue #1658: falls back to navigator.language when localStorage is empty', () => {
vi.stubGlobal('navigator', { language: 'zh-CN' })
const settingsStore = useSettingsGeneral()
const resolvedLanguage = settingsStore.getLanguage()
// 'zh-CN' 被重映射为 'zh-Hans'
expect(resolvedLanguage).toBe('zh-Hans')
expect(localStorageMock.getItem).toHaveBeenCalledWith('settings/language')
})
})
从源码结构看,这个文件把两个测试要素结合得很典型:
createTestingPinia({ createSpy: vi.fn, stubActions: false })—— 用@pinia/testing建实例,但通过stubActions: false禁用 action 打桩,跑真实逻辑;- 用
vi.stubGlobal('localStorage', ...)/vi.stubGlobal('navigator', ...)替换浏览器全局对象,模拟 Electron 重启后持久化丢失的场景。
这说明一个常见误区:createTestingPinia 不等于“mock 一切”。它既可以纯 mock(组件测试场景),也可以只借用其 spy 基建、保持 store 逻辑真实(stubActions: false),airi 的 character.test.ts 同样采用这种“真逻辑 + 外部依赖打桩”的混合策略(mock 语音运行时、LLM marker parser 等外部边界,而 store 本身的 action 真实执行)。
带插件的测试
如果你的 store 依赖自定义 Pinia 插件(例如持久化、sync 插件),测试中要把插件挂到测试 pinia 上,并同时 app.use(pinia) + setActivePinia(pinia):
import { setActivePinia, createPinia } from 'pinia'
import { createApp } from 'vue'
import { somePlugin } from '../src/stores/plugin'
const app = createApp({})
beforeEach(() => {
const pinia = createPinia().use(somePlugin)
app.use(pinia)
setActivePinia(pinia)
})
注意顺序:插件要在 use 链上先于 app.use(pinia) 生效;setActivePinia 保证后续脱离组件的 useStore() 调用也拿到同一个带插件的实例。
三、组件测试:@pinia/testing 的 createTestingPinia()
当被测对象是组件而非 store 本身时(你只关心组件对状态的渲染与交互,不关心 action 内部实现),应该引入 @pinia/testing:
npm i -D @pinia/testing
(在 airi 的 pnpm workspace 中,等价做法是把 @pinia/testing: catalog: 加进对应包的 package.json,版本由 pnpm-workspace.yaml 统一管理。)
基本用法:
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { useSomeStore } from '@/stores/myStore'
const wrapper = mount(Counter, {
global: {
plugins: [createTestingPinia()],
},
})
const store = useSomeStore()
// 直接操作状态
store.name = 'new name'
store.$patch({ name: 'new name' })
// action 默认全部被打桩为 spy
store.someAction()
expect(store.someAction).toHaveBeenCalledTimes(1)
行为语义需要记牢三点:
- state / getters 保留响应式:你可以像操作真实 store 一样直接赋值或
$patch; - actions 默认是 vi.fn() 风格的 spy:调用不会执行原函数体,但会记录调用,从而可用
toHaveBeenCalledTimes/toHaveBeenCalledWith断言; - 挂载到组件时通过
global.plugins注入,组件内useStore()自动解析到这个测试实例,无需setActivePinia(若同一用例还需要在组件外调用 store,则补一个setActivePinia(pinia)即可)。
四、初始状态:initialState
组件测试中经常需要“从某个特定状态出发”渲染组件。initialState 以 store 名 → 初始 state 的映射给出:
const wrapper = mount(Counter, {
global: {
plugins: [
createTestingPinia({
initialState: {
counter: { n: 20 }, // Store name → initial state
},
}),
],
},
})
映射的 key 是 store 的 id(defineStore('counter', ...) 的第一个参数,或 setup store 的 id 选项),value 只需给出你想覆盖的字段。组件挂载后断言 wrapper 渲染出的就是 n: 20 对应的视图,而无需手动执行一串 action 把状态“凑”到该值。
五、Action 打桩的三种粒度
5.1 执行真实 action
createTestingPinia({ stubActions: false })
即上文 airi 测试文件普遍采用的模式:stubActions: false 关闭所有打桩,action 原样执行。适合 store 逻辑本身复杂、需要端到端验证状态流转的场景。
5.2 选择性打桩
可以只对部分 action 打桩:
// 只打桩指定 action
createTestingPinia({
stubActions: ['increment', 'reset'],
})
// 或用函数按规则判定
createTestingPinia({
stubActions: (actionName, store) => {
if (actionName.startsWith('set')) return true
return false
},
})
函数形式接收 (actionName, store),返回 true 的 action 会被替换为 spy,其余照旧执行。这在“部分 action 会发起网络/文件 IO,其余是纯状态计算”的 store 中很实用——只把危险边界打桩掉。
5.3 Mock action 返回值
action 被打桩后就是普通 spy,可以安排返回值/解析值:
import type { Mock } from 'vitest'
// 获取 store 之后
store.someAction.mockResolvedValue('mocked value')
这样组件里 await someAction() 拿到的就是 'mocked value',你可以据此断言加载态、成功态的 UI。
六、Mock Getters:测试中可直接赋值
createTestingPinia 创建实例时,getter 变为可写的,可以直接覆写计算结果:
const pinia = createTestingPinia()
const counter = useCounterStore(pinia)
counter.double = 3 // 覆盖 computed 值
// 恢复默认行为
counter.double = undefined
counter.double // 现在正常走 getter 计算
这对“getter 依赖昂贵计算或外部数据”的测试非常有用:把 double 钉死为 3,即可单独验证依赖它的视图分支。赋回 undefined 表示撤销覆写、回到真实 getter。
七、自定义 spy 工厂:createSpy
createTestingPinia 需要知道如何创建 spy。若你没有开启 Jest/Vitest globals,需要显式传入:
import { vi } from 'vitest'
createTestingPinia({
createSpy: vi.fn,
})
使用 Sinon 的项目可以传 sinon.spy:
import sinon from 'sinon'
createTestingPinia({
createSpy: sinon.spy,
})
airi 的测试文件(如 general.test.ts、character.test.ts)统一写法是 createTestingPinia({ createSpy: vi.fn, stubActions: false })——显式声明 spy 工厂来自 vi.fn,既保证在非 globals 配置下也能工作,也让代码意图清晰。
八、Pinia 插件与测试实例
给测试 pinia 附加自定义插件时,通过 options 传入,而不是 testingPinia.use(...):
import { somePlugin } from '../src/stores/plugin'
createTestingPinia({
stubActions: false,
plugins: [somePlugin],
})
官方文档特别强调:不要使用 testingPinia.use(MyPlugin),插件必须放进 createTestingPinia 的 options 中,由它在实例初始化阶段统一应用。
九、类型安全的 mocked store
默认 useStore() 的返回类型中 actions 只是普通函数,无法直接 .mockResolvedValue。可以用条件类型把 actions 映射为 Mock:
import type { Mock } from 'vitest'
import type { Store, StoreDefinition } from 'pinia'
function mockedStore<TStoreDef extends () => unknown>(
useStore: TStoreDef
): TStoreDef extends StoreDefinition<infer Id, infer State, infer Getters, infer Actions>
? Store<Id, State, Record<string, never>, {
[K in keyof Actions]: Actions[K] extends (...args: any[]) => any
? Mock<Actions[K]>
: Actions[K]
}>
: ReturnType<TStoreDef> {
return useStore() as any
}
// 用法
const store = mockedStore(useSomeStore)
store.someAction.mockResolvedValue('value') // 带类型!
原理是借助 StoreDefinition 提取 State/Getters/Actions 类型参数,再把 actions 逐项映射为 Mock<原函数签名>。这样 mockResolvedValue 的参数类型、调用参数类型都有完整检查,避免字符串拼写错误导致的运行时 mock 失败。
十、E2E 测试:无需特殊处理
在 Playwright 等 E2E 场景中,Pinia 以生产路径正常工作,不需要 @pinia/testing,也不必打桩——E2E 的价值恰在于跑真实 store 逻辑。
十一、在 airi 中落地:完整心智模型
结合 packages/stage-ui/src/stores/settings/audio-device.test.ts 这类复杂用例,可以总结出 airi 场景下的分层测试策略:
| 层次 | 工具 | 关键配置 | 验证目标 |
|---|---|---|---|
| Store 逻辑测试 | createTestingPinia + setActivePinia |
{ createSpy: vi.fn, stubActions: false } |
action/getter 真实执行,外部依赖(localStorage、navigator、composable)用 vi.mock / vi.stubGlobal 打桩 |
| 组件渲染测试 | mount + global.plugins: [createTestingPinia()] |
initialState 预设状态,actions 默认打桩 |
组件对状态的响应与交互行为 |
| 部分打桩 | stubActions: [names] 或函数 |
按前缀/白名单选择性 mock | 隔离网络/IO 边界,保留纯计算逻辑 |
| 插件依赖 | plugins: [...] options |
不使用 testingPinia.use() |
插件注入的字段/钩子在测试中可用 |
实操建议:
- 默认
stubActions: false:airi 的测试几乎全部如此——store 逻辑本身就是被测目标,mock 的是浏览器 API、i18n(vi.mock('vue-i18n'))、composables 等外部边界; - 组件测试才启用默认打桩:配合
initialState直接铺状态,断言渲染结果与 spy 调用; - 每个用例独立实例:无论哪条路线,
beforeEach重建 pinia 实例 + 重置 mock(vi.resetAllMocks()/vi.unstubAllGlobals())是 airi 测试文件的标准收尾动作,防止用例间状态泄漏; - 修改依赖后运行单个包的测试:
pnpm --filter @proj-airi/stage-ui test(对应 packages/stage-ui/package.json 的vitest脚本)。
参考路径
- 测试最佳实践原文:.agents/skills/pinia/references/best-practices-testing.md
- Pinia 技能索引(Stores/Plugins/Testing/SSR 等主题导航):.agents/skills/pinia/SKILL.md
- 真实 store 测试示例:packages/stage-ui/src/stores/settings/general.test.ts、packages/stage-ui/src/stores/settings/audio-device.test.ts、packages/stage-ui/src/stores/character.test.ts
- 依赖版本(pinia ^4.0.3、@pinia/testing ^2.0.1):pnpm-workspace.yaml
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00