Skip to content
On this page

Pinia 测试

为 Pinia Store 编写测试是确保应用状态管理正确性和可靠性的重要环节。本指南将详细介绍如何为 Pinia Store 编写单元测试、集成测试和端到端测试。

测试环境设置

安装测试依赖

bash
# Jest
npm install -D jest @types/jest

# 或者使用 Vitest (推荐)
npm install -D vitest @vitest/ui jsdom

# Vue 测试工具
npm install -D @vue/test-utils

测试配置

javascript
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  transform: {
    '^.+\\.vue$': '@vue/vue3-jest',
    '^.+\\js$': 'babel-jest',
  },
  collectCoverageFrom: [
    'src/stores/**/*.js',
    '!src/stores/index.js',
  ],
}

或者使用 Vitest:

javascript
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./tests/setup.js'],
  },
})

基础测试设置

创建测试辅助函数

javascript
// tests/helpers/pinia.js
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'

export const createTestPinia = (options = {}) => {
  const testingPinia = createTestingPinia({
    createSpy: options.createSpy || vi.fn, // 使用 Vitest 的 mock 函数
    stubActions: options.stubActions !== false, // 默认模拟 Actions
  })
  
  setActivePinia(testingPinia)
  return testingPinia
}

测试示例 Store

javascript
// src/stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'counter',
  }),
  
  getters: {
    doubleCount: (state) => state.count * 2,
    isEven: (state) => state.count % 2 === 0,
  },
  
  actions: {
    increment() {
      this.count++
    },
    
    decrement() {
      this.count--
    },
    
    incrementBy(amount) {
      this.count += amount
    },
    
    async asyncIncrement() {
      await new Promise(resolve => setTimeout(resolve, 100))
      this.increment()
      return this.count
    },
  },
})

单元测试

测试 State

javascript
// tests/unit/counter.store.spec.js
import { describe, it, expect, beforeEach } from 'vitest'
import { createTestPinia } from '../helpers/pinia'
import { useCounterStore } from '@/stores/counter'

describe('Counter Store - State', () => {
  let store
  
  beforeEach(() => {
    createTestPinia()
    store = useCounterStore()
  })
  
  it('初始化时 count 为 0', () => {
    expect(store.count).toBe(0)
  })
  
  it('初始化时 name 为 counter', () => {
    expect(store.name).toBe('counter')
  })
  
  it('初始状态可以被覆盖用于测试', () => {
    const pinia = createTestPinia({
      initialState: {
        counter: { count: 10, name: 'test-counter' },
      },
    })
    
    const testStore = useCounterStore(pinia)
    expect(testStore.count).toBe(10)
    expect(testStore.name).toBe('test-counter')
  })
})

测试 Getters

javascript
// 继续在 tests/unit/counter.store.spec.js 中
describe('Counter Store - Getters', () => {
  let store
  
  beforeEach(() => {
    createTestPinia()
    store = useCounterStore()
  })
  
  it('doubleCount 返回 count 的两倍', () => {
    store.count = 5
    expect(store.doubleCount).toBe(10)
  })
  
  it('isEven 正确判断奇偶性', () => {
    store.count = 4
    expect(store.isEven).toBe(true)
    
    store.count = 5
    expect(store.isEven).toBe(false)
  })
  
  it('getters 是响应式的', () => {
    store.count = 2
    expect(store.doubleCount).toBe(4)
    
    store.count = 3
    expect(store.doubleCount).toBe(6) // 应该自动更新
  })
})

测试 Actions

javascript
// 继续在 tests/unit/counter.store.spec.js 中
describe('Counter Store - Actions', () => {
  let store
  
  beforeEach(() => {
    createTestPinia()
    store = useCounterStore()
  })
  
  it('increment 增加 count 1', () => {
    store.count = 5
    store.increment()
    expect(store.count).toBe(6)
  })
  
  it('decrement 减少 count 1', () => {
    store.count = 5
    store.decrement()
    expect(store.count).toBe(4)
  })
  
  it('incrementBy 按指定数量增加', () => {
    store.count = 5
    store.incrementBy(3)
    expect(store.count).toBe(8)
  })
  
  it('asyncIncrement 异步增加 count', async () => {
    store.count = 5
    const result = await store.asyncIncrement()
    expect(store.count).toBe(6)
    expect(result).toBe(6)
  })
})

