Vue.js

Vue 3 + TypeScript + Pinia + Vitest:状态管理、组件测试与持续集成

通过真实 Store 测试、组件交互、异步错误与 CI 门禁,建立可维护的 Vue 3 TypeScript 测试体系。

TY
Tycho
技术博主
• 2026-09-27 • 24 分钟阅读 • 1 次浏览
Vue 3 + TypeScript + Pinia + Vitest:状态管理、组件测试与持续集成

一、测试目标与分层策略

测试的目标是让重构有反馈,而不是追求覆盖率数字。纯函数和 Store 状态转换放在快速单元测试,组件验证用户可见交互,少量端到端用例覆盖登录、创建和刷新等关键旅程。

每个缺陷优先在成本最低且能稳定复现的层级补测试。DOM 细节、内部方法名和快照不应成为主要契约;用户可见文本、可访问角色、状态变化和 API 边界才是长期契约。

少量 E2E:登录 → 创建 → 刷新后仍存在
中量组件测试:输入、点击、错误、可访问性
大量单元测试:纯函数、Store action/getter、边界条件

二、创建项目并固定依赖

提交 lockfile,并在 CI 使用 npm ci。Node 版本用 .nvmrc、Volta 或 CI 镜像固定;本地与 CI 版本漂移会造成不可复现的依赖、快照和 ESM 行为。

npm create vite@latest task-board -- --template vue-ts
cd task-board
npm install
npm install pinia
npm install -D vitest @vue/test-utils jsdom @pinia/testing vue-tsc
npm pkg set scripts.test='vitest run'
npm pkg set scripts.typecheck='vue-tsc --noEmit'

三、配置 Vitest 与 jsdom

setup 文件集中清理浏览器状态和全局 mock,不要让单个用例依赖执行顺序。覆盖率只统计产品源码,生成目录和类型声明不计入。

// vite.config.ts
export default defineConfig({
  plugins: [vue()],
  resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
  test: {
    environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'],
    coverage: { reporter: ['text','html'], include: ['src/**/*.{ts,vue}'] },
  },
})
// src/test/setup.ts
import { afterEach, vi } from 'vitest'
afterEach(() => { localStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals() })

四、实现类型明确的 Pinia Store

Store 处理领域状态,不直接读写 DOM。HTTP、时钟和存储封装成依赖,测试时替换。输入在边界归一化,找不到实体时明确抛错,不能静默失败。

export type Task = { id:string; title:string; done:boolean }
export const useTaskStore = defineStore('tasks', () => {
  const tasks = ref<Task[]>([])
  const pendingCount = computed(() => tasks.value.filter(t => !t.done).length)
  function add(title:string) {
    const value = title.trim(); if (!value) throw new Error('title_required')
    tasks.value.push({ id:crypto.randomUUID(), title:value, done:false })
  }
  function toggle(id:string) {
    const task = tasks.value.find(t => t.id === id)
    if (!task) throw new Error('task_not_found'); task.done = !task.done
  }
  return { tasks, pendingCount, add, toggle }
})

五、测试真实 Action 与 Getter

Store 单元测试使用 createPinia 和 setActivePinia,让 action 真实运行。覆盖正常、边界和错误路径,断言结果状态而不是内部实现。

describe('task store', () => {
  beforeEach(() => setActivePinia(createPinia()))
  it('normalizes and toggles', () => {
    const store = useTaskStore(); store.add('  read docs  ')
    expect(store.tasks[0].title).toBe('read docs')
    expect(store.pendingCount).toBe(1)
    store.toggle(store.tasks[0].id)
    expect(store.pendingCount).toBe(0)
  })
  it('rejects empty title', () => {
    expect(() => useTaskStore().add('   ')).toThrow('title_required')
  })
})

六、按用户行为测试组件

createTestingPinia 默认把 action 替换为 spy,适合验证组件是否发出正确命令;若要在组件测试运行真实 action,显式设置 stubActions:false。必须清楚当前测试验证的是调用还是状态。

const wrapper = mount(TaskForm, {
  global: { plugins: [createTestingPinia({ createSpy: vi.fn })] },
})
const store = useTaskStore()
await wrapper.get('input').setValue('write tests')
await wrapper.get('form').trigger('submit')
expect(store.add).toHaveBeenCalledWith('write tests')

await wrapper.get('input').setValue('   ')
await wrapper.get('form').trigger('submit')
expect(wrapper.get('[role=alert]').text()).toContain('请输入')

七、插件、持久化与 Schema 迁移

Pinia 插件只有安装到应用或测试容器后才运行。持久化测试每次清理 localStorage;格式中加入 schemaVersion,旧数据解析失败时回退到空状态并保留诊断,而不是让页面白屏。

const pinia = createPinia()
pinia.use(({ store }) => store.$subscribe((_m, state) => {
  localStorage.setItem(store.$id, JSON.stringify({ schemaVersion:1, state }))
}))
mount(TaskForm, { global: { plugins:[pinia] } })
useTaskStore().add('persist me')
expect(JSON.parse(localStorage.getItem('tasks')!).schemaVersion).toBe(1)

八、异步、超时和竞态测试

至少覆盖成功、非 2xx、超时、非法 JSON、重复点击和后发请求先返回。组件卸载或发起新请求时取消旧请求,避免旧响应覆盖新状态。mock 返回应保留 HTTP 语义,不能只返回任意对象。

const response = { ok:true, json:async () => [{ id:'1', title:'remote', done:false }] }
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
await store.load()
expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({ signal:expect.any(AbortSignal) }))
expect(store.tasks).toHaveLength(1)

九、CI 质量门与稳定性

CI 顺序是锁定安装、类型检查、单元与组件测试、构建。覆盖率阈值保护关键逻辑,不为数字写无意义断言。失败优先排查共享全局状态、未清理计时器、真实网络和脆弱选择器。

name: frontend-quality
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm run typecheck
      - run: npm run test -- --coverage
      - run: npm run build

十、验收清单

最终检查测试是否隔离 Pinia、localStorage、mock 和计时器,组件是否用可访问角色与用户文本定位,异常和竞态是否有用例,CI 是否在任一步失败时阻止合并。

  • 真实 action 测试状态转换。
  • 组件 spy 与真实 action 场景明确区分。
  • 不依赖用例顺序和真实网络。
  • 关键用户旅程另有少量 E2E 验收。

总结

可维护测试体系把状态逻辑放在 Pinia 真实 action 测试,把交互契约放在组件测试,再以少量 E2E 覆盖关键旅程。明确 stub 行为、隔离浏览器状态并固定 CI 工具链,测试才能真正保护重构。

官方资料与继续学习

TY

Tycho

热爱分享技术知识,帮助开发者成长。

评论 (0)

评论功能当前已关闭
暂无评论,快来抢沙发吧!