Skip to content

Pinia Store 测试

使用各种测试框架测试 Pinia store 的综合示例和模式,包括单元测试、集成测试和端到端测试策略。

功能特性

  • 🧪 使用 Vitest/Jest 进行单元测试
  • 🔗 集成测试模式
  • 🎭 使用 Playwright 进行端到端测试
  • 🎯 模拟策略和最佳实践
  • 📊 覆盖率报告和分析
  • 🔄 测试异步操作和副作用
  • 🛡️ 错误处理和边缘情况测试
  • 🎨 测试 store 组合和依赖
  • 📱 组件集成测试
  • 🔧 自定义测试工具和助手

测试设置

Vitest 配置

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
        'src/main.ts'
      ],
      thresholds: {
        global: {
          branches: 80,
          functions: 80,
          lines: 80,
          statements: 80
        }
      }
    },
    testTimeout: 10000,
    hookTimeout: 10000
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@stores': resolve(__dirname, 'src/stores'),
      '@components': resolve(__dirname, 'src/components'),
      '@utils': resolve(__dirname, 'src/utils')
    }
  }
})

测试设置文件

typescript
// src/test/setup.ts
import { vi } from 'vitest'
import { config } from '@vue/test-utils'
import { createPinia } from 'pinia'

// 全局测试设置
beforeEach(() => {
  // 在每个测试前重置所有模拟
  vi.clearAllMocks()
  
  // 清除 localStorage
  localStorage.clear()
  sessionStorage.clear()
  
  // 重置 fetch 模拟
  global.fetch = vi.fn()
})

// Vue Test Utils 全局配置
config.global.plugins = [createPinia()]

// 模拟 IntersectionObserver
global.IntersectionObserver = vi.fn(() => ({
  disconnect: vi.fn(),
  observe: vi.fn(),
  unobserve: vi.fn()
}))

// 模拟 ResizeObserver
global.ResizeObserver = vi.fn(() => ({
  disconnect: vi.fn(),
  observe: vi.fn(),
  unobserve: vi.fn()
}))

// 模拟 window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn()
  }))
})

测试工具

Store 测试助手

typescript
// src/test/store-utils.ts
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { beforeEach, afterEach, vi } from 'vitest'
import type { App } from 'vue'
import { createApp } from 'vue'

export interface StoreTestContext {
  pinia: Pinia
  app: App
}

export function setupStoreTest(): StoreTestContext {
  let pinia: Pinia
  let app: App
  
  beforeEach(() => {
    app = createApp({})
    pinia = createPinia()
    app.use(pinia)
    setActivePinia(pinia)
  })
  
  afterEach(() => {
    vi.clearAllMocks()
    app.unmount()
  })
  
  return {
    get pinia() { return pinia },
    get app() { return app }
  }
}

export function createMockApi<T extends Record<string, any>>(methods: T): T {
  const mock = {} as T
  
  for (const [key, value] of Object.entries(methods)) {
    if (typeof value === 'function') {
      mock[key as keyof T] = vi.fn(value) as T[keyof T]
    } else {
      mock[key as keyof T] = value
    }
  }
  
  return mock
}

export async function waitForStoreAction(
  fn: () => Promise<any>,
  timeout = 5000
): Promise<void> {
  const timeoutPromise = new Promise<never>((_, reject) => {
    setTimeout(() => reject(new Error(`Store 操作在 ${timeout}ms 后超时`)), timeout)
  })
  
  try {
    await Promise.race([fn(), timeoutPromise])
  } catch (error) {
    if (error instanceof Error && error.message.includes('超时')) {
      throw error
    }
    // 重新抛出 store 操作的原始错误
    throw error
  }
}

export function createMockResponse<T>(data: T, options: {
  status?: number
  statusText?: string
  headers?: Record<string, string>
} = {}): Response {
  const {
    status = 200,
    statusText = 'OK',
    headers = { 'Content-Type': 'application/json' }
  } = options
  
  return new Response(JSON.stringify(data), {
    status,
    statusText,
    headers
  })
}

