Skip to content

L17 · 单元测试:Vitest + Vue Test Utils ​

🎯 本节目标:为核心组件、Composable 和 Pinia Store 编写单元测试
📦 本节产出:核心行为测试 + 覆盖率报告 + CI 可执行的测试脚本
🔗 前置钩子:L16 的完整功能集
🔗 后续钩子:L18 的 CI/CD 需要跑测试

1. 为什么要写测试 ​

测试让重复验证更容易,也能固定曾经出现过的边界问题。它只检查写进断言的行为,不能代替浏览器中的完整操作验证。


2. 安装配置 ​

bash
npx --yes npm@11.6.2 install -D --save-exact vitest@4.1.11 @vitest/coverage-v8@4.1.11
npx --yes npm@11.6.2 install -D @vue/test-utils@2 happy-dom@20 @pinia/testing@1

本课固定 Vitest 与覆盖率插件为同一版本,配合 Vite 6、Pinia 3 和 Node 22.12+。提交更新后的 package-lock.json,CI 使用 npm ci。4.1.11 已包含维护者公布的 mock 文件加载边界修复。

这里用 npx 临时运行 npm 11.6.2 完成安装,不修改全局 npm。核对这组依赖时,Node 22 附带的 npm 10.9.8 在安装阶段出现 Arborist edgesOut 内部错误;npm 11.6.2 安装通过,生成的锁文件也已用 npm 10.9.8 的 npm ci 验证。执行测试和 CI 的命令保持不变。

新建测试配置并合并原 Vite 配置,保留已有的 Vue 插件和 @ 路径别名:

typescript
// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(viteConfig, defineConfig({
  test: {
    environment: 'happy-dom',
    globals: false, // 下文显式导入 describe / it / expect / vi
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{ts,vue}'],
      exclude: ['src/**/*.d.ts', 'src/**/__tests__/**', 'src/test/**'],
    },
  },
}))
typescript
// src/test/setup.ts
import { afterEach } from 'vitest'
import { enableAutoUnmount } from '@vue/test-utils'

// 每个测试完成后卸载 mount 创建的组件,清理组件拥有的副作用
// 保留 Test Utils 默认的 Transition stub;动画视觉效果由浏览器检查
enableAutoUnmount(afterEach)

测试运行会转换 TypeScript,但不会替代类型检查。脚手架的 tsconfig.app.json 排除了 __tests__,因此补一个测试配置:

json
{
  "extends": "./tsconfig.app.json",
  "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "vitest.config.ts"],
  "exclude": [],
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo",
    "types": ["node"]
  }
}

把上面保存为 tsconfig.test.json。在 package.json 原有 scripts 中追加以下条目,保留 dev、build、lint 等已有命令:

json
{
  "test": "vitest",
  "test:run": "vitest run",
  "test:coverage": "vitest run --coverage",
  "type-check:test": "vue-tsc --noEmit -p tsconfig.test.json"
}

配置说明见 Vitest 4 配置文档。


3. 测试 Composable ​

Composable 可能包含响应式副作用或生命周期,不一定是纯函数。L08 的两个示例可以放进 effectScope 测试,并在结束后停止其中的 watch;依赖 onMounted / inject 的 useTheme 则应挂载测试组件。

3.1 测试 useLocalStorage ​

typescript
// src/composables/__tests__/useLocalStorage.spec.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { effectScope, nextTick } from 'vue'
import { useLocalStorage } from '../useLocalStorage'

const isString = (value: unknown): value is string => typeof value === 'string'
function isCounter(value: unknown): value is { count: number; name: string } {
  if (!value || typeof value !== 'object') return false
  const data = value as Record<string, unknown>
  return typeof data.count === 'number' && typeof data.name === 'string'
}

describe('useLocalStorage', () => {
  let scope: ReturnType<typeof effectScope>
  beforeEach(() => {
    localStorage.clear()
    scope = effectScope()
  })
  afterEach(() => {
    scope.stop()
    vi.restoreAllMocks()
  })

  it('存储为空时返回默认值', () => {
    const data = scope.run(() => useLocalStorage('test-key', 'default', isString))!
    expect(data.value).toBe('default')
  })

  it('从 localStorage 恢复通过校验的数据', () => {
    localStorage.setItem('test-key', JSON.stringify('saved'))
    const data = scope.run(() => useLocalStorage('test-key', 'default', isString))!
    expect(data.value).toBe('saved')
  })

  it('修改值后写入 localStorage', async () => {
    const data = scope.run(() => useLocalStorage('test-key', 'initial', isString))!
    data.value = 'updated'
    await nextTick() // 等待 Vue 调度的 watch
    expect(JSON.parse(localStorage.getItem('test-key')!)).toBe('updated')
  })

  it('对象属性修改也会触发 deep watch 写入', async () => {
    const data = scope.run(() => useLocalStorage('test-obj', { count: 0, name: 'test' }, isCounter))!
    data.value.count = 5
    await nextTick()
    expect(JSON.parse(localStorage.getItem('test-obj')!)).toEqual({ count: 5, name: 'test' })
  })

  it('非法 JSON 回退到默认值', () => {
    vi.spyOn(console, 'error').mockImplementation(() => {})
    localStorage.setItem('test-key', 'invalid json{{{')
    const data = scope.run(() => useLocalStorage('test-key', 'fallback', isString))!
    expect(data.value).toBe('fallback')
  })

  it('合法 JSON 但类型错误时也回退', () => {
    vi.spyOn(console, 'error').mockImplementation(() => {})
    localStorage.setItem('test-key', JSON.stringify(123))
    const data = scope.run(() => useLocalStorage('test-key', 'fallback', isString))!
    expect(data.value).toBe('fallback')
  })
})

