L17 · 单元测试:Vitest + Vue Test Utils
🎯 本节目标:为核心组件、Composable 和 Pinia Store 编写单元测试
📦 本节产出:核心行为测试 + 覆盖率报告 + CI 可执行的测试脚本
🔗 前置钩子:L16 的完整功能集
🔗 后续钩子:L18 的 CI/CD 需要跑测试1. 为什么要写测试
测试让重复验证更容易,也能固定曾经出现过的边界问题。它只检查写进断言的行为,不能代替浏览器中的完整操作验证。
2. 安装配置
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 插件和 @ 路径别名:
// 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/**'],
},
},
}))// 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__,因此补一个测试配置:
{
"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 等已有命令:
{
"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
// 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: []。
// 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 基础
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
// 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
// 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
// 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 的使用
<!-- 在组件中添加 test id -->
<button data-testid="add-todo-btn" @click="addTodo">添加</button>
<input data-testid="todo-input" v-model="text" />// 在测试中使用
await wrapper.get('[data-testid="todo-input"]').setValue('新任务')
await wrapper.get('[data-testid="add-todo-btn"]').trigger('click')好处: 不依赖 CSS class 或 DOM 结构,重构样式不会破坏测试。
6.3 测试覆盖率
# §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 提交
git add .
git commit -m "L17: Vitest 单元测试 - 组件/Composable/Store"🔗 → 下一节
L18 将在 GitHub Actions 中自动运行这些测试,并把应用部署到 Vercel——完成从开发到上线的完整链路。