export function mockFetch(responses: Array<{
  url: string | RegExp
  response: any
  status?: number
  delay?: number
}>): void {
  global.fetch = vi.fn().mockImplementation(async (url: string) => {
    const match = responses.find(r => 
      typeof r.url === 'string' ? r.url === url : r.url.test(url)
    )
    
    if (!match) {
      throw new Error(`未找到 URL 的模拟响应: ${url}`)
    }
    
    if (match.delay) {
      await new Promise(resolve => setTimeout(resolve, match.delay))
    }
    
    return createMockResponse(match.response, { status: match.status })
  })
}

组件测试助手

typescript
// src/test/component-utils.ts
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import type { Component } from 'vue'

export interface ComponentTestOptions {
  props?: Record<string, any>
  slots?: Record<string, any>
  global?: {
    plugins?: any[]
    mocks?: Record<string, any>
    stubs?: Record<string, any>
  }
}

export function mountWithPinia(
  component: Component,
  options: ComponentTestOptions = {}
): VueWrapper {
  const pinia = createPinia()
  setActivePinia(pinia)
  
  return mount(component, {
    ...options,
    global: {
      plugins: [pinia, ...(options.global?.plugins || [])],
      ...options.global
    }
  })
}

export async function waitForAsyncComponent(
  wrapper: VueWrapper,
  timeout = 1000
): Promise<void> {
  await wrapper.vm.$nextTick()
  
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      reject(new Error(`组件异步操作在 ${timeout}ms 后超时`))
    }, timeout)
    
    const checkComplete = () => {
      if (!wrapper.vm.$el.querySelector('[data-testid="loading"]')) {
        clearTimeout(timer)
        resolve()
      } else {
        setTimeout(checkComplete, 10)
      }
    }
    
    checkComplete()
  })
}

单元测试示例

测试基本 Store 操作

typescript
// src/stores/__tests__/counter.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useCounterStore } from '@stores/counter'
import { setupStoreTest } from '@/test/store-utils'

describe('计数器 Store', () => {
  setupStoreTest()
  
  it('应该使用默认状态初始化', () => {
    const store = useCounterStore()
    
    expect(store.count).toBe(0)
    expect(store.doubleCount).toBe(0)
    expect(store.isEven).toBe(true)
  })
  
  it('应该递增计数', () => {
    const store = useCounterStore()
    
    store.increment()
    
    expect(store.count).toBe(1)
    expect(store.doubleCount).toBe(2)
    expect(store.isEven).toBe(false)
  })
  
  it('应该递减计数', () => {
    const store = useCounterStore()
    
    store.increment() // count = 1
    store.decrement() // count = 0
    
    expect(store.count).toBe(0)
    expect(store.isEven).toBe(true)
  })
  
  it('应该按自定义数量递增', () => {
    const store = useCounterStore()
    
    store.incrementBy(5)
    
    expect(store.count).toBe(5)
    expect(store.doubleCount).toBe(10)
  })
  
  it('应该重置计数', () => {
    const store = useCounterStore()
    
    store.incrementBy(10)
    store.reset()
    
    expect(store.count).toBe(0)
    expect(store.doubleCount).toBe(0)
  })
})

测试异步操作

typescript
// src/stores/__tests__/todos.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useTodosStore } from '@stores/todos'
import { setupStoreTest, createMockApi, waitForStoreAction } from '@/test/store-utils'
import type { Todo } from '@/types'

const mockTodosApi = createMockApi({
  fetchTodos: vi.fn(),
  createTodo: vi.fn(),
  updateTodo: vi.fn(),
  deleteTodo: vi.fn()
})

vi.mock('@/api/todos', () => ({
  todosApi: mockTodosApi
}))