3.2 测试 useTodos ​

这是对 L08 保留的 Composable 的独立回归测试;当前页面已经在 L11 改用 Pinia。若已删除旧 Composable,就跳过本节,把相应行为测试放到 §5 的 Store 测试中。保留它时需按 L12 的迁移说明给新任务补上 tags: []。

typescript
// src/composables/__tests__/useTodos.spec.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { effectScope } from 'vue'
import { useTodos } from '../useTodos'

describe('useTodos', () => {
  let todos: ReturnType<typeof useTodos>
  let scope: ReturnType<typeof effectScope>

  beforeEach(() => {
    localStorage.clear()
    scope = effectScope()
    todos = scope.run(() => useTodos())!
  })
  afterEach(() => scope.stop())

  it('无历史存储时使用空列表', () => {
    expect(todos.todos.value).toEqual([])
  })

  it('addTodo 应该添加新任务', () => {
    const before = todos.todos.value.length
    todos.addTodo('新任务')
    expect(todos.todos.value.length).toBe(before + 1)
    expect(todos.todos.value.at(-1)?.text).toBe('新任务')
  })

  it('addTodo 应该设置正确的默认值', () => {
    todos.addTodo('测试任务')
    const added = todos.todos.value.at(-1)!
    expect(added.done).toBe(false)
    expect(added.priority).toBe('medium')
    expect(Number.isSafeInteger(added.id)).toBe(true)
    expect(added.tags).toEqual([])
  })

  it('toggleTodo 应该切换完成状态', () => {
    todos.addTodo('待切换')
    const id = todos.todos.value.at(-1)!.id
    expect(todos.todos.value.find(t => t.id === id)!.done).toBe(false)

    todos.toggleTodo(id)
    expect(todos.todos.value.find(t => t.id === id)!.done).toBe(true)

    todos.toggleTodo(id)
    expect(todos.todos.value.find(t => t.id === id)!.done).toBe(false)
  })

  it('deleteTodo 应该删除指定任务', () => {
    todos.addTodo('将被删除')
    const id = todos.todos.value.at(-1)!.id
    const before = todos.todos.value.length

    todos.deleteTodo(id)
    expect(todos.todos.value.length).toBe(before - 1)
    expect(todos.todos.value.find(t => t.id === id)).toBeUndefined()
  })

  it('clearDone 应该清除所有已完成任务', () => {
    todos.addTodo('任务 A')
    todos.addTodo('任务 B')
    const idA = todos.todos.value.at(-2)!.id
    todos.toggleTodo(idA) // 标记 A 为完成

    todos.clearDone()
    expect(todos.todos.value.find(t => t.id === idA)).toBeUndefined()
  })

  describe('filteredTodos', () => {
    it('filter = all 应该返回全部', () => {
      todos.filter.value = 'all'
      expect(todos.filteredTodos.value.length).toBe(todos.todos.value.length)
    })

    it('filter = active 应该只返回未完成', () => {
      todos.addTodo('活跃任务')
      todos.filter.value = 'active'
      todos.filteredTodos.value.forEach(t => {
        expect(t.done).toBe(false)
      })
    })

    it('filter = done 应该只返回已完成', () => {
      todos.addTodo('完成任务')
      const id = todos.todos.value.at(-1)!.id
      todos.toggleTodo(id)
      todos.filter.value = 'done'
      todos.filteredTodos.value.forEach(t => {
        expect(t.done).toBe(true)
      })
    })
  })

  describe('stats', () => {
    it('应该正确计算统计数据', () => {
      // 清空后添加 3 条
      todos.todos.value = []
      todos.addTodo('A')
      todos.addTodo('B')
      todos.addTodo('C')
      todos.toggleTodo(todos.todos.value[0]!.id) // A 完成

      expect(todos.stats.value.total).toBe(3)
      expect(todos.stats.value.doneCount).toBe(1)
      expect(todos.stats.value.activeCount).toBe(2)
      expect(todos.stats.value.donePercent).toBe(33) // Math.round(1/3*100)
    })
  })
})

