Skip to content

组件测试

测试使用 Pinia store 的 Vue 组件需要特别考虑,以确保测试是隔离的、可靠的和可维护的。本指南涵盖了使用 Pinia store 测试组件的各种策略。

基本设置

测试环境配置

首先,使用必要的依赖项设置测试环境:

bash
npm install -D @vue/test-utils vitest jsdom
# 或
npm install -D @vue/test-utils jest @vue/vue3-jest

Vitest 配置

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

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true
  }
})

测试策略

1. 使用真实 Store 进行测试

对于集成风格的测试,你可以使用真实的 store:

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

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    }
  }
})
vue
<!-- components/Counter.vue -->
<template>
  <div>
    <span data-testid="count">{{ store.count }}</span>
    <button data-testid="increment" @click="store.increment">+</button>
    <button data-testid="decrement" @click="store.decrement">-</button>
  </div>
</template>

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

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

describe('Counter 组件', () => {
  let wrapper
  let store

  beforeEach(() => {
    // 为每个测试创建新的 pinia 实例
    setActivePinia(createPinia())
    
    wrapper = mount(Counter, {
      global: {
        plugins: [createPinia()]
      }
    })
    
    store = useCounterStore()
  })

  it('显示当前计数', () => {
    expect(wrapper.get('[data-testid="count"]').text()).toBe('0')
  })

  it('点击增加按钮时增加计数', async () => {
    await wrapper.get('[data-testid="increment"]').trigger('click')
    expect(wrapper.get('[data-testid="count"]').text()).toBe('1')
    expect(store.count).toBe(1)
  })

  it('点击减少按钮时减少计数', async () => {
    store.count = 5 // 设置初始状态
    await wrapper.vm.$nextTick()
    
    await wrapper.get('[data-testid="decrement"]').trigger('click')
    expect(wrapper.get('[data-testid="count"]').text()).toBe('4')
    expect(store.count).toBe(4)
  })
})

2. 模拟 Store

对于单元测试,你可能想要模拟 store 来隔离组件逻辑:

js
// tests/Counter.mock.test.js
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { vi, beforeEach, describe, it, expect } from 'vitest'
import Counter from '@/components/Counter.vue'

// 模拟 store
vi.mock('@/stores/counter', () => ({
  useCounterStore: vi.fn(() => ({
    count: 0,
    increment: vi.fn(),
    decrement: vi.fn()
  }))
}))

describe('Counter 组件(模拟)', () => {
  let wrapper
  let mockStore

  beforeEach(() => {
    setActivePinia(createPinia())
    
    // 在模拟后导入
    const { useCounterStore } = await import('@/stores/counter')
    mockStore = useCounterStore()
    
    wrapper = mount(Counter, {
      global: {
        plugins: [createPinia()]
      }
    })
  })

  it('点击增加按钮时调用 increment', async () => {
    await wrapper.get('[data-testid="increment"]').trigger('click')
    expect(mockStore.increment).toHaveBeenCalledOnce()
  })

  it('点击减少按钮时调用 decrement', async () => {
    await wrapper.get('[data-testid="decrement"]').trigger('click')
    expect(mockStore.decrement).toHaveBeenCalledOnce()
  })
})

3. 部分 Store 模拟

有时你只想模拟 store 的特定部分:

js
// tests/Counter.partial-mock.test.js
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { vi, beforeEach, describe, it, expect } from 'vitest'
import Counter from '@/components/Counter.vue'
import { useCounterStore } from '@/stores/counter'

describe('Counter 组件(部分模拟)', () => {
  let wrapper
  let store

  beforeEach(() => {
    setActivePinia(createPinia())
    
    wrapper = mount(Counter, {
      global: {
        plugins: [createPinia()]
      }
    })
    
    store = useCounterStore()
    
    // 模拟特定的 action,同时保持状态响应式
    store.increment = vi.fn(() => {
      store.count++
    })
    store.decrement = vi.fn(() => {
      store.count--
    })
  })

  it('增加计数并调用模拟的 action', async () => {
    await wrapper.get('[data-testid="increment"]').trigger('click')
    
    expect(store.increment).toHaveBeenCalledOnce()
    expect(store.count).toBe(1)
    expect(wrapper.get('[data-testid="count"]').text()).toBe('1')
  })
})