describe('待办事项 Store', () => {
  setupStoreTest()
  
  const mockTodos: Todo[] = [
    {
      id: '1',
      title: '测试待办事项 1',
      completed: false,
      createdAt: new Date(),
      updatedAt: new Date()
    },
    {
      id: '2',
      title: '测试待办事项 2',
      completed: true,
      createdAt: new Date(),
      updatedAt: new Date()
    }
  ]
  
  beforeEach(() => {
    vi.clearAllMocks()
  })
  
  it('应该成功获取待办事项', async () => {
    const store = useTodosStore()
    
    mockTodosApi.fetchTodos.mockResolvedValue({
      data: mockTodos,
      message: '成功',
      success: true,
      timestamp: new Date()
    })
    
    await waitForStoreAction(() => store.fetchTodos())
    
    expect(store.todos).toEqual(mockTodos)
    expect(store.isLoading).toBe(false)
    expect(store.error).toBeNull()
    expect(mockTodosApi.fetchTodos).toHaveBeenCalledOnce()
  })
  
  it('应该处理获取待办事项错误', async () => {
    const store = useTodosStore()
    const error = new Error('网络错误')
    
    mockTodosApi.fetchTodos.mockRejectedValue(error)
    
    await expect(store.fetchTodos()).rejects.toThrow('网络错误')
    
    expect(store.todos).toEqual([])
    expect(store.isLoading).toBe(false)
    expect(store.hasError).toBe(true)
    expect(store.error).toBe('网络错误')
  })
  
  it('应该使用乐观更新创建待办事项', async () => {
    const store = useTodosStore()
    const newTodo = {
      title: '新待办事项',
      completed: false
    }
    
    const createdTodo: Todo = {
      id: '3',
      ...newTodo,
      createdAt: new Date(),
      updatedAt: new Date()
    }
    
    mockTodosApi.createTodo.mockResolvedValue({
      data: createdTodo,
      message: '已创建',
      success: true,
      timestamp: new Date()
    })
    
    // 检查乐观更新
    const createPromise = store.createTodo(newTodo)
    
    // 应该立即有临时待办事项
    expect(store.todos).toHaveLength(1)
    expect(store.todos[0].title).toBe(newTodo.title)
    expect(store.todos[0].id).toMatch(/^temp-/)
    
    await waitForStoreAction(() => createPromise)
    
    // 应该用真实的待办事项替换临时的
    expect(store.todos).toHaveLength(1)
    expect(store.todos[0]).toEqual(createdTodo)
    expect(mockTodosApi.createTodo).toHaveBeenCalledWith(newTodo)
  })
  
  it('应该在创建错误时回滚乐观更新', async () => {
    const store = useTodosStore()
    const newTodo = {
      title: '新待办事项',
      completed: false
    }
    
    mockTodosApi.createTodo.mockRejectedValue(new Error('创建失败'))
    
    const createPromise = store.createTodo(newTodo)
    
    // 应该有临时待办事项
    expect(store.todos).toHaveLength(1)
    
    await expect(createPromise).rejects.toThrow('创建失败')
    
    // 应该回滚到空状态
    expect(store.todos).toHaveLength(0)
    expect(store.hasError).toBe(true)
  })
  
  it('应该更新待办事项', async () => {
    const store = useTodosStore()
    
    // 设置初始状态
    store.$patch({ todos: [...mockTodos] })
    
    const updates = { completed: true }
    const updatedTodo = { ...mockTodos[0], ...updates, updatedAt: new Date() }
    
    mockTodosApi.updateTodo.mockResolvedValue({
      data: updatedTodo,
      message: '已更新',
      success: true,
      timestamp: new Date()
    })
    
    await waitForStoreAction(() => store.updateTodo('1', updates))
    
    expect(store.todos[0]).toEqual(updatedTodo)
    expect(mockTodosApi.updateTodo).toHaveBeenCalledWith('1', updates)
  })
  
  it('应该删除待办事项', async () => {
    const store = useTodosStore()
    
    // 设置初始状态
    store.$patch({ todos: [...mockTodos] })
    
    mockTodosApi.deleteTodo.mockResolvedValue({
      data: null,
      message: '已删除',
      success: true,
      timestamp: new Date()
    })
    
    await waitForStoreAction(() => store.deleteTodo('1'))
    
    expect(store.todos).toHaveLength(1)
    expect(store.todos[0].id).toBe('2')
    expect(mockTodosApi.deleteTodo).toHaveBeenCalledWith('1')
  })
  
  it('应该正确过滤待办事项', () => {
    const store = useTodosStore()
    
    store.$patch({ todos: [...mockTodos] })
    
    expect(store.completedTodos).toHaveLength(1)
    expect(store.completedTodos[0].id).toBe('2')
    
    expect(store.pendingTodos).toHaveLength(1)
    expect(store.pendingTodos[0].id).toBe('1')
    
    expect(store.completionRate).toBe(0.5)
  })
})

