当前位置: 首页 > news >正文

【Vue】单元测试(Jest/Vue Test Utils)

在这里插入图片描述

个人主页:Guiat
归属专栏:Vue

在这里插入图片描述

文章目录

  • 1. Vue 单元测试简介
    • 1.1 为什么需要单元测试
    • 1.2 测试工具介绍
  • 2. 环境搭建
    • 2.1 安装依赖
    • 2.2 配置 Jest
  • 3. 编写第一个测试
    • 3.1 组件示例
    • 3.2 编写测试用例
    • 3.3 运行测试
  • 4. Vue Test Utils 核心 API
    • 4.1 挂载组件
    • 4.2 常用断言和操作
  • 5. 测试组件交互
    • 5.1 测试用户输入
    • 5.2 测试 Props 和自定义事件
  • 6. 模拟依赖
    • 6.1 模拟 API 请求
    • 6.2 模拟 Vuex
  • 7. 测试 Vue Router
    • 7.1 模拟 Vue Router
    • 7.2 测试路由组件
  • 8. 快照测试
    • 8.1 基本快照测试
    • 8.2 更新快照
  • 9. 测试覆盖率
    • 9.1 理解覆盖率指标
    • 9.2 覆盖率报告
  • 10. 测试最佳实践
    • 10.1 组织测试
    • 10.2 测试原则
    • 10.3 常见测试场景对比
  • 11. 持续集成中的测试
    • 11.1 配置 CI 流程
    • 11.2 测试报告整合
  • 12. 测试驱动开发 (TDD) 与 Vue
    • 12.1 TDD 流程
    • 12.2 TDD 示例
  • 13. 常见问题与解决方案
    • 13.1 异步测试问题
    • 13.2 复杂 DOM 结构查找问题
    • 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 安装依赖

# 使用 Vue CLI 创建项目时选择单元测试
vue create my-project# 或在现有项目中安装
npm install --save-dev jest @vue/test-utils vue-jest babel-jest

2.2 配置 Jest

package.json 中添加 Jest 配置:

{"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

<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 文件:

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 运行测试

npm run test:unit

4. Vue Test Utils 核心 API

4.1 挂载组件

// 完全挂载组件及其子组件
const wrapper = mount(Component, {propsData: { /* 组件 props */ },data() { /* 覆盖组件数据 */ },mocks: { /* 模拟全局对象 */ },stubs: { /* 替换子组件 */ }
})// 只挂载当前组件,不渲染子组件
const wrapper = shallowMount(Component, options)

4.2 常用断言和操作

// 查找元素
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

<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

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

<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

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

<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 = trueconst response = await axios.get('/api/users')this.users = response.data} catch (error) {this.error = '获取用户列表失败'} finally {this.loading = false}}}
}
</script>

测试代码 UserList.spec.js

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

<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

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 storelet actionslet statebeforeEach(() => {// 设置模拟的 state 和 actionsstate = {count: 5}actions = {increment: jest.fn(),decrement: jest.fn()}// 创建模拟的 storestore = 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

<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

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

<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

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 基本快照测试

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 更新快照

当组件合法变更后,需要更新快照:

# 更新所有快照
jest --updateSnapshot# 更新特定测试的快照
jest --updateSnapshot -t 'MessageDisplay'

9. 测试覆盖率

9.1 理解覆盖率指标

Jest 提供四种覆盖率指标:

  • 语句覆盖率(Statements): 程序中执行到的语句比例
  • 分支覆盖率(Branches): 程序中执行到的分支比例(if/else)
  • 函数覆盖率(Functions): 被调用过的函数比例
  • 行覆盖率(Lines): 程序中执行到的行数比例

9.2 覆盖率报告

执行带覆盖率报告的测试:

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 组织测试

  • 按照组件结构组织测试文件
  • 为每个组件创建单独的测试文件
  • 使用清晰的测试描述和分组
describe('组件名', () => {describe('功能1', () => {it('子功能 A', () => { /* ... */ })it('子功能 B', () => { /* ... */ })})describe('功能2', () => {it('子功能 C', () => { /* ... */ })it('子功能 D', () => { /* ... */ })})
})

10.2 测试原则

  • 测试行为而非实现: 关注组件的输出而非内部工作方式
  • 使用数据属性标记测试元素: 使用 data-test 属性标记用于测试的元素
  • 一个测试只测一个行为: 每个测试只断言一个行为
  • 避免过度模拟: 尽量减少模拟的数量
  • 编写可维护的测试: 测试代码应该和产品代码一样重视质量

10.3 常见测试场景对比

graph TDA[测试场景] --> B[组件渲染]A --> C[用户交互]A --> D[API 调用]A --> E[Vuex 整合]A --> F[路由功能]B --> B1[使用 mount() 测试完整渲染]B --> B2[使用 shallowMount() 测试隔离组件]B --> B3[使用 toMatchSnapshot() 测试 UI 稳定性]C --> C1[使用 trigger() 测试点击事件]C --> C2[使用 setValue() 测试表单输入]C --> C3[使用 emitted() 测试自定义事件]D --> D1[使用 jest.mock() 模拟 axios]D --> D2[测试加载状态]D --> D3[测试成功/失败处理]E --> E1[模拟 Vuex store]E --> E2[测试 getter 计算属性]E --> E3[验证 actions 被正确调度]F --> F1[使用 mocks 模拟 $route]F --> F2[验证 router.push 调用]F --> F3[测试基于路由的组件行为]

11. 持续集成中的测试

11.1 配置 CI 流程

在 GitHub Actions 中设置 Vue 测试的 .github/workflows/test.yml 配置:

name: Unit Testson:push:branches: [ main ]pull_request:branches: [ main ]jobs:test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v2- name: Use Node.jsuses: actions/setup-node@v2with:node-version: '14'- name: Install dependenciesrun: npm ci- name: Run testsrun: npm run test:unit- name: Upload coverageuses: codecov/codecov-action@v1with:token: ${{ secrets.CODECOV_TOKEN }}

11.2 测试报告整合

将 Jest 测试报告整合到 CI 系统,使用 JUnit 格式:

// package.json
{"jest": {"reporters": ["default",["jest-junit", {"outputDirectory": "./test-results/jest","outputName": "results.xml"}]]}
}

12. 测试驱动开发 (TDD) 与 Vue

12.1 TDD 流程

  1. 编写失败的测试: 先编写测试,验证未实现的功能
  2. 编写最少的代码使测试通过: 实现功能使测试通过
  3. 重构代码: 优化实现,保持测试通过

12.2 TDD 示例

假设我们要开发一个待办事项组件,先编写测试:

// 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('新任务')})
})