测试复杂组件

使用多个 Store 的组件

vue
<!-- components/UserProfile.vue -->
<template>
  <div>
    <div v-if="userStore.isLoading">加载中...</div>
    <div v-else-if="userStore.error">错误:{{ userStore.error }}</div>
    <div v-else>
      <h1>{{ userStore.user?.name }}</h1>
      <p>购物车商品:{{ cartStore.itemCount }}</p>
      <button @click="logout">退出登录</button>
    </div>
  </div>
</template>

<script setup>
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'

const userStore = useUserStore()
const cartStore = useCartStore()

const logout = async () => {
  await userStore.logout()
  cartStore.clearCart()
}
</script>
js
// tests/UserProfile.test.js
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { vi, beforeEach, describe, it, expect } from 'vitest'
import UserProfile from '@/components/UserProfile.vue'
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'

describe('UserProfile 组件', () => {
  let wrapper
  let userStore
  let cartStore

  beforeEach(() => {
    setActivePinia(createPinia())
    
    wrapper = mount(UserProfile, {
      global: {
        plugins: [createPinia()]
      }
    })
    
    userStore = useUserStore()
    cartStore = useCartStore()
    
    // 模拟异步 action
    userStore.logout = vi.fn().mockResolvedValue()
    cartStore.clearCart = vi.fn()
  })

  it('显示加载状态', async () => {
    userStore.isLoading = true
    await wrapper.vm.$nextTick()
    
    expect(wrapper.text()).toContain('加载中...')
  })

  it('显示错误状态', async () => {
    userStore.error = '加载用户失败'
    await wrapper.vm.$nextTick()
    
    expect(wrapper.text()).toContain('错误:加载用户失败')
  })

  it('加载完成时显示用户资料', async () => {
    userStore.user = { name: '张三' }
    cartStore.itemCount = 3
    await wrapper.vm.$nextTick()
    
    expect(wrapper.text()).toContain('张三')
    expect(wrapper.text()).toContain('购物车商品:3')
  })

  it('正确处理退出登录', async () => {
    const logoutButton = wrapper.find('button')
    await logoutButton.trigger('click')
    
    expect(userStore.logout).toHaveBeenCalledOnce()
    expect(cartStore.clearCart).toHaveBeenCalledOnce()
  })
})

测试异步操作