测试 Store 组合

typescript
// src/stores/__tests__/user-profile.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useUserStore } from '@stores/user'
import { useProfileStore } from '@stores/profile'
import { setupStoreTest, createMockApi } from '@/test/store-utils'

const mockUserApi = createMockApi({
  login: vi.fn(),
  logout: vi.fn()
})

const mockProfileApi = createMockApi({
  getProfile: vi.fn(),
  updateProfile: vi.fn()
})

vi.mock('@/api/user', () => ({ userApi: mockUserApi }))
vi.mock('@/api/profile', () => ({ profileApi: mockProfileApi }))

describe('Store 组合 - 用户资料', () => {
  setupStoreTest()
  
  it('应该在用户登录时同步资料数据', async () => {
    const userStore = useUserStore()
    const profileStore = useProfileStore()
    
    const mockUser = {
      id: '1',
      email: 'test@example.com',
      firstName: '张',
      lastName: '三'
    }
    
    const mockProfile = {
      userId: '1',
      bio: '测试简介',
      avatar: 'avatar.jpg',
      preferences: {
        theme: 'dark',
        language: 'zh'
      }
    }
    
    mockUserApi.login.mockResolvedValue({
      data: { user: mockUser, token: 'token', expiresAt: new Date().toISOString() },
      message: '成功',
      success: true,
      timestamp: new Date()
    })
    
    mockProfileApi.getProfile.mockResolvedValue({
      data: mockProfile,
      message: '成功',
      success: true,
      timestamp: new Date()
    })
    
    await userStore.login({ email: 'test@example.com', password: 'password' })
    
    // 登录后应该自动获取资料
    expect(profileStore.profile).toEqual(mockProfile)
    expect(mockProfileApi.getProfile).toHaveBeenCalledWith('1')
  })
  
  it('应该在用户登出时清除资料数据', async () => {
    const userStore = useUserStore()
    const profileStore = useProfileStore()
    
    // 设置初始状态
    profileStore.$patch({
      profile: {
        userId: '1',
        bio: '测试简介',
        avatar: 'avatar.jpg',
        preferences: { theme: 'dark', language: 'zh' }
      }
    })
    
    await userStore.logout()
    
    expect(profileStore.profile).toBeNull()
  })
  
  it('应该处理资料更新并与用户 store 同步', async () => {
    const userStore = useUserStore()
    const profileStore = useProfileStore()
    
    // 设置初始状态
    userStore.$patch({
      user: {
        id: '1',
        email: 'test@example.com',
        firstName: '张',
        lastName: '三'
      },
      isAuthenticated: true
    })
    
    const updatedProfile = {
      userId: '1',
      bio: '更新的简介',
      avatar: 'new-avatar.jpg',
      preferences: { theme: 'light', language: 'en' }
    }
    
    mockProfileApi.updateProfile.mockResolvedValue({
      data: updatedProfile,
      message: '已更新',
      success: true,
      timestamp: new Date()
    })
    
    await profileStore.updateProfile({
      bio: '更新的简介',
      avatar: 'new-avatar.jpg'
    })
    
    expect(profileStore.profile).toEqual(updatedProfile)
    expect(userStore.user?.preferences).toEqual(updatedProfile.preferences)
  })
})

集成测试

测试组件-Store 集成

typescript
// src/components/__tests__/TodoList.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mountWithPinia, waitForAsyncComponent } from '@/test/component-utils'
import { createMockApi, mockFetch } from '@/test/store-utils'
import TodoList from '@/components/TodoList.vue'
import { useTodosStore } from '@stores/todos'

const mockTodosApi = createMockApi({
  fetchTodos: vi.fn(),
  createTodo: vi.fn(),
  updateTodo: vi.fn(),
  deleteTodo: vi.fn()
})