然后实现组件:

<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 异步测试问题

问题: 测试未等待组件更新就进行断言
解决方案: 使用 awaitnextTick

// 错误示例
wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('已更新')  // 可能失败// 正确示例
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('已更新')  // 已等待更新

13.2 复杂 DOM 结构查找问题

问题: 难以准确定位要测试的元素
解决方案: 使用 data-test 属性标记测试元素

<template><div><h1 data-test="title">标题</h1><p data-test="content">内容</p></div>
</template>
// 使用 data-test 属性查找元素
wrapper.find('[data-test="title"]')

13.3 模拟复杂的 Vuex Store

问题: 大型应用中 Store 结构复杂
解决方案: 只模拟测试需要的部分

const store = new Vuex.Store({modules: {user: {namespaced: true,state: { name: 'Test User' },getters: {fullName: () => 'Test User Full'},actions: {login: jest.fn()}},// 其他模块可以省略}
})

结语
感谢您的阅读!期待您的一键三连!欢迎指正!

在这里插入图片描述

相关文章:

  • React自定义Hook之useMutilpleRef
  • React19源码系列之 root.render过程
  • Animate 中HTMLCanvas 画布下的鼠标事件列表(DOM 鼠标)
  • 14、服务端组件:未来魔法预览——React 19 RSC实践
  • 权力结构下的人才价值重构:从 “工具论” 到 “存在论” 的转变​
  • 详解React Fiber架构中,reconcile阶段的具体工作流程
  • 【项目篇之消息序列化】仿照RabbitMQ模拟实现消息队列
  • PostgreSQL psql 命令和常用的 SQL 语句整理
  • WGS84(GPS)、火星坐标系(GCJ02)、百度地图(BD09)坐标系转换Java代码
  • 哈希封装unordered_map和unordered_set的模拟实现
  • 海思dump图原理
  • socket套接字-UDP(中)
  • java Optional
  • 【MQ篇】RabbitMQ之死信交换机!
  • OpenCV 图形API(65)图像结构分析和形状描述符------拟合二维点集的直线函数 fitLine2D()
  • FlinkUpsertKafka深度解析
  • 基础的贝叶斯神经网络(BNN)回归
  • 零基础小白如何上岸数模国奖
  • 大学之大:伦敦政治经济学院2025.4.27
  • 【音视频】FFmpeg过滤器框架分析
  • 走访中广核风电基地:701台风机如何乘风化电,点亮3000万人绿色生活
  • 流浪猫给车主造成困扰,长春一小区拟投药应对?律师:此举欠妥
  • 影子调查|23岁男子驾照拟注销背后的“被精神病”疑云
  • 在上海生活8年,13岁英国女孩把城市记忆写进歌里
  • 清华成立人工智能医院,将构建“AI+医疗+教育+科研”闭环
  • 哈马斯同意释放剩余所有以色列方面被扣押人员,以换取停火五年