首页
/ airi 中的 Pinia 测试实战:从 store 单元测试到 @pinia/testing 的完整使用指南

airi 中的 Pinia 测试实战:从 store 单元测试到 @pinia/testing 的完整使用指南

2026-09-05 23:25:01作者:瞿蔚英Wynne

本篇基于 airi 仓库内置的 Pinia 测试最佳实践文档(best-practices-testing.md),系统讲解 Pinia store 与 Vue 组件的测试方法:如何用 createPinia + setActivePinia 做 store 单元测试、如何用 @pinia/testingcreateTestingPinia() 做组件级 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 实例

官方推荐的最小模式是 beforeEachsetActivePinia(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')
  })
})

从源码结构看,这个文件把两个测试要素结合得很典型:

  1. createTestingPinia({ createSpy: vi.fn, stubActions: false }) —— 用 @pinia/testing 建实例,但通过 stubActions: false 禁用 action 打桩,跑真实逻辑;
  2. 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/testingcreateTestingPinia()

当被测对象是组件而非 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)

行为语义需要记牢三点:

  1. state / getters 保留响应式:你可以像操作真实 store 一样直接赋值或 $patch
  2. actions 默认是 vi.fn() 风格的 spy:调用不会执行原函数体,但会记录调用,从而可用 toHaveBeenCalledTimes / toHaveBeenCalledWith 断言;
  3. 挂载到组件时通过 global.plugins 注入,组件内 useStore() 自动解析到这个测试实例,无需 setActivePinia(若同一用例还需要在组件外调用 store,则补一个 setActivePinia(pinia) 即可)。

四、初始状态:initialState

组件测试中经常需要“从某个特定状态出发”渲染组件。initialStatestore 名 → 初始 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.tscharacter.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.jsonvitest 脚本)。

参考路径

登录后查看全文
热门项目推荐
相关项目推荐