第6章 6.2 测试:Vitest + Vue Test Utils
🎯 开场:为什么你需要一个"代码体检医生"?
想象一下:你写了一个很棒的待办事项 App,功能齐全,上线前你手动点了 100 次测试,结果上线第一天,用户反馈"添加任务后列表不刷新"。你熬夜到凌晨 3 点排查,发现是一个变量名拼写错误。
如果有一套自动化的"代码体检医生",每次修改代码时自动跑一遍核心功能,你就能:
- 在用户发现 bug 之前把它揪出来
- 大胆重构,不用担心改坏原有功能
- 心里有底,知道自己的代码到底能不能正常工作
这就是测试的价值。上两章我们学了 TypeScript 给 Vue 加上"类型安全带",这一章我们给项目装上"自动体检仪"——Vitest。
学完这章,你就能:
- 给 Vue 项目配置完整的测试环境
- 用 Vue Test Utils 组件的"内部状态"(what)
- 写出能捕获 bug 的测试用例
🧱 基础:Vitest 核心概念(15 分钟)
什么是 Vitest?
Vite\n\n
\n\n
\n\nst 是一个"代码体检医生",专门给 JavaScript/TypeScript 代码做检查。它跑得飞快,因为它的底层用的是 Vite(就是那个构建工具),而不是老旧的 Jest。
类比一下:
- 普通开发:你写完代码,手动点一遍测试
- 有 Vitest:代码每次保存,自动跑一遍测试,秒级反馈
第一个测试:给"加法函数"做体检
先从一个最简单的例子感受一下。
# 创建一个小项目来练手
npm init -y
npm install -D vitest
创建一个 math.js 文件:
// math.js
export function add(a, b) {
return a + b
}
创建一个 math.test.js 文件(注意:文件名必须是 .test.js 或 .spec.js 结尾):
// math.test.js
import { describe, it, expect } from 'vitest'
import { add } from './math'
// describe: 把相关测试归为一组
describe('加法函数测试', () => {
// it: 写一个具体的测试用例
it('1 + 1 应该等于 2', () => {
expect(add(1, 1)).toBe(2) // expect(实际值).toBe(预期值)
})
it('负数相加应该正确', () => {
expect(add(-5, 3)).toBe(-2)
})
})
在 package.json 里加一条脚本:
{
"scripts": {
"test": "vitest"
}
}
运行测试:
npm test
预期输出:
✓ src/math.test.js > 加法函数测试 > 1 + 1 应该等于 2
✓ src/math.test.js > 加法函数测试 > 负数相加应该正确
2 passed
解释: describe 就像一个"测试文件夹",把相关的测试用例归类。it 里面写"这个功能应该怎么工作",然后用 expect 写出你的预期。
Vue 组件测试:mount vs shallowMount
这是 Vue Test Utils 的核心概念。
场景: 你要测试一个「显示用户名」的组件,但它内部用了另一个「格式化日期」的子组件。
mount: 渲染所有子组件(连子组件的子组件都渲染)shallowMount: 只渲染当前组件,假装子组件存在但不真正渲染
类比:
- mount = 体检时把你整个人放进去扫描(包括五脏六腑)
- shallowMount = 只扫描你的"外表",不查内部器官(假设内部没问题)
配置 Vitest + Vue Test Utils
在一个真实的 Vue3 项目里测试组件:
npm install -D @vue/test-utils vitest
创建 vitest.config.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 // 允许全局使用 describe/it/expect
}
})
修改 package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui" // 可视化界面
}
}
测试一个真实的 Vue 组件
假设我们有一个「用户卡片」组件 UserCard.vue:
<!-- UserCard.vue -->
<template>
<div class="user-card">
<h2>{{ name }}</h2>
<p>年龄:{{ age }}</p>
<button @click="age++">长大一岁</button>
</div>
</template>
<script setup>
defineProps({
name: String,
age: {
type: Number,
default: 0
}
})
</script>
创建测试文件 UserCard.test.js:
// UserCard.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
describe('UserCard 组件测试', () => {
it('应该正确显示传入的用户名', () => {
const wrapper = mount(UserCard, {
props: { name: '小明', age: 25 }
})
// 找到 h2 标签,检查文本内容
expect(wrapper.find('h2').text()).toBe('小明')
})
it('点击按钮应该让年龄增加', async () => {
const wrapper = mount(UserCard, {
props: { name: '小红', age: 20 }
})
// 初始年龄是 20
expect(wrapper.find('p').text()).toContain('20')
// 点击按钮
await wrapper.find('button').trigger('click')
// 年龄应该变成 21
expect(wrapper.find('p').text()).toContain('21')
})
})
运行 npm test,输出:
✓ UserCard.test.js > UserCard 组件测试 > 应该正确显示传入的用户名
✓ UserCard.test.js > UserCard 组件测试 > 点击按钮应该让年龄增加
2 passed
常用断言方法
| 断言 | 含义 | 示例 |
|---|---|---|
.toBe(x) |
严格相等(===) | expect(1+1).toBe(2) |
.toEqual(x) |
值相等(适合对象/数组) | expect({a:1}).toEqual({a:1}) |
.toContain(x) |
包含 | expect('hello').toContain('ell') |
.toBeTruthy() |
真值 | expect(1).toBeTruthy() |
.toBeFalsy() |
假值 | expect(0).toBeFalsy() |
.toHaveBeenCalled() |
函数被调用 | expect(fn).toHaveBeenCalled() |
🔥 实战:3 个递进项目(35 分钟)
项目 1:给计算器组件写测试(5 分钟)
这是一个最简单的 Vue 组件测试,跟着抄就能跑。
目标: 测试一个「计算器组件」,验证 1+1=2。
Counter.vue:
<template>
<div>
<span data-testid="count">{{ count }}</span>
<button @click="count++">加1</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
Counter.test.js:
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter 计数器测试', () => {
it('初始值应该是 0', () => {
const wrapper = mount(Counter)
expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
})
it('点击按钮应该增加 1', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
})
})
预期输出: 2 个测试全部通过 ✅
项目 2:从 JSON 读取数据测试(15 分钟)
目标: 测试一个「成绩列表组件」,数据从 JSON 文件加载。
场景: 老师想看到班里每个学生的平均分,低于 60 分的显示红色。
StudentList.vue:
<template>
<div class="student-list">
<h2>学生成绩</h2>
<div v-for="student in students" :key="student.name"
:class="{ 'low-score': student.avg < 60 }">
{{ student.name }}: {{ student.avg.toFixed(1) }}分
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
students: {
type: Array,
required: true
}
})
</script>
<style>
.low-score { color: red; }
</style>
students.json:
[
{ "name": "小明", "scores": [85, 90, 78] },
{ "name": "小红", "scores": [55, 60, 58] },
{ "name": "小刚", "scores": [92, 88, 95] }
]
StudentList.test.js:
import { mount } from '@vue/test-utils'
import StudentList from './StudentList.vue'
import studentsData from './students.json'
// 计算平均分的辅助函数
function calcAvg(scores) {
return scores.reduce((a, b) => a + b, 0) / scores.length
}
// 预处理数据
const students = studentsData.map(s => ({
name: s.name,
avg: calcAvg(s.scores)
}))
describe('StudentList 学生成绩组件测试', () => {
it('应该显示所有学生', () => {
const wrapper = mount(StudentList, {
props: { students }
})
expect(wrapper.findAll('div > div')).toHaveLength(3)
})
it('低分学生应该标红', () => {
const wrapper = mount(StudentList, {
props: { students }
})
// 小红的平均分是 (55+60+58)/3 = 57.67,应该有 low-score class
const lowScoreDiv = wrapper.find('.low-score')
expect(lowScoreDiv.text()).toContain('小红')
})
it('高分学生不应该标红', () => {
const wrapper = mount(StudentList, {
props: { students }
})
// 小刚的平均分是 91.67,不应该有 low-score class
const highScoreDiv = wrapper.findAll('div > div')[2]
expect(highScoreDiv.classes()).not.toContain('low-score')
})
})
预期输出: 3 个测试全部通过 ✅
项目 3:组合测试 + 覆盖率报告(15 分钟)
目标: 把前两个项目的测试整合,写一个完整的「待办清单」测试,并生成覆盖率报告。
场景: 你写了一个 TodoList 组件,支持添加任务、标记完成、删除任务,现在要用测试"全面体检"。
TodoList.vue:
<template>
<div class="todo-list">
<input v-model="newTask" @keyup.enter="addTask" placeholder="新任务..." />
<button @click="addTask">添加</button>
<ul>
<li v-for="(task, index) in tasks" :key="index"
:class="{ completed: task.done }">
<input type="checkbox" v-model="task.done" @change="toggle(index)" />
<span>{{ task.text }}</span>
<button @click="remove(index)">删除</button>
</li>
</ul>
<p>已完成:{{ doneCount }} / {{ tasks.length }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const newTask = ref('')
const tasks = ref([
{ text: '学习 Vitest', done: false },
{ text: '写测试用例', done: true }
])
const doneCount = computed(() => tasks.value.filter(t => t.done).length)
function addTask() {
if (newTask.value.trim()) {
tasks.value.push({ text: newTask.value.trim(), done: false })
newTask.value = ''
}
}
function toggle(index) {
tasks.value[index].done = !tasks.value[index].done
}
function remove(index) {
tasks.value.splice(index, 1)
}
</script>
<style>
.completed span { text-decoration: line-through; color: gray; }
</style>
TodoList.test.js:
import { mount } from '@vue/test-utils'
import TodoList from './TodoList.vue'
describe('TodoList 待办清单测试', () => {
it('初始应该显示已有任务', () => {
const wrapper = mount(TodoList)
expect(wrapper.findAll('li')).toHaveLength(2)
})
it('添加新任务', async () => {
const wrapper = mount(TodoList)
await wrapper.find('input').setValue('新任务测试')
await wrapper.find('button').trigger('click')
expect(wrapper.findAll('li')).toHaveLength(3)
expect(wrapper.text()).toContain('新任务测试')
})
it('标记任务完成', async () => {
const wrapper = mount(TodoList)
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[0].setChecked(true)
expect(wrapper.text()).toContain('已完成:2 / 2')
})
it('删除任务', async () => {
const wrapper = mount(TodoList)
const deleteButtons = wrapper.findAll('button')
await deleteButtons.at(-1).trigger('click') // at(-1) 取最后一个
expect(wrapper.findAll('li')).toHaveLength(1)
})
it('空任务不应该添加', async () => {
const wrapper = mount(TodoList)
const initialCount = wrapper.findAll('li').length
await wrapper.find('input').setValue(' ')
await wrapper.find('button').trigger('click')
expect(wrapper.findAll('li')).toHaveLength(initialCount)
})
})
生成覆盖率报告:
修改 vitest.config.js:
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
coverage: {
reporter: ['text', 'json', 'html'], // 输出多种格式覆盖率报告
exclude: ['node_modules', '*.config.js']
}
}
})
运行:
npm test -- --coverage
预期输出:
✓ TodoList.test.js (5 tests) - 全部通过
File | % Stmts | % Branch | % Funcs | % Lines |
--------------|---------|----------|---------|---------|
TodoList.vue | 100.00| 100.00| 100.00| 100.00|
All files | 100.00| 100.00| 100.00| 100.00|
解释: 覆盖率报告告诉你「你的代码有多少被测试跑过了」。100% 不是说代码没问题,而是「每一行都被执行过」。但这已经是很好的信号!
💪 进阶:常见坑 + 调试技巧(20 分钟)
坑 1:异步操作没等待
// ❌ 错误:没等异步完成就检查结果
it('点击后数据应该更新', () => {
const wrapper = mount(MyComponent)
wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('更新后的内容') // 会失败!
})
// ✅ 正确:加 async/await
it('点击后数据应该更新', async () => {
const wrapper = mount(MyComponent)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('更新后的内容') // 通过!
})
坑 2:nextTick 的必要性
// ❌ 错误:Vue 更新是异步的,直接检查拿不到最新值
it('输入后显示应该更新', async () => {
const wrapper = mount(MyInput)
await wrapper.find('input').setValue('hello')
expect(wrapper.text()).toContain('hello') // 可能失败
})
// ✅ 正确:加 await wrapper.vm.$nextTick()
it('输入后显示应该更新', async () => {
const wrapper = mount(MyInput)
await wrapper.find('input').setValue('hello')
await wrapper.vm.$nextTick() // 等 Vue 更新完 DOM
expect(wrapper.text()).toContain('hello')
})
坑 3:浅层渲染时,子组件事件丢失
// ❌ 错误:用 shallowMount,子组件的事件处理可能不工作
it('子组件按钮点击', () => {
const wrapper = shallowMount(ParentComponent)
wrapper.find('child-component').find('button').trigger('click') // 失败
})
// ✅ 正确:用 mount 完整渲染
it('子组件按钮点击', () => {
const wrapper = mount(ParentComponent)
wrapper.find('button').trigger('click') // 通过
})
坑 4:props 数据类型问题
// ❌ 错误:传了字符串 '25' 而不是数字 25
const wrapper = mount(UserCard, { props: { age: '25' } })
// ✅ 正确:传正确类型
const wrapper = mount(UserCard, { props: { age: 25 } })
坑 5:toBe vs toEqual
// ❌ 错误:对象/数组要用 toEqual,不是 toBe
expect({a: 1}).toBe({a: 1}) // 失败!因为是不同引用
// ✅ 正确:对象/数组用 toEqual
expect({a: 1}).toEqual({a: 1}) // 通过!
调试技巧:看组件的"内心"
it('调试用', () => {
const wrapper = mount(UserCard, { props: { name: '测试', age: 20 } })
// 打印组件实例的所有数据
console.log(wrapper.vm)
// 打印 HTML 结构
console.log(wrapper.html())
// 打印所有 props
console.log(wrapper.props())
// 打印所有 data
console.log(wrapper.vm.$data)
})
✏️ 练习题(10 分钟)
练习 1(2 分钟):基础改写
- 输入:把 Counter.vue 的初始值改成 10
- 预期输出:测试应该调整为初始值是 10
- 提示:改 ref(0) 为 ref(10),测试里的 toBe('0') 改成 toBe('10')
练习 2(2 分钟):加一个条件判断
- 输入:在 Counter.vue 里加一个"重置"按钮,点击后 count 归零
- 预期输出:能写出点击重置后 count 变回 0 的测试
- 提示:给按钮加个 class 或 text 来定位
练习 3(2 分钟):处理新数据集
- 输入:用项目 2 的方式处理一组新学生成绩:[{name: '小李', scores: [70, 75, 80]}]
- 预期输出:平均分应该是 75.0,显示正确
- 提示:复用 calcAvg 函数,格式和 students.json 一样
练习 4(2 分钟):串接两个组件
- 输入:创建一个父组件,包含 StudentList 和 Counter
- 预期输出:两个子组件都能正常工作
- 提示:用 mount 而非 shallowMount
练习 5(2 分钟):报错分析
- 输入:运行测试时出现 TypeError: Cannot read property 'text' of null
- 预期输出:分析原因并修复
- 提示:检查 wrapper.find('h2') 是否真的找到了元素
📝 作业:做一个「Vitest 测试练习工具」(30 分钟-2 小时)
需求描述:
做一个「待办清单 + 统计分析」的小工具,并写完整的测试用例。
功能点:
1. 添加任务(text + 优先级:高中低)
2. 按优先级筛选显示
3. 显示统计:总数、完成数、完成率
加分项:
1. 用 shallowMount 测试父组件,mount 测试子组件
2. 生成覆盖率报告,达到 80% 以上
验收标准:
- 能跑起来:npm test 全部通过
- 输出符合预期
- 每个函数都有测试覆盖
📚 总结
这一章我们学了:
1. Vitest 是 Vue 项目的自动化测试工具,配置简单、运行飞快
2. Vue Test Utils 让你能"透视"组件,检查 props、events、DOM 状态
3. 测试覆盖率让你量化"代码被体检了多少"
延伸资源:
- Vitest 官方文档(英文,但例子很清楚)
- Vue Test Utils 官方文档(必看!)
- 《Vue3 Design and Best Practices》第 8 章测试部分
互动钩子:
你在项目中是怎么做测试的?有没有遇到过"测试写了一大堆,上线还是出 bug"的情况?评论区聊聊老粉优先回复!
下一章我们要解决一个性能问题:当列表有 10000 条数据时,页面卡成 PPT怎么办?下一章「性能优化:懒加载 + 虚拟列表」给你答案。

评论(0)