测试复杂 Store

带有副作用的 Store

javascript
// src/stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    loading: false,
    error: null,
  }),
  
  getters: {
    isLoggedIn: (state) => !!state.user,
    userName: (state) => state.user?.name || 'Guest',
  },
  
  actions: {
    async fetchUser(id) {
      this.loading = true
      this.error = null
      
      try {
        // 模拟 API 调用
        const response = await fetch(`/api/users/${id}`)
        if (!response.ok) {
          throw new Error('Failed to fetch user')
        }
        this.user = await response.json()
      } catch (error) {
        this.error = error.message
        throw error
      } finally {
        this.loading = false
      }
    },
    
    setUser(userData) {
      this.user = userData
    },
    
    clearUser() {
      this.user = null
      this.error = null
    },
  },
})

测试异步操作

javascript
// tests/unit/user.store.spec.js
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { createTestPinia } from '../helpers/pinia'
import { useUserStore } from '@/stores/user'

describe('User Store - Async Operations', () => {
  let store
  
  beforeEach(() => {
    // 创建模拟的 fetch 函数
    global.fetch = vi.fn()
    store = useUserStore(createTestPinia())
  })
  
  afterEach(() => {
    vi.clearAllMocks()
  })
  
  it('fetchUser 成功获取用户数据', async () => {
    const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' }
    
    global.fetch.mockResolvedValue({
      ok: true,
      json: async () => mockUser,
    })
    
    await store.fetchUser(1)
    
    expect(store.loading).toBe(false)
    expect(store.user).toEqual(mockUser)
    expect(store.error).toBeNull()
    expect(global.fetch).toHaveBeenCalledWith('/api/users/1')
  })
  
  it('fetchUser 处理 API 错误', async () => {
    global.fetch.mockResolvedValue({
      ok: false,
      status: 404,
    })
    
    await expect(store.fetchUser(999)).rejects.toThrow('Failed to fetch user')
    
    expect(store.loading).toBe(false)
    expect(store.user).toBeNull()
    expect(store.error).toBe('Failed to fetch user')
  })
  
  it('setUser 正确设置用户数据', () => {
    const userData = { id: 1, name: 'Jane Doe' }
    store.setUser(userData)
    
    expect(store.user).toEqual(userData)
    expect(store.isLoggedIn).toBe(true)
    expect(store.userName).toBe('Jane Doe')
  })
  
  it('clearUser 清除用户数据', () => {
    store.setUser({ id: 1, name: 'John' })
    store.clearUser()
    
    expect(store.user).toBeNull()
    expect(store.isLoggedIn).toBe(false)
    expect(store.userName).toBe('Guest')
  })
})

组件集成测试

测试使用 Store 的组件

vue
<!-- src/components/Counter.vue -->
<template>
  <div>
    <p>Count: {{ store.count }}</p>
    <p>Double: {{ store.doubleCount }}</p>
    <button @click="store.increment">Increment</button>
    <button @click="store.decrement">Decrement</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const store = useCounterStore()
</script>
javascript
// tests/components/Counter.spec.js
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestPinia } from '../helpers/pinia'
import Counter from '@/components/Counter.vue'
import { useCounterStore } from '@/stores/counter'

describe('Counter Component', () => {
  let wrapper
  let store
  
  beforeEach(() => {
    const pinia = createTestPinia()
    wrapper = mount(Counter, {
      global: {
        plugins: [pinia],
      },
    })
    store = useCounterStore()
  })
  
  it('显示正确的初始计数值', () => {
    expect(wrapper.text()).toContain('Count: 0')
    expect(wrapper.text()).toContain('Double: 0')
  })
  
  it('点击 Increment 按钮增加计数', async () => {
    const incrementBtn = wrapper.find('button')
    await incrementBtn.trigger('click')
    
    expect(wrapper.text()).toContain('Count: 1')
    expect(wrapper.text()).toContain('Double: 2')
  })
  
  it('点击 Decrement 按钮减少计数', async () => {
    // 先增加到 1
    store.increment()
    await wrapper.vm.$nextTick()
    
    const decrementBtn = wrapper.findAll('button')[1]
    await decrementBtn.trigger('click')
    
    expect(wrapper.text()).toContain('Count: 0')
  })
})

