Vue3单元测试实战:用Jest和Vue Test Utils为组件编写测试
Vue3 单元测试实战:用 Jest 和 Vue Test Utils 为组件编写测试
目录
- 前言
- 2.1 安装 Jest 与 Vue Test Utils
- 2.2 配置
jest.config.js
- 2.3 配置 Babel 与 Vue 支持
- 测试基本流程图解
- 4.1 创建组件
HelloWorld.vue
- 4.2 编写测试文件
HelloWorld.spec.js
- 4.3 运行测试并断言
- 4.1 创建组件
- 5.1 创建带 Props 的组件
Greeting.vue
- 5.2 编写对应测试
Greeting.spec.js
- 5.3 覆盖默认值、传入不同值的场景
- 5.1 创建带 Props 的组件
- 6.1 创建带事件的组件
Counter.vue
- 6.2 编写测试:触发点击、监听自定义事件
- 6.1 创建带事件的组件
- 7.1 创建异步组件
FetchData.vue
- 7.2 使用
jest.mock
模拟 API - 7.3 编写测试:等待异步更新并断言
- 7.1 创建异步组件
- 8.1 配置 Pinia 测试环境
- 8.2 测试依赖注入(
provide
/inject
)
- 9.1 使用
beforeEach
和afterEach
重置状态 - 9.2 测试组件生命周期钩子
- 9.3 测试路由组件(
vue-router
)
- 9.1 使用
- 总结
前言
在前端开发中,组件化带来了更高的可维护性,而单元测试是保证组件质量的重要手段。对于 Vue3 项目,Jest 与 Vue Test Utils 是最常用的测试工具组合。本文将带你从零开始,逐步搭建测试环境,了解 Jest 与 Vue Test Utils 的核心用法,并通过丰富的代码示例与ASCII 流程图,手把手教你如何为 Vue3 组件编写测试用例,覆盖 Props、事件、异步、依赖注入等常见场景。
项目环境搭建
2.1 安装 Jest 与 Vue Test Utils
假设你已有一个 Vue3 项目(基于 Vite 或 Vue CLI)。首先需要安装测试依赖:
npm install --save-dev jest @vue/test-utils@next vue-jest@next babel-jest @babel/core @babel/preset-env
jest
:测试运行器@vue/test-utils@next
:Vue3 版本的 Test Utilsvue-jest@next
:用于把.vue
文件转换为 Jest 可执行的模块babel-jest
,@babel/core
,@babel/preset-env
:用于支持 ES 模块与最新语法
如果你使用 TypeScript,则再安装:
npm install --save-dev ts-jest @types/jest
2.2 配置 jest.config.js
在项目根目录创建 jest.config.js
:
// jest.config.js
module.exports = {
// 表示运行环境为 jsdom(用于模拟浏览器环境)
testEnvironment: 'jsdom',
// 文件扩展名
moduleFileExtensions: ['js', 'json', 'vue'],
// 转换规则,针对 vue 单文件组件和 js
transform: {
'^.+\\.vue$': 'vue-jest',
'^.+\\.js$': 'babel-jest'
},
// 解析 alias,如果在 vite.config.js 中配置过 @ 别名,需要同步映射
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
// 测试匹配的文件
testMatch: ['**/__tests__/**/*.spec.js', '**/*.spec.js'],
// 覆盖报告
collectCoverage: true,
coverageDirectory: 'coverage',
};
2.3 配置 Babel 与 Vue 支持
在项目根目录添加 babel.config.js
,使 Jest 能够处理现代语法:
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }]
]
};
同时确保 package.json
中的 scripts
包含:
{
"scripts": {
"test": "jest --watchAll"
}
}
此时执行 npm run test
,若无报错,说明测试环境已初步搭建成功。
测试基本流程图解
在实际测试中,流程可以概括为:
┌─────────────────────────────────────────────┐
│ 开发者编写或修改 Vue 组件 │
│ 例如: HelloWorld.vue │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 编写对应单元测试文件 HelloWorld.spec.js │
│ 使用 Vue Test Utils mount/shallowMount │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 运行 Jest 测试命令 npm run test │
├─────────────────────────────────────────────┤
│ Jest 根据 jest.config.js 加载测试文件 │
│ 将 .vue 文件由 vue-jest 转译为 JS 模块 │
│ Babel 将 ES6/ESNext 语法转换为 CommonJS │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 测试用例执行: │
│ 1. mount 组件,得到 wrapper/vnode │
│ 2. 执行渲染,产生 DOM 片段 │
│ 3. 断言 DOM 结构与组件行为 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Jest 输出测试结果与覆盖率 │
└─────────────────────────────────────────────┘
- vue-jest:负责把
.vue
单文件组件转换为 Jest 可运行的 JS - babel-jest:负责把 JS 中的现代语法(例如
import
、async/await
)转换为 Jest 支持的 - mount/shallowMount:Vue Test Utils 提供的挂载方法,用于渲染组件并返回可操作的
wrapper
对象 - 断言:配合 Jest 的
expect
API,对wrapper.html()
、wrapper.text()
、wrapper.find()
等进行校验
第一个测试示例:测试简单组件
4.1 创建组件 HelloWorld.vue
在 src/components/HelloWorld.vue
:
<template>
<div class="hello">
<h1>{{ title }}</h1>
<p>{{ msg }}</p>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
title: {
type: String,
default: 'Hello Vue3'
},
msg: {
type: String,
required: true
}
});
</script>
<style scoped>
.hello {
text-align: center;
}
</style>
title
带有默认值msg
是必填的 props
4.2 编写测试文件 HelloWorld.spec.js
在 tests/HelloWorld.spec.js
或者 src/components/__tests__/HelloWorld.spec.js
:
// HelloWorld.spec.js
import { mount } from '@vue/test-utils';
import HelloWorld from '@/components/HelloWorld.vue';
describe('HelloWorld.vue', () => {
it('渲染默认 title 和传入 msg', () => {
// 不传 title,使用默认值
const wrapper = mount(HelloWorld, {
props: { msg: '这是单元测试示例' }
});
// 检查 h1 文本
expect(wrapper.find('h1').text()).toBe('Hello Vue3');
// 检查 p 文本
expect(wrapper.find('p').text()).toBe('这是单元测试示例');
});
it('渲染自定义 title', () => {
const wrapper = mount(HelloWorld, {
props: {
title: '自定义标题',
msg: '另一个消息'
}
});
expect(wrapper.find('h1').text()).toBe('自定义标题');
expect(wrapper.find('p').text()).toBe('另一个消息');
});
});
mount(HelloWorld, { props })
:渲染组件wrapper.find('h1').text()
:获取元素文本并断言
4.3 运行测试并断言
在命令行执行:
npm run test
若一切正常,将看到类似:
PASS src/components/HelloWorld.spec.js
HelloWorld.vue
✓ 渲染默认 title 和传入 msg (20 ms)
✓ 渲染自定义 title (5 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
至此,你已成功编写并运行了第一个 Vue3 单元测试。
测试带有 Props 的组件
5.1 创建带 Props 的组件 Greeting.vue
在 src/components/Greeting.vue
:
<template>
<div>
<p v-if="name">你好,{{ name }}!</p>
<p v-else>未传入姓名</p>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
name: {
type: String,
default: ''
}
});
</script>
- 展示两种情况:传入
name
和不传的场景
5.2 编写对应测试 Greeting.spec.js
// Greeting.spec.js
import { mount } from '@vue/test-utils';
import Greeting from '@/components/Greeting.vue';
describe('Greeting.vue', () => {
it('未传入 name 时,显示提示信息', () => {
const wrapper = mount(Greeting);
expect(wrapper.text()).toContain('未传入姓名');
});
it('传入 name 时,显示问候语', () => {
const wrapper = mount(Greeting, {
props: { name: '张三' }
});
expect(wrapper.text()).toContain('你好,张三!');
});
});
5.3 覆盖默认值、传入不同值的场景
为了提高覆盖率,你还可以测试以下边界情况:
- 传入空字符串
- 传入特殊字符
it('传入空字符串时仍显示“未传入姓名”', () => {
const wrapper = mount(Greeting, { props: { name: '' } });
expect(wrapper.text()).toBe('未传入姓名');
});
it('传入特殊字符时能正确渲染', () => {
const wrapper = mount(Greeting, { props: { name: '😊' } });
expect(wrapper.find('p').text()).toBe('你好,😊!');
});
测试带有事件和回调的组件
6.1 创建带事件的组件 Counter.vue
在 src/components/Counter.vue
:
<template>
<div>
<button @click="increment">+1</button>
<span class="count">{{ count }}</span>
</div>
</template>
<script setup>
import { ref, defineEmits } from 'vue';
const emit = defineEmits(['update']); // 向父组件发送 update 事件
const count = ref(0);
function increment() {
count.value++;
emit('update', count.value); // 每次点击向外发当前 count
}
</script>
<style scoped>
.count {
margin-left: 8px;
font-weight: bold;
}
</style>
- 每次点击按钮,
count
自增并通过$emit('update', count)
将当前值传递给父组件
6.2 编写测试:触发点击、监听自定义事件
在 src/components/Counter.spec.js
:
import { mount } from '@vue/test-utils';
import Counter from '@/components/Counter.vue';
describe('Counter.vue', () => {
it('点击按钮后 count 增加并触发 update 事件', async () => {
// 包含监听 update 事件的 mock 函数
const wrapper = mount(Counter);
const button = wrapper.find('button');
const countSpan = wrapper.find('.count');
// 监听自定义事件
await button.trigger('click');
expect(countSpan.text()).toBe('1');
// 获取 emitted 事件列表
const updates = wrapper.emitted('update');
expect(updates).toBeTruthy(); // 事件存在
expect(updates.length).toBe(1); // 触发一次
expect(updates[0]).toEqual([1]); // 传递的参数为 [1]
// 再次点击
await button.trigger('click');
expect(countSpan.text()).toBe('2');
expect(wrapper.emitted('update').length).toBe(2);
expect(wrapper.emitted('update')[1]).toEqual([2]);
});
});
await button.trigger('click')
:模拟点击wrapper.emitted('update')
:获取所有update
事件调用记录,是一个二维数组,每次事件调用的参数保存为数组
测试异步行为与 API 请求
7.1 创建异步组件 FetchData.vue
在 src/components/FetchData.vue
,假设它在挂载后请求 API 并展示结果:
<template>
<div>
<button @click="loadData">加载数据</button>
<div v-if="loading">加载中...</div>
<div v-else-if="error">{{ error }}</div>
<ul v-else>
<li v-for="item in items" :key="item.id">{{ item.text }}</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
import axios from 'axios';
const items = ref([]);
const loading = ref(false);
const error = ref('');
async function loadData() {
loading.value = true;
error.value = '';
try {
const res = await axios.get('/api/items');
items.value = res.data;
} catch (e) {
error.value = '请求失败';
} finally {
loading.value = false;
}
}
</script>
<style scoped>
li {
list-style: none;
}
</style>
loadData
按钮触发异步请求,加载成功后将items
更新成接口返回值,失败时显示错误
7.2 使用 jest.mock
模拟 API
在测试文件 FetchData.spec.js
中,先 mock
掉 axios
模块:
// FetchData.spec.js
import { mount } from '@vue/test-utils';
import FetchData from '@/components/FetchData.vue';
import axios from 'axios';
// 模拟 axios.get
jest.mock('axios');
describe('FetchData.vue', () => {
it('加载成功时,展示列表', async () => {
// 先定义 axios.get 返回的 Promise
const mockData = [{ id: 1, text: '项目一' }, { id: 2, text: '项目二' }];
axios.get.mockResolvedValue({ data: mockData });
const wrapper = mount(FetchData);
// 点击按钮触发 loadData
await wrapper.find('button').trigger('click');
// loading 状态
expect(wrapper.text()).toContain('加载中...');
// 等待所有异步操作完成
await wrapper.vm.$nextTick(); // 等待 DOM 更新
await wrapper.vm.$nextTick(); // 再次等待,确保 Promise resolve 后更新
// 此时 loading 已为 false,列表渲染成功
const listItems = wrapper.findAll('li');
expect(listItems).toHaveLength(2);
expect(listItems[0].text()).toBe('项目一');
expect(listItems[1].text()).toBe('项目二');
expect(wrapper.text()).not.toContain('加载中...');
});
it('加载失败时,展示错误信息', async () => {
// 模拟 reject
axios.get.mockRejectedValue(new Error('网络错误'));
const wrapper = mount(FetchData);
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('加载中...');
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('请求失败');
});
});
jest.mock('axios')
:告诉 Jest 拦截对axios
的导入,并使用模拟实现axios.get.mockResolvedValue(...)
:模拟请求成功axios.get.mockRejectedValue(...)
:模拟请求失败- 两次
await wrapper.vm.$nextTick()
用于保证 Vue 的异步 DOM 更新完成
7.3 编写测试:等待异步更新并断言
在上述测试中,我们重点关注:
- 点击触发异步请求后,
loading
文本出现 - 等待 Promise resolve 后,列表渲染与错误处理逻辑
测试带有依赖注入与 Pinia 的组件
8.1 配置 Pinia 测试环境
假设我们在组件中使用了 Pinia 管理全局状态,需要在测试时注入 Pinia。先安装 Pinia:
npm install pinia --save
在测试中可手动创建一个测试用的 Pinia 实例并传入:
// store/counter.js
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++;
}
}
});
在组件 CounterWithPinia.vue
中:
<template>
<div>
<button @click="increment">+1</button>
<span class="count">{{ counter.count }}</span>
</div>
</template>
<script setup>
import { useCounterStore } from '@/store/counter';
import { storeToRefs } from 'pinia';
const counter = useCounterStore();
const { count } = storeToRefs(counter);
function increment() {
counter.increment();
}
</script>
测试时:在每个测试文件中创建 Pinia 并挂载:
// CounterWithPinia.spec.js
import { mount } from '@vue/test-utils';
import CounterWithPinia from '@/components/CounterWithPinia.vue';
import { createPinia, setActivePinia } from 'pinia';
describe('CounterWithPinia.vue', () => {
beforeEach(() => {
// 每个测试前初始化 Pinia
setActivePinia(createPinia());
});
it('点击按钮后,Pinia store count 增加', async () => {
const wrapper = mount(CounterWithPinia, {
global: {
plugins: [createPinia()]
}
});
expect(wrapper.find('.count').text()).toBe('0');
await wrapper.find('button').trigger('click');
expect(wrapper.find('.count').text()).toBe('1');
});
});
setActivePinia(createPinia())
:使测试用例中的useCounterStore()
能拿到新创建的 Pinia 实例- 在
mount
时通过global.plugins: [createPinia()]
把 Pinia 插件传递给 Vue 应用上下文
8.2 测试依赖注入(provide
/ inject
)
如果组件使用了 provide
/ inject
,需要在测试时手动提供或模拟注入。示例:
<!-- ParentProvide.vue -->
<template>
<ChildInject />
</template>
<script setup>
import { provide } from 'vue';
function parentMethod(msg) {
// ...
}
provide('parentMethod', parentMethod);
</script>
对应的 ChildInject.vue
:
<template>
<button @click="callParent">通知父组件</button>
</template>
<script setup>
import { inject } from 'vue';
const parentMethod = inject('parentMethod');
function callParent() {
parentMethod && parentMethod('Hello');
}
</script>
测试时,需要手动提供注入的 parentMethod
:
// ChildInject.spec.js
import { mount } from '@vue/test-utils';
import ChildInject from '@/components/ChildInject.vue';
describe('ChildInject.vue', () => {
it('调用注入的方法', async () => {
const mockFn = jest.fn();
const wrapper = mount(ChildInject, {
global: {
provide: {
parentMethod: mockFn
}
}
});
await wrapper.find('button').trigger('click');
expect(mockFn).toHaveBeenCalledWith('Hello');
});
});
高级技巧与最佳实践
9.1 使用 beforeEach
和 afterEach
重置状态
- 在多个测试中需要重复挂载组件或初始化全局插件时,可把公共逻辑放到
beforeEach
中,比如重置 Jest 模块模拟、创建 Pinia、清空 DOM:
describe('FetchData.vue', () => {
let wrapper;
beforeEach(() => {
// 清空所有 mock
jest.clearAllMocks();
// 挂载组件
wrapper = mount(FetchData, {
global: { /* ... */ }
});
});
afterEach(() => {
// 卸载组件,清理 DOM
wrapper.unmount();
});
it('...', async () => {
// ...
});
});
9.2 测试组件生命周期钩子
有时需要验证某个钩子是否被调用,例如 onMounted
中执行某段逻辑。可以在测试中通过 spy 或 mock 来断言。
import { mount } from '@vue/test-utils';
import MyComponent from '@/components/MyComponent.vue';
jest.spyOn(MyComponent, 'setup'); // 如果 setup 有副作用
describe('MyComponent.vue', () => {
it('should call onMounted callback', () => {
const onMountedSpy = jest.fn();
mount(MyComponent, {
global: {
provide: {
onMountedCallback: onMountedSpy
}
}
});
// 假设组件在 onMounted 中会调用 inject 的 onMountedCallback
expect(onMountedSpy).toHaveBeenCalled();
});
});
9.3 测试路由组件(vue-router
)
当组件依赖路由实例时,需要在测试中模拟路由环境。示例:
<!-- UserProfile.vue -->
<template>
<div>{{ userId }}</div>
</template>
<script setup>
import { useRoute } from 'vue-router';
const route = useRoute();
const userId = route.params.id;
</script>
测试时提供一个替代的路由环境:
// UserProfile.spec.js
import { mount } from '@vue/test-utils';
import UserProfile from '@/components/UserProfile.vue';
import { createRouter, createMemoryHistory } from 'vue-router';
describe('UserProfile.vue', () => {
it('渲染路由参数 id', () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/user/:id', component: UserProfile }]
});
router.push('/user/123');
return router.isReady().then(() => {
const wrapper = mount(UserProfile, {
global: {
plugins: [router]
}
});
expect(wrapper.text()).toBe('123');
});
});
});
总结
本文从搭建测试环境、基本流程图解出发,深入讲解了如何使用 Jest 与 Vue Test Utils 为 Vue3 组件编写单元测试。包括:
- 测试简单组件:验证模板输出与 Props 默认值
- 测试事件交互:模拟用户点击、监听
$emit
事件 - 测试异步请求:使用
jest.mock
模拟网络请求,等待异步更新后断言 - 测试依赖注入与 Pinia 状态:提供
provide
、初始化 Pinia,并验证组件与全局状态的交互 - 高级技巧:利用 Jest 钩子重置状态、测试生命周期钩子、测试路由组件
通过丰富的代码示例与图解,希望能帮助你快速掌握 Vue3 单元测试的实战要点,将组件质量与代码健壮性提升到新的高度。
评论已关闭