4. 测试 Vue 组件 ​

4.1 Vue Test Utils 基础 ​

typescript
import { mount } from '@vue/test-utils'
import TodoItem from '@/components/todo/TodoItem.vue'

// mount:完整渲染(包括子组件)
const wrapper = mount(TodoItem, {
  props: {
    id: 1,
    text: '学习 Vue',
    done: false,
    priority: 'high',
    createdAt: '2024-01-01',
  },
})

// 常用查询方法
wrapper.text()                      // 获取所有文本内容
wrapper.find('.todo-text')          // 按 CSS 选择器查找
wrapper.findAll('.tag')             // 查找所有匹配
wrapper.find('[data-testid="toggle"]')  // 按 data-testid 查找
wrapper.exists()                    // 元素是否存在
wrapper.classes()                   // 获取 CSS class 列表
wrapper.attributes('disabled')      // 获取属性值

4.2 测试 TodoItem ​

typescript
// src/components/todo/__tests__/TodoItem.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TodoItem from '@/components/todo/TodoItem.vue'

const defaultProps = {
  id: 1,
  text: '学习 Vitest',
  done: false,
  priority: 'high' as const,
  createdAt: '2024-01-15',
}

describe('TodoItem', () => {
  it('应该渲染任务文本', () => {
    const wrapper = mount(TodoItem, { props: defaultProps })
    expect(wrapper.text()).toContain('学习 Vitest')
  })

  it('完成状态应该有 is-done class', () => {
    const wrapper = mount(TodoItem, {
      props: { ...defaultProps, done: true },
    })
    expect(wrapper.classes()).toContain('is-done')
  })

  it('未完成状态不应该有 is-done class', () => {
    const wrapper = mount(TodoItem, {
      props: { ...defaultProps, done: false },
    })
    expect(wrapper.classes()).not.toContain('is-done')
  })

  it('应该显示优先级标识', () => {
    const wrapper = mount(TodoItem, { props: defaultProps })
    const badge = wrapper.find('.priority-badge')
    expect(badge.exists()).toBe(true)
    expect(badge.text()).toContain('high')
  })

  it('点击切换按钮应该触发 toggle 事件', async () => {
    const wrapper = mount(TodoItem, { props: defaultProps })
    await wrapper.find('.toggle-btn').trigger('click')

    expect(wrapper.emitted('toggle')).toBeTruthy()
    expect(wrapper.emitted('toggle')![0]).toEqual([1])
  })

  it('点击删除按钮应该触发 delete 事件', async () => {
    const wrapper = mount(TodoItem, { props: defaultProps })
    await wrapper.find('.delete-btn').trigger('click')

    expect(wrapper.emitted('delete')).toBeTruthy()
    expect(wrapper.emitted('delete')![0]).toEqual([1])
  })

  it('完成状态向辅助技术提供已按下状态', () => {
    const wrapper = mount(TodoItem, {
      props: { ...defaultProps, done: true },
    })
    expect(wrapper.get('.toggle-btn').attributes('aria-pressed')).toBe('true')
  })
})

5. 测试 Pinia Store ​

typescript
// src/stores/__tests__/taskStore.spec.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia, disposePinia } from 'pinia'
import { useTaskStore } from '../taskStore'

describe('taskStore', () => {
  let pinia: ReturnType<typeof createPinia>
  beforeEach(() => {
    localStorage.clear()
    // 每个测试创建全新的 Pinia 实例(隔离状态)
    pinia = createPinia()
    setActivePinia(pinia)
  })
  afterEach(() => disposePinia(pinia))

  it('无历史存储时有两条示例任务', () => {
    const store = useTaskStore()
    expect(store.todos).toHaveLength(2)
  })

  it('addTodo 应该添加任务', () => {
    const store = useTaskStore()
    const before = store.todos.length
    store.addTodo('测试任务')
    expect(store.todos.length).toBe(before + 1)
  })

  it('toggleTodo 应该切换状态', () => {
    const store = useTaskStore()
    store.addTodo('待切换')
    const id = store.todos.at(-1)!.id

    store.toggleTodo(id)
    expect(store.todos.find(t => t.id === id)!.done).toBe(true)
  })

  it('stats getter 应该返回正确统计', () => {
    const store = useTaskStore()
    store.todos = [
      { id: 1, text: 'A', done: true, priority: 'low', createdAt: '2026-09-22', tags: [] },
      { id: 2, text: 'B', done: false, priority: 'high', createdAt: '2026-09-22', tags: [] },
      { id: 3, text: 'C', done: false, priority: 'medium', createdAt: '2026-09-22', tags: [] },
    ]

    expect(store.stats.total).toBe(3)
    expect(store.stats.doneCount).toBe(1)
    expect(store.stats.activeCount).toBe(2)
    expect(store.stats.donePercent).toBe(33)
  })
})