Mock 和 Stub 技术

Mock Actions

javascript
// tests/unit/mock-actions.spec.js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createTestPinia } from '../helpers/pinia'
import { useUserStore } from '@/stores/user'

describe('Mock Actions', () => {
  let store
  
  beforeEach(() => {
    // 自动模拟所有 Actions
    const pinia = createTestPinia({ stubActions: true })
    store = useUserStore(pinia)
  })
  
  it('actions 被正确模拟', () => {
    store.fetchUser(1) // 这不会执行实际的 API 调用
    expect(store.fetchUser).toHaveBeenCalled() // 检查是否被调用
  })
  
  it('可以手动模拟特定 action', async () => {
    // 手动模拟 action
    store.fetchUser = vi.fn().mockResolvedValue({ id: 1, name: 'Mock User' })
    
    const result = await store.fetchUser(1)
    expect(result).toEqual({ id: 1, name: 'Mock User' })
    expect(store.fetchUser).toHaveBeenCalledWith(1)
  })
})

依赖注入测试

javascript
// src/stores/api.js
import { defineStore } from 'pinia'

export const useApiStore = defineStore('api', (deps = {}) => {
  const { apiClient = defaultApiClient } = deps
  
  const data = ref([])
  const loading = ref(false)
  const error = ref(null)
  
  async function fetchData() {
    loading.value = true
    error.value = null
    
    try {
      data.value = await apiClient.get('/data')
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }
  
  return {
    data,
    loading,
    error,
    fetchData,
  }
})

// 测试依赖注入
// tests/unit/api.store.spec.js
describe('API Store with Dependency Injection', () => {
  it('使用提供的依赖项', async () => {
    const mockApiClient = {
      get: vi.fn().mockResolvedValue([{ id: 1, name: 'Test' }]),
    }
    
    const pinia = createTestPinia()
    const store = useApiStore(pinia, { apiClient: mockApiClient })
    
    await store.fetchData()
    
    expect(mockApiClient.get).toHaveBeenCalledWith('/data')
    expect(store.data).toEqual([{ id: 1, name: 'Test' }])
  })
})

测试最佳实践

1. 测试状态转换

javascript
// 测试状态机类型的状态转换
import { useAuthStore } from '@/stores/auth'

describe('Auth Store - State Transitions', () => {
  let store
  
  beforeEach(() => {
    store = useAuthStore(createTestPinia())
  })
  
  it('正确处理登录状态转换', async () => {
    expect(store.status).toBe('unauthenticated')
    
    // 模拟登录过程
    await store.login({ email: 'test@example.com', password: 'password' })
    
    expect(store.status).toBe('authenticated')
    expect(store.user).not.toBeNull()
  })
  
  it('正确处理登出状态转换', () => {
    store.setUser({ id: 1, name: 'Test User' })
    store.logout()
    
    expect(store.status).toBe('unauthenticated')
    expect(store.user).toBeNull()
  })
})

2. 测试错误边界

javascript
describe('Error Handling', () => {
  let store
  
  beforeEach(() => {
    store = useUserStore(createTestPinia())
  })
  
  it('在 action 失败时设置错误状态', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: false,
      status: 500,
    })
    
    await expect(store.fetchUser(1)).rejects.toThrow()
    
    expect(store.error).not.toBeNull()
    expect(store.loading).toBe(false)
  })
})

3. 测试持久化

javascript
// 如果使用了持久化插件
import { createTestingPinia } from '@pinia/testing'

describe('Persistence Tests', () => {
  it('状态正确持久化', () => {
    const pinia = createTestingPinia({
      initialState: {
        counter: { count: 42 },
      },
    })
    
    const store = useCounterStore(pinia)
    expect(store.count).toBe(42)
  })
})

测试运行和报告

运行测试

bash
# 运行所有测试
npm test

# 运行特定测试文件
npm test tests/unit/counter.store.spec.js

# 监听模式
npm test -- --watch

# 生成覆盖率报告
npm test -- --coverage

通过以上测试策略和示例,您可以为 Pinia Store 编写全面的测试,确保状态管理的可靠性和正确性。