

文章目录
- [1. Vue 单元测试简介](#1. Vue 单元测试简介)
-
- [1.1 为什么需要单元测试](#1.1 为什么需要单元测试)
- [1.2 测试工具介绍](#1.2 测试工具介绍)
- [2. 环境搭建](#2. 环境搭建)
-
- [2.1 安装依赖](#2.1 安装依赖)
- [2.2 配置 Jest](#2.2 配置 Jest)
- [3. 编写第一个测试](#3. 编写第一个测试)
-
- [3.1 组件示例](#3.1 组件示例)
- [3.2 编写测试用例](#3.2 编写测试用例)
- [3.3 运行测试](#3.3 运行测试)
- [4. Vue Test Utils 核心 API](#4. Vue Test Utils 核心 API)
-
- [4.1 挂载组件](#4.1 挂载组件)
- [4.2 常用断言和操作](#4.2 常用断言和操作)
- [5. 测试组件交互](#5. 测试组件交互)
-
- [5.1 测试用户输入](#5.1 测试用户输入)
- [5.2 测试 Props 和自定义事件](#5.2 测试 Props 和自定义事件)
- [6. 模拟依赖](#6. 模拟依赖)
-
- [6.1 模拟 API 请求](#6.1 模拟 API 请求)
- [6.2 模拟 Vuex](#6.2 模拟 Vuex)
- [7. 测试 Vue Router](#7. 测试 Vue Router)
-
- [7.1 模拟 Vue Router](#7.1 模拟 Vue Router)
- [7.2 测试路由组件](#7.2 测试路由组件)
- [8. 快照测试](#8. 快照测试)
-
- [8.1 基本快照测试](#8.1 基本快照测试)
- [8.2 更新快照](#8.2 更新快照)
- [9. 测试覆盖率](#9. 测试覆盖率)
-
- [9.1 理解覆盖率指标](#9.1 理解覆盖率指标)
- [9.2 覆盖率报告](#9.2 覆盖率报告)
- [10. 测试最佳实践](#10. 测试最佳实践)
-
- [10.1 组织测试](#10.1 组织测试)
- [10.2 测试原则](#10.2 测试原则)
- [10.3 常见测试场景对比](#10.3 常见测试场景对比)
- [11. 持续集成中的测试](#11. 持续集成中的测试)
-
- [11.1 配置 CI 流程](#11.1 配置 CI 流程)
- [11.2 测试报告整合](#11.2 测试报告整合)
- [12. 测试驱动开发 (TDD) 与 Vue](#12. 测试驱动开发 (TDD) 与 Vue)
-
- [12.1 TDD 流程](#12.1 TDD 流程)
- [12.2 TDD 示例](#12.2 TDD 示例)
- [13. 常见问题与解决方案](#13. 常见问题与解决方案)
-
- [13.1 异步测试问题](#13.1 异步测试问题)
- [13.2 复杂 DOM 结构查找问题](#13.2 复杂 DOM 结构查找问题)
- [13.3 模拟复杂的 Vuex Store](#13.3 模拟复杂的 Vuex Store)
正文
1. Vue 单元测试简介
单元测试是确保代码质量和可维护性的重要手段,在 Vue 应用开发中,Jest 和 Vue Test Utils 是最常用的测试工具组合。
1.1 为什么需要单元测试
- 提早发现 bug,减少线上问题
- 重构代码时提供安全保障
- 作为代码的活文档,帮助理解组件功能
- 促进更好的代码设计和模块化
1.2 测试工具介绍
- Jest: Facebook 开发的 JavaScript 测试框架,提供断言库、测试运行器和覆盖率报告
- Vue Test Utils: Vue.js 官方的单元测试实用工具库,提供挂载组件和与之交互的方法
2. 环境搭建
2.1 安装依赖
bash
# 使用 Vue CLI 创建项目时选择单元测试
vue create my-project
# 或在现有项目中安装
npm install --save-dev jest @vue/test-utils vue-jest babel-jest
2.2 配置 Jest
在 package.json
中添加 Jest 配置:
json
{
"jest": {
"moduleFileExtensions": [
"js",
"vue"
],
"transform": {
"^.+\\.vue$": "vue-jest",
"^.+\\.js$": "babel-jest"
},
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
},
"testMatch": [
"**/tests/unit/**/*.spec.[jt]s?(x)"
],
"collectCoverage": true,
"collectCoverageFrom": [
"src/**/*.{js,vue}",
"!src/main.js",
"!src/router/index.js",
"!**/node_modules/**"
]
}
}
3. 编写第一个测试
3.1 组件示例
假设有一个简单的计数器组件 Counter.vue
:
vue
<template>
<div>
<span class="count">{{ count }}</span>
<button @click="increment">增加</button>
<button @click="decrement">减少</button>
</div>
</template>
<script>
export default {
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count += 1
},
decrement() {
this.count -= 1
}
}
}
</script>
3.2 编写测试用例
创建 tests/unit/Counter.spec.js
文件:
javascript
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'
describe('Counter.vue', () => {
it('初始计数为0', () => {
const wrapper = mount(Counter)
expect(wrapper.find('.count').text()).toBe('0')
})
it('点击增加按钮后计数加1', async () => {
const wrapper = mount(Counter)
await wrapper.findAll('button').at(0).trigger('click')
expect(wrapper.find('.count').text()).toBe('1')
})
it('点击减少按钮后计数减1', async () => {
const wrapper = mount(Counter)
await wrapper.findAll('button').at(1).trigger('click')
expect(wrapper.find('.count').text()).toBe('-1')
})
})
3.3 运行测试
bash
npm run test:unit
4. Vue Test Utils 核心 API
4.1 挂载组件
javascript
// 完全挂载组件及其子组件
const wrapper = mount(Component, {
propsData: { /* 组件 props */ },
data() { /* 覆盖组件数据 */ },
mocks: { /* 模拟全局对象 */ },
stubs: { /* 替换子组件 */ }
})
// 只挂载当前组件,不渲染子组件
const wrapper = shallowMount(Component, options)
4.2 常用断言和操作
javascript
// 查找元素
wrapper.find('div') // CSS 选择器
wrapper.find('.class-name')
wrapper.find('[data-test="id"]')
wrapper.findComponent(ChildComponent)
// 检查内容和属性
expect(wrapper.text()).toContain('Hello')
expect(wrapper.html()).toContain('<div>')
expect(wrapper.attributes('id')).toBe('my-id')
expect(wrapper.classes()).toContain('active')
// 触发事件
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('new value')
// 访问组件实例
console.log(wrapper.vm.count) // 访问数据
wrapper.vm.increment() // 调用方法
// 更新组件
await wrapper.setProps({ color: 'red' })
await wrapper.setData({ count: 5 })
5. 测试组件交互
5.1 测试用户输入
假设有一个表单组件 Form.vue
:
vue
<template>
<form @submit.prevent="submitForm">
<input v-model="username" data-test="username" />
<input type="password" v-model="password" data-test="password" />
<button type="submit" data-test="submit">登录</button>
<p v-if="error" data-test="error">{{ error }}</p>
</form>
</template>
<script>
export default {
data() {
return {
username: '',
password: '',
error: ''
}
},
methods: {
submitForm() {
if (!this.username || !this.password) {
this.error = '用户名和密码不能为空'
return
}
this.$emit('form-submitted', {
username: this.username,
password: this.password
})
this.error = ''
}
}
}
</script>
测试代码 Form.spec.js
:
javascript
import { mount } from '@vue/test-utils'
import Form from '@/components/Form.vue'
describe('Form.vue', () => {
it('提交空表单时显示错误信息', async () => {
const wrapper = mount(Form)
await wrapper.find('[data-test="submit"]').trigger('click')
expect(wrapper.find('[data-test="error"]').text()).toBe('用户名和密码不能为空')
})
it('表单正确提交时触发事件', async () => {
const wrapper = mount(Form)
await wrapper.find('[data-test="username"]').setValue('user1')
await wrapper.find('[data-test="password"]').setValue('pass123')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('form-submitted')).toBeTruthy()
expect(wrapper.emitted('form-submitted')[0][0]).toEqual({
username: 'user1',
password: 'pass123'
})
})
})
5.2 测试 Props 和自定义事件
假设有一个展示商品的组件 ProductItem.vue
:
vue
<template>
<div class="product-item">
<h3>{{ product.name }}</h3>
<p>{{ product.price }}元</p>
<button @click="addToCart">加入购物车</button>
</div>
</template>
<script>
export default {
props: {
product: {
type: Object,
required: true
}
},
methods: {
addToCart() {
this.$emit('add-to-cart', this.product.id)
}
}
}
</script>
测试代码 ProductItem.spec.js
:
javascript
import { mount } from '@vue/test-utils'
import ProductItem from '@/components/ProductItem.vue'
describe('ProductItem.vue', () => {
const product = {
id: 1,
name: '测试商品',
price: 99
}
it('正确渲染商品信息', () => {
const wrapper = mount(ProductItem, {
propsData: { product }
})
expect(wrapper.find('h3').text()).toBe('测试商品')
expect(wrapper.find('p').text()).toBe('99元')
})
it('点击按钮触发加入购物车事件', async () => {
const wrapper = mount(ProductItem, {
propsData: { product }
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('add-to-cart')).toBeTruthy()
expect(wrapper.emitted('add-to-cart')[0]).toEqual([1])
})
})
6. 模拟依赖
6.1 模拟 API 请求
假设有一个使用 axios 获取用户数据的组件 UserList.vue
:
vue
<template>
<div>
<div v-if="loading">加载中...</div>
<ul v-else>
<li v-for="user in users" :key="user.id" data-test="user">
{{ user.name }}
</li>
</ul>
<div v-if="error" data-test="error">{{ error }}</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
data() {
return {
users: [],
loading: true,
error: null
}
},
created() {
this.fetchUsers()
},
methods: {
async fetchUsers() {
try {
this.loading = true
const response = await axios.get('/api/users')
this.users = response.data
} catch (error) {
this.error = '获取用户列表失败'
} finally {
this.loading = false
}
}
}
}
</script>
测试代码 UserList.spec.js
:
javascript
import { mount, flushPromises } from '@vue/test-utils'
import UserList from '@/components/UserList.vue'
import axios from 'axios'
// 模拟 axios
jest.mock('axios')
describe('UserList.vue', () => {
it('成功获取用户列表', async () => {
// 设置 axios.get 的模拟返回值
axios.get.mockResolvedValue({
data: [
{ id: 1, name: '用户1' },
{ id: 2, name: '用户2' }
]
})
const wrapper = mount(UserList)
// 等待异步操作完成
await flushPromises()
// 断言加载状态消失
expect(wrapper.find('div').text()).not.toBe('加载中...')
// 断言用户列表已渲染
const users = wrapper.findAll('[data-test="user"]')
expect(users).toHaveLength(2)
expect(users.at(0).text()).toBe('用户1')
expect(users.at(1).text()).toBe('用户2')
})
it('获取用户列表失败', async () => {
// 设置 axios.get 模拟抛出错误
axios.get.mockRejectedValue(new Error('API 错误'))
const wrapper = mount(UserList)
// 等待异步操作完成
await flushPromises()
// 断言显示错误信息
expect(wrapper.find('[data-test="error"]').text()).toBe('获取用户列表失败')
})
})
6.2 模拟 Vuex
假设有一个使用 Vuex 的计数器组件 VuexCounter.vue
:
vue
<template>
<div>
<span data-test="count">{{ count }}</span>
<button @click="increment">增加</button>
<button @click="decrement">减少</button>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex'
export default {
computed: {
...mapState(['count'])
},
methods: {
...mapActions(['increment', 'decrement'])
}
}
</script>
测试代码 VuexCounter.spec.js
:
javascript
import { mount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import VuexCounter from '@/components/VuexCounter.vue'
// 创建扩展的 Vue 实例
const localVue = createLocalVue()
localVue.use(Vuex)
describe('VuexCounter.vue', () => {
let store
let actions
let state
beforeEach(() => {
// 设置模拟的 state 和 actions
state = {
count: 5
}
actions = {
increment: jest.fn(),
decrement: jest.fn()
}
// 创建模拟的 store
store = new Vuex.Store({
state,
actions
})
})
it('从 store 渲染计数', () => {
const wrapper = mount(VuexCounter, {
store,
localVue
})
expect(wrapper.find('[data-test="count"]').text()).toBe('5')
})
it('调度 increment action', async () => {
const wrapper = mount(VuexCounter, {
store,
localVue
})
await wrapper.findAll('button').at(0).trigger('click')
expect(actions.increment).toHaveBeenCalled()
})
it('调度 decrement action', async () => {
const wrapper = mount(VuexCounter, {
store,
localVue
})
await wrapper.findAll('button').at(1).trigger('click')
expect(actions.decrement).toHaveBeenCalled()
})
})
7. 测试 Vue Router
7.1 模拟 Vue Router
使用 Vue Router 的导航组件 Navigation.vue
:
vue
<template>
<nav>
<router-link to="/" data-test="home">首页</router-link>
<router-link to="/about" data-test="about">关于</router-link>
<button @click="goToContact" data-test="contact">联系我们</button>
</nav>
</template>
<script>
export default {
methods: {
goToContact() {
this.$router.push('/contact')
}
}
}
</script>
测试代码 Navigation.spec.js
:
javascript
import { mount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
import Navigation from '@/components/Navigation.vue'
const localVue = createLocalVue()
localVue.use(VueRouter)
describe('Navigation.vue', () => {
it('点击按钮进行路由导航', async () => {
const router = new VueRouter()
// 监视 router.push 方法
router.push = jest.fn()
const wrapper = mount(Navigation, {
localVue,
router
})
await wrapper.find('[data-test="contact"]').trigger('click')
expect(router.push).toHaveBeenCalledWith('/contact')
})
})
7.2 测试路由组件
假设有一个根据路由参数显示内容的组件 UserDetails.vue
:
vue
<template>
<div>
<h1 data-test="user-name">{{ userName }}</h1>
</div>
</template>
<script>
export default {
data() {
return {
userName: ''
}
},
created() {
// 根据路由参数获取用户名
this.userName = `用户 ${this.$route.params.id}`
}
}
</script>
测试代码 UserDetails.spec.js
:
javascript
import { mount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
import UserDetails from '@/components/UserDetails.vue'
const localVue = createLocalVue()
localVue.use(VueRouter)
describe('UserDetails.vue', () => {
it('根据路由参数显示用户名', () => {
// 创建带有初始路由和参数的路由实例
const router = new VueRouter()
// 创建带有模拟路由的组件
const wrapper = mount(UserDetails, {
localVue,
mocks: {
$route: {
params: {
id: '42'
}
}
}
})
expect(wrapper.find('[data-test="user-name"]').text()).toBe('用户 42')
})
})
8. 快照测试
快照测试可以确保组件 UI 不会意外改变。
8.1 基本快照测试
javascript
import { mount } from '@vue/test-utils'
import MessageDisplay from '@/components/MessageDisplay.vue'
describe('MessageDisplay.vue', () => {
it('渲染的 UI 与上次快照匹配', () => {
const wrapper = mount(MessageDisplay, {
propsData: {
message: '欢迎使用 Vue!'
}
})
expect(wrapper.html()).toMatchSnapshot()
})
})
8.2 更新快照
当组件合法变更后,需要更新快照:
bash
# 更新所有快照
jest --updateSnapshot
# 更新特定测试的快照
jest --updateSnapshot -t 'MessageDisplay'
9. 测试覆盖率
9.1 理解覆盖率指标
Jest 提供四种覆盖率指标:
- 语句覆盖率(Statements): 程序中执行到的语句比例
- 分支覆盖率(Branches): 程序中执行到的分支比例(if/else)
- 函数覆盖率(Functions): 被调用过的函数比例
- 行覆盖率(Lines): 程序中执行到的行数比例
9.2 覆盖率报告
执行带覆盖率报告的测试:
bash
jest --coverage
典型的覆盖率报告输出:
-----------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------------|---------|----------|---------|---------|-------------------
All files | 85.71 | 83.33 | 85.71 | 85.71 |
components/Counter.vue| 100.00 | 100.00 | 100.00 | 100.00 |
components/Form.vue | 71.42 | 66.67 | 71.42 | 71.42 | 15-18
-----------------------|---------|----------|---------|---------|-------------------
10. 测试最佳实践
10.1 组织测试
- 按照组件结构组织测试文件
- 为每个组件创建单独的测试文件
- 使用清晰的测试描述和分组
javascript
describe('组件名', () => {
describe('功能1', () => {
it('子功能 A', () => { /* ... */ })
it('子功能 B', () => { /* ... */ })
})
describe('功能2', () => {
it('子功能 C', () => { /* ... */ })
it('子功能 D', () => { /* ... */ })
})
})
10.2 测试原则
- 测试行为而非实现: 关注组件的输出而非内部工作方式
- 使用数据属性标记测试元素 : 使用
data-test
属性标记用于测试的元素 - 一个测试只测一个行为: 每个测试只断言一个行为
- 避免过度模拟: 尽量减少模拟的数量
- 编写可维护的测试: 测试代码应该和产品代码一样重视质量
10.3 常见测试场景对比
11. 持续集成中的测试
11.1 配置 CI 流程
在 GitHub Actions 中设置 Vue 测试的 .github/workflows/test.yml
配置:
yaml
name: Unit Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test:unit
- name: Upload coverage
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
11.2 测试报告整合
将 Jest 测试报告整合到 CI 系统,使用 JUnit 格式:
json
// package.json
{
"jest": {
"reporters": [
"default",
["jest-junit", {
"outputDirectory": "./test-results/jest",
"outputName": "results.xml"
}]
]
}
}
12. 测试驱动开发 (TDD) 与 Vue
12.1 TDD 流程
- 编写失败的测试: 先编写测试,验证未实现的功能
- 编写最少的代码使测试通过: 实现功能使测试通过
- 重构代码: 优化实现,保持测试通过
12.2 TDD 示例
假设我们要开发一个待办事项组件,先编写测试:
javascript
// TodoList.spec.js
import { mount } from '@vue/test-utils'
import TodoList from '@/components/TodoList.vue'
describe('TodoList.vue', () => {
it('显示待办事项列表', () => {
const wrapper = mount(TodoList, {
propsData: {
todos: [
{ id: 1, text: '学习 Vue', done: false },
{ id: 2, text: '学习单元测试', done: true }
]
}
})
const items = wrapper.findAll('[data-test="todo-item"]')
expect(items).toHaveLength(2)
expect(items.at(0).text()).toContain('学习 Vue')
expect(items.at(1).text()).toContain('学习单元测试')
expect(items.at(1).classes()).toContain('completed')
})
it('添加新的待办事项', async () => {
const wrapper = mount(TodoList)
await wrapper.find('[data-test="new-todo"]').setValue('新任务')
await wrapper.find('form').trigger('submit')
expect(wrapper.findAll('[data-test="todo-item"]')).toHaveLength(1)
expect(wrapper.find('[data-test="todo-item"]').text()).toContain('新任务')
})
})
然后实现组件:
vue
<template>
<div>
<form @submit.prevent="addTodo">
<input v-model="newTodo" data-test="new-todo" />
<button type="submit">添加</button>
</form>
<ul>
<li
v-for="todo in allTodos"
:key="todo.id"
:class="{ completed: todo.done }"
data-test="todo-item"
>
{{ todo.text }}
</li>
</ul>
</div>
</template>
<script>
export default {
props: {
todos: {
type: Array,
default: () => []
}
},
data() {
return {
newTodo: '',
localTodos: []
}
},
computed: {
allTodos() {
return [...this.todos, ...this.localTodos]
}
},
methods: {
addTodo() {
if (this.newTodo.trim()) {
this.localTodos.push({
id: Date.now(),
text: this.newTodo,
done: false
})
this.newTodo = ''
}
}
}
}
</script>
<style scoped>
.completed {
text-decoration: line-through;
}
</style>
13. 常见问题与解决方案
13.1 异步测试问题
问题 : 测试未等待组件更新就进行断言
解决方案 : 使用 await
和 nextTick
javascript
// 错误示例
wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('已更新') // 可能失败
// 正确示例
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('已更新') // 已等待更新
13.2 复杂 DOM 结构查找问题
问题 : 难以准确定位要测试的元素
解决方案 : 使用 data-test
属性标记测试元素
vue
<template>
<div>
<h1 data-test="title">标题</h1>
<p data-test="content">内容</p>
</div>
</template>
javascript
// 使用 data-test 属性查找元素
wrapper.find('[data-test="title"]')
13.3 模拟复杂的 Vuex Store
问题 : 大型应用中 Store 结构复杂
解决方案: 只模拟测试需要的部分
javascript
const store = new Vuex.Store({
modules: {
user: {
namespaced: true,
state: { name: 'Test User' },
getters: {
fullName: () => 'Test User Full'
},
actions: {
login: jest.fn()
}
},
// 其他模块可以省略
}
})
结语
感谢您的阅读!期待您的一键三连!欢迎指正!