vi.mock('@/api/todos', () => ({
  todosApi: mockTodosApi
}))

describe('TodoList 组件集成', () => {
  const mockTodos = [
    {
      id: '1',
      title: '测试待办事项 1',
      completed: false,
      createdAt: new Date(),
      updatedAt: new Date()
    },
    {
      id: '2',
      title: '测试待办事项 2',
      completed: true,
      createdAt: new Date(),
      updatedAt: new Date()
    }
  ]
  
  beforeEach(() => {
    vi.clearAllMocks()
  })
  
  it('应该从 store 渲染待办事项', async () => {
    mockTodosApi.fetchTodos.mockResolvedValue({
      data: mockTodos,
      message: '成功',
      success: true,
      timestamp: new Date()
    })
    
    const wrapper = mountWithPinia(TodoList)
    await waitForAsyncComponent(wrapper)
    
    const todoItems = wrapper.findAll('[data-testid="todo-item"]')
    expect(todoItems).toHaveLength(2)
    
    expect(todoItems[0].text()).toContain('测试待办事项 1')
    expect(todoItems[1].text()).toContain('测试待办事项 2')
  })
  
  it('应该在表单提交时创建新待办事项', async () => {
    const newTodo = {
      id: '3',
      title: '新待办事项',
      completed: false,
      createdAt: new Date(),
      updatedAt: new Date()
    }
    
    mockTodosApi.fetchTodos.mockResolvedValue({
      data: [],
      message: '成功',
      success: true,
      timestamp: new Date()
    })
    
    mockTodosApi.createTodo.mockResolvedValue({
      data: newTodo,
      message: '已创建',
      success: true,
      timestamp: new Date()
    })
    
    const wrapper = mountWithPinia(TodoList)
    await waitForAsyncComponent(wrapper)
    
    const input = wrapper.find('[data-testid="todo-input"]')
    const form = wrapper.find('[data-testid="todo-form"]')
    
    await input.setValue('新待办事项')
    await form.trigger('submit')
    
    expect(mockTodosApi.createTodo).toHaveBeenCalledWith({
      title: '新待办事项',
      completed: false
    })
  })
  
  it('应该切换待办事项完成状态', async () => {
    mockTodosApi.fetchTodos.mockResolvedValue({
      data: mockTodos,
      message: '成功',
      success: true,
      timestamp: new Date()
    })
    
    mockTodosApi.updateTodo.mockResolvedValue({
      data: { ...mockTodos[0], completed: true },
      message: '已更新',
      success: true,
      timestamp: new Date()
    })
    
    const wrapper = mountWithPinia(TodoList)
    await waitForAsyncComponent(wrapper)
    
    const checkbox = wrapper.find('[data-testid="todo-checkbox-1"]')
    await checkbox.setChecked(true)
    
    expect(mockTodosApi.updateTodo).toHaveBeenCalledWith('1', {
      completed: true
    })
  })
  
  it('应该显示加载状态', async () => {
    // 模拟慢速 API 响应
    mockTodosApi.fetchTodos.mockImplementation(() => 
      new Promise(resolve => 
        setTimeout(() => resolve({
          data: mockTodos,
          message: '成功',
          success: true,
          timestamp: new Date()
        }), 100)
      )
    )
    
    const wrapper = mountWithPinia(TodoList)
    
    // 初始应该显示加载
    expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true)
    
    await waitForAsyncComponent(wrapper)
    
    // 数据加载后应该隐藏加载
    expect(wrapper.find('[data-testid="loading"]').exists()).toBe(false)
  })
  
  it('应该显示错误状态', async () => {
    mockTodosApi.fetchTodos.mockRejectedValue(new Error('网络错误'))
    
    const wrapper = mountWithPinia(TodoList)
    await waitForAsyncComponent(wrapper)
    
    expect(wrapper.find('[data-testid="error"]').exists()).toBe(true)
    expect(wrapper.find('[data-testid="error"]').text()).toContain('网络错误')
  })
})

测试 Store 持久化

typescript
// src/stores/__tests__/persistence.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { useUserStore } from '@stores/user'
import { setupStoreTest } from '@/test/store-utils'