5.1 使用 @pinia/testing ​

typescript
// src/views/__tests__/HomeView.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { useTaskStore } from '@/stores/taskStore'
import HomeView from '../HomeView.vue'

describe('HomeView', () => {
  it('将输入文本交给 Store action', async () => {
    localStorage.clear()
    const pinia = createTestingPinia({
      createSpy: vi.fn,
      initialState: {
        // 必须与 defineStore('tasks', ...) 的 id 一致
        tasks: {
          todos: [
            { id: 1, text: '测试任务', done: false, priority: 'high', createdAt: '2026-09-22', tags: [] },
          ],
        },
      },
      // 默认 stubActions: true,只记录调用;要执行真实 action 可设为 false
    })
    const wrapper = mount(HomeView, { global: { plugins: [pinia] } })
    await wrapper.get('.todo-input').setValue('新任务')
    await wrapper.get('.add-btn').trigger('click')
    expect(useTaskStore(pinia).addTodo).toHaveBeenCalledWith('新任务')
  })
})

这里测试的是组件有没有调用 action,不是在验证新增任务逻辑。持久化插件未加入这些 Store 单测;需要验证恢复和写入时,应在挂载的测试应用中安装该插件,再检查实际存储。参见 Pinia 测试文档。

6. 测试最佳实践 ​

6.1 测什么,不测什么 ​

✅ 应该测❌ 不应该测
组件的输出(渲染内容)组件的内部实现细节
Props 是否正确渲染具体的 CSS 样式
事件是否正确触发第三方库的行为
Composable 的返回值Vue 框架本身的功能
Store 的状态变化private 方法
边缘情况和错误处理快照测试(除非有意义)

6.2 data-testid 的使用 ​

vue
<!-- 在组件中添加 test id -->
<button data-testid="add-todo-btn" @click="addTodo">添加</button>
<input data-testid="todo-input" v-model="text" />
typescript
// 在测试中使用
await wrapper.get('[data-testid="todo-input"]').setValue('新任务')
await wrapper.get('[data-testid="add-todo-btn"]').trigger('click')

好处: 不依赖 CSS class 或 DOM 结构,重构样式不会破坏测试。

6.3 测试覆盖率 ​

bash
# §2 已安装同版本 @vitest/coverage-v8
npm run type-check:test
npm run test:coverage

命令会输出测试结果和覆盖率,并在 coverage/ 生成 HTML 与 lcov.info。本文不预设执行结果;实际通过数量和百分比取决于保留的课程功能与新增测试。把 coverage/ 加入 .gitignore。

配置的覆盖范围包括 src 下尚未测试的代码,因此新增上述几个文件不代表全项目已达到 80%。先检查未覆盖的分支是否包含错误处理、编辑取消、拖拽排序或主题切换等关键行为,再决定补充测试。若团队确定了最低覆盖目标,可以另外配置 coverage.thresholds;本课没有设置硬性门槛。


7. 本节总结 ​

文件结构 ​

src/
├── composables/
│   ├── useLocalStorage.ts
│   ├── useTodos.ts
│   └── __tests__/
│       ├── useLocalStorage.spec.ts
│       └── useTodos.spec.ts
├── components/
│   └── todo/
│       ├── TodoItem.vue
│       └── __tests__/
│           └── TodoItem.spec.ts
├── views/
│   └── __tests__/
│       └── HomeView.spec.ts
├── stores/
│   ├── taskStore.ts
│   └── __tests__/
│       └── taskStore.spec.ts
└── test/
    └── setup.ts

检查清单 ​

  • [ ] 能安装和配置 Vitest + Vue Test Utils
  • [ ] 能测试 Composable 的返回值和响应式行为
  • [ ] 能用 mount() 渲染组件并查询 DOM
  • [ ] 能用 trigger() 模拟用户交互
  • [ ] 能用 emitted() 验证事件触发
  • [ ] 能用 setActivePinia(createPinia()) 隔离 Store 测试
  • [ ] 能用 createTestingPinia 在组件测试中注入 Store
  • [ ] 知道 data-testid 的优势
  • [ ] 能生成覆盖率报告

Git 提交 ​

bash
git add .
git commit -m "L17: Vitest 单元测试 - 组件/Composable/Store"

🔗 → 下一节 ​

L18 将在 GitHub Actions 中自动运行这些测试,并把应用部署到 Vercel——完成从开发到上线的完整链路。