vue
<!-- components/ProductList.vue -->
<template>
  <div>
    <button @click="loadProducts" :disabled="store.isLoading">
      {{ store.isLoading ? '加载中...' : '加载产品' }}
    </button>
    
    <div v-if="store.error" class="error">
      {{ store.error }}
    </div>
    
    <ul v-if="store.products.length">
      <li v-for="product in store.products" :key="product.id">
        {{ product.name }} - ¥{{ product.price }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { useProductStore } from '@/stores/product'

const store = useProductStore()

const loadProducts = async () => {
  try {
    await store.fetchProducts()
  } catch (error) {
    console.error('加载产品失败:', error)
  }
}
</script>
js
// tests/ProductList.test.js
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { vi, beforeEach, describe, it, expect } from 'vitest'
import ProductList from '@/components/ProductList.vue'
import { useProductStore } from '@/stores/product'

describe('ProductList 组件', () => {
  let wrapper
  let store

  beforeEach(() => {
    setActivePinia(createPinia())
    
    wrapper = mount(ProductList, {
      global: {
        plugins: [createPinia()]
      }
    })
    
    store = useProductStore()
  })

  it('成功加载产品', async () => {
    const mockProducts = [
      { id: 1, name: '产品 1', price: 10 },
      { id: 2, name: '产品 2', price: 20 }
    ]
    
    store.fetchProducts = vi.fn().mockImplementation(async () => {
      store.isLoading = true
      await new Promise(resolve => setTimeout(resolve, 100))
      store.products = mockProducts
      store.isLoading = false
    })
    
    const button = wrapper.find('button')
    await button.trigger('click')
    
    // 检查加载状态
    expect(button.text()).toBe('加载中...')
    expect(button.attributes('disabled')).toBeDefined()
    
    // 等待异步操作
    await vi.runAllTimers()
    await wrapper.vm.$nextTick()
    
    expect(store.fetchProducts).toHaveBeenCalledOnce()
    expect(wrapper.text()).toContain('产品 1 - ¥10')
    expect(wrapper.text()).toContain('产品 2 - ¥20')
  })

  it('处理获取错误', async () => {
    const errorMessage = '网络错误'
    
    store.fetchProducts = vi.fn().mockImplementation(async () => {
      store.isLoading = true
      await new Promise(resolve => setTimeout(resolve, 100))
      store.error = errorMessage
      store.isLoading = false
      throw new Error(errorMessage)
    })
    
    const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    
    const button = wrapper.find('button')
    await button.trigger('click')
    
    await vi.runAllTimers()
    await wrapper.vm.$nextTick()
    
    expect(wrapper.find('.error').text()).toBe(errorMessage)
    expect(consoleSpy).toHaveBeenCalledWith('加载产品失败:', expect.any(Error))
    
    consoleSpy.mockRestore()
  })
})

使用组合式 API 进行测试

测试自定义组合式函数

js
// composables/useCart.js
import { computed } from 'vue'
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'

export function useCart() {
  const cartStore = useCartStore()
  const userStore = useUserStore()
  
  const totalWithDiscount = computed(() => {
    const discount = userStore.user?.isPremium ? 0.1 : 0
    return cartStore.total * (1 - discount)
  })
  
  const addItem = async (product, quantity = 1) => {
    if (!userStore.user) {
      throw new Error('用户必须登录')
    }
    
    await cartStore.addItem(product, quantity)
  }
  
  return {
    items: cartStore.items,
    total: cartStore.total,
    totalWithDiscount,
    addItem
  }
}
js
// tests/useCart.test.js
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { useCart } from '@/composables/useCart'
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'

describe('useCart 组合式函数', () => {
  let cartStore
  let userStore

  beforeEach(() => {
    setActivePinia(createPinia())
    cartStore = useCartStore()
    userStore = useUserStore()
  })

  it('为高级用户计算折扣后总价', () => {
    cartStore.total = 100
    userStore.user = { isPremium: true }
    
    const { totalWithDiscount } = useCart()
    
    expect(totalWithDiscount.value).toBe(90) // 10% 折扣
  })

  it('为普通用户计算无折扣总价', () => {
    cartStore.total = 100
    userStore.user = { isPremium: false }
    
    const { totalWithDiscount } = useCart()
    
    expect(totalWithDiscount.value).toBe(100) // 无折扣
  })

  it('未登录用户添加商品时抛出错误', async () => {
    userStore.user = null
    
    const { addItem } = useCart()
    
    await expect(addItem({ id: 1, name: '产品' })).rejects.toThrow('用户必须登录')
  })

  it('用户登录时添加商品', async () => {
    userStore.user = { id: 1, name: '张三' }
    cartStore.addItem = vi.fn().mockResolvedValue()
    
    const { addItem } = useCart()
    const product = { id: 1, name: '产品' }
    
    await addItem(product, 2)
    
    expect(cartStore.addItem).toHaveBeenCalledWith(product, 2)
  })
})

测试工具

测试辅助函数

js
// tests/helpers/pinia-test-utils.js
import { createPinia, setActivePinia } from 'pinia'
import { mount } from '@vue/test-utils'

/**
 * 为测试创建新的 Pinia 实例
 */
export function createTestPinia() {
  const pinia = createPinia()
  setActivePinia(pinia)
  return pinia
}

/**
 * 使用新的 Pinia 实例挂载组件
 */
export function mountWithPinia(component, options = {}) {
  const pinia = createTestPinia()
  
  return mount(component, {
    global: {
      plugins: [pinia],
      ...options.global
    },
    ...options
  })
}

/**
 * 创建具有默认实现的模拟 store
 */
export function createMockStore(storeName, initialState = {}) {
  return {
    $id: storeName,
    $state: { ...initialState },
    $patch: vi.fn(),
    $reset: vi.fn(),
    $subscribe: vi.fn(),
    $onAction: vi.fn()
  }
}

使用测试辅助函数

js
// tests/Counter.helper.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import Counter from '@/components/Counter.vue'
import { useCounterStore } from '@/stores/counter'
import { mountWithPinia } from './helpers/pinia-test-utils'

describe('Counter 组件(使用辅助函数)', () => {
  let wrapper
  let store

  beforeEach(() => {
    wrapper = mountWithPinia(Counter)
    store = useCounterStore()
  })

  it('显示当前计数', () => {
    expect(wrapper.get('[data-testid="count"]').text()).toBe('0')
  })

  it('点击增加按钮时增加计数', async () => {
    await wrapper.get('[data-testid="increment"]').trigger('click')
    expect(store.count).toBe(1)
  })
})

最佳实践

1. 隔离测试

始终为每个测试创建新的 Pinia 实例:

js
beforeEach(() => {
  setActivePinia(createPinia())
})

2. 测试行为,而非实现

专注于组件做什么,而不是如何做:

js
// ✅ 好的做法 - 测试行为
it('登录时显示用户名', async () => {
  userStore.user = { name: '张三' }
  await wrapper.vm.$nextTick()
  expect(wrapper.text()).toContain('张三')
})

// ❌ 避免 - 测试实现细节
it('调用 useUserStore', () => {
  expect(useUserStore).toHaveBeenCalled()
})

3. 使用数据测试 ID

使用 data-testid 属性进行可靠的元素选择:

vue
<template>
  <button data-testid="submit-button" @click="submit">
    提交
  </button>
</template>
js
const submitButton = wrapper.get('[data-testid="submit-button"]')

4. 模拟外部依赖

模拟 API 调用和外部服务:

js
// 全局模拟 fetch
global.fetch = vi.fn()

// 或模拟特定模块
vi.mock('@/api/products', () => ({
  fetchProducts: vi.fn().mockResolvedValue([])
}))

5. 测试错误状态

不要忘记测试错误场景:

js
it('获取失败时显示错误消息', async () => {
  store.fetchProducts = vi.fn().mockRejectedValue(new Error('网络错误'))
  
  await wrapper.find('button').trigger('click')
  await wrapper.vm.$nextTick()
  
  expect(wrapper.find('.error').exists()).toBe(true)
})

常见模式

测试 Store 订阅

js
it('响应 store 变化', async () => {
  const callback = vi.fn()
  store.$subscribe(callback)
  
  store.count = 5
  await wrapper.vm.$nextTick()
  
  expect(callback).toHaveBeenCalled()
  expect(wrapper.get('[data-testid="count"]').text()).toBe('5')
})

测试带副作用的 Store Action

js
it('成功 API 调用后更新 UI', async () => {
  const mockUser = { id: 1, name: '张三' }
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve(mockUser)
  })
  
  await store.fetchUser(1)
  await wrapper.vm.$nextTick()
  
  expect(wrapper.text()).toContain('张三')
  expect(store.user).toEqual(mockUser)
})

测试计算属性

js
it('依赖项变化时更新计算值', async () => {
  store.items = [
    { price: 10, quantity: 2 },
    { price: 20, quantity: 1 }
  ]
  
  await wrapper.vm.$nextTick()
  
  expect(wrapper.get('[data-testid="total"]').text()).toBe('40')
  
  store.items[0].quantity = 3
  await wrapper.vm.$nextTick()
  
  expect(wrapper.get('[data-testid="total"]').text()).toBe('50')
})

总结

使用 Pinia store 测试组件需要仔细考虑隔离、模拟策略和测试组织。通过遵循这些模式和最佳实践,你可以创建可靠、可维护的测试,让你对应用程序的行为充满信心。

记住要:

  • 始终为每个测试使用新的 Pinia 实例
  • 测试行为而不是实现细节
  • 适当地模拟外部依赖
  • 测试成功和错误场景
  • 使用辅助函数减少样板代码

更多测试策略,请参阅:

Released under the MIT License.