describe('Store 持久化', () => {
  setupStoreTest()
  
  beforeEach(() => {
    localStorage.clear()
    sessionStorage.clear()
  })
  
  afterEach(() => {
    localStorage.clear()
    sessionStorage.clear()
  })
  
  it('应该将用户数据持久化到 localStorage', () => {
    const store = useUserStore()
    
    const userData = {
      id: '1',
      email: 'test@example.com',
      firstName: '张',
      lastName: '三'
    }
    
    store.$patch({ user: userData, isAuthenticated: true })
    
    const stored = localStorage.getItem('user-store')
    expect(stored).toBeTruthy()
    
    const parsed = JSON.parse(stored!)
    expect(parsed.user).toEqual(userData)
    expect(parsed.isAuthenticated).toBe(true)
  })
  
  it('应该从 localStorage 恢复用户数据', () => {
    const userData = {
      id: '1',
      email: 'test@example.com',
      firstName: '张',
      lastName: '三'
    }
    
    localStorage.setItem('user-store', JSON.stringify({
      user: userData,
      isAuthenticated: true
    }))
    
    const store = useUserStore()
    
    expect(store.user).toEqual(userData)
    expect(store.isAuthenticated).toBe(true)
  })
  
  it('应该处理损坏的 localStorage 数据', () => {
    localStorage.setItem('user-store', 'invalid-json')
    
    const store = useUserStore()
    
    // 应该回退到默认状态
    expect(store.user).toBeNull()
    expect(store.isAuthenticated).toBe(false)
  })
  
  it('应该在登出时清除持久化', async () => {
    const store = useUserStore()
    
    store.$patch({
      user: { id: '1', email: 'test@example.com' },
      isAuthenticated: true
    })
    
    expect(localStorage.getItem('user-store')).toBeTruthy()
    
    await store.logout()
    
    expect(localStorage.getItem('user-store')).toBeNull()
  })
})

端到端测试

Playwright E2E 测试

typescript
// tests/e2e/todos.spec.ts
import { test, expect } from '@playwright/test'

test.describe('待办事项应用 E2E', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/')
  })
  
  test('应该创建和管理待办事项', async ({ page }) => {
    // 创建新待办事项
    await page.fill('[data-testid="todo-input"]', '买菜')
    await page.click('[data-testid="add-todo-btn"]')
    
    // 验证待办事项出现在列表中
    await expect(page.locator('[data-testid="todo-item"]')).toContainText('买菜')
    
    // 标记待办事项为已完成
    await page.check('[data-testid="todo-checkbox"]')
    
    // 验证待办事项被标记为已完成
    await expect(page.locator('[data-testid="todo-item"]')).toHaveClass(/completed/)
    
    // 删除待办事项
    await page.click('[data-testid="delete-todo-btn"]')
    
    // 验证待办事项被移除
    await expect(page.locator('[data-testid="todo-item"]')).toHaveCount(0)
  })
  
  test('应该在页面重新加载后保持待办事项', async ({ page }) => {
    // 创建待办事项
    await page.fill('[data-testid="todo-input"]', '持久化待办事项')
    await page.click('[data-testid="add-todo-btn"]')
    
    // 重新加载页面
    await page.reload()
    
    // 验证待办事项持久化
    await expect(page.locator('[data-testid="todo-item"]')).toContainText('持久化待办事项')
  })
  
  test('应该正确过滤待办事项', async ({ page }) => {
    // 创建多个待办事项
    await page.fill('[data-testid="todo-input"]', '待办事项 1')
    await page.click('[data-testid="add-todo-btn"]')
    
    await page.fill('[data-testid="todo-input"]', '待办事项 2')
    await page.click('[data-testid="add-todo-btn"]')
    
    // 标记第一个待办事项为已完成
    await page.check('[data-testid="todo-checkbox"]:first-child')
    
    // 按已完成过滤
    await page.click('[data-testid="filter-completed"]')
    
    // 应该只显示已完成的待办事项
    await expect(page.locator('[data-testid="todo-item"]')).toHaveCount(1)
    await expect(page.locator('[data-testid="todo-item"]')).toContainText('待办事项 1')
    
    // 按活跃过滤
    await page.click('[data-testid="filter-active"]')
    
    // 应该只显示活跃的待办事项
    await expect(page.locator('[data-testid="todo-item"]')).toHaveCount(1)
    await expect(page.locator('[data-testid="todo-item"]')).toContainText('待办事项 2')
  })
  
  test('应该优雅地处理网络错误', async ({ page }) => {
    // 拦截 API 调用并模拟网络错误
    await page.route('**/api/todos', route => {
      route.abort('failed')
    })
    
    await page.goto('/')
    
    // 应该显示错误消息
    await expect(page.locator('[data-testid="error-message"]')).toBeVisible()
    
    // 应该显示重试按钮
    await expect(page.locator('[data-testid="retry-btn"]')).toBeVisible()
  })
})

性能测试

Store 性能测试

typescript
// src/stores/__tests__/performance.test.ts
import { describe, it, expect } from 'vitest'
import { useProductsStore } from '@stores/products'
import { setupStoreTest } from '@/test/store-utils'

describe('Store 性能', () => {
  setupStoreTest()
  
  it('应该高效处理大型数据集', () => {
    const store = useProductsStore()
    
    // 生成大型数据集
    const products = Array.from({ length: 10000 }, (_, i) => ({
      id: `product-${i}`,
      name: `产品 ${i}`,
      price: Math.random() * 100,
      category: `分类 ${i % 10}`,
      inStock: Math.random() > 0.5
    }))
    
    const start = performance.now()
    
    store.$patch({ products })
    
    const patchTime = performance.now() - start
    
    // 应该快速修补大型数据集(< 100ms)
    expect(patchTime).toBeLessThan(100)
    
    const filterStart = performance.now()
    
    // 测试过滤性能
    store.setFilters({ category: '分类 1', inStock: true })
    const filtered = store.filteredProducts
    
    const filterTime = performance.now() - filterStart
    
    // 应该快速过滤(< 50ms)
    expect(filterTime).toBeLessThan(50)
    expect(filtered.length).toBeGreaterThan(0)
  })
  
  it('应该防抖搜索操作', async () => {
    const store = useProductsStore()
    
    let searchCallCount = 0
    const originalSearch = store.searchProducts
    
    store.searchProducts = (...args) => {
      searchCallCount++
      return originalSearch.apply(store, args)
    }
    
    // 快速搜索调用
    store.searchProducts('test')
    store.searchProducts('test1')
    store.searchProducts('test12')
    store.searchProducts('test123')
    
    // 等待防抖
    await new Promise(resolve => setTimeout(resolve, 350))
    
    // 由于防抖,应该只调用一次搜索
    expect(searchCallCount).toBe(1)
  })
})

覆盖率和报告

覆盖率配置

json
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage",
    "test:coverage:ui": "vitest --coverage --ui",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

自定义测试报告器

typescript
// src/test/reporter.ts
import type { Reporter } from 'vitest'

export class StoreTestReporter implements Reporter {
  onInit() {
    console.log('🧪 开始 Pinia store 测试...')
  }
  
  onFinished(files, errors) {
    const storeTests = files.filter(file => 
      file.name.includes('stores/__tests__')
    )
    
    const totalStoreTests = storeTests.reduce(
      (acc, file) => acc + (file.result?.numTotalTests || 0),
      0
    )
    
    const passedStoreTests = storeTests.reduce(
      (acc, file) => acc + (file.result?.numPassedTests || 0),
      0
    )
    
    console.log(`\n📊 Store 测试摘要:`)
    console.log(`   总 store 测试数: ${totalStoreTests}`)
    console.log(`   通过: ${passedStoreTests}`)
    console.log(`   失败: ${totalStoreTests - passedStoreTests}`)
    
    if (errors.length > 0) {
      console.log(`\n❌ 错误: ${errors.length}`)
    }
  }
}

这个综合测试指南提供了彻底测试 Pinia store 的模式和示例,从基本单元测试到复杂的集成场景和端到端测试策略。

Released under the MIT License.