1.前言
近期对现有的前端项目新增了单元测试,特此对这段时间用到的一些单元测试知识做一下总结。
2.单元测试工具
项目的技术选型是vue3
+vite
+Pinia
,单元测试工具采用vitest
+Vue Test Utils
。
vitest
:vitest是vite项目的首选测试框架,可以与vite共用配置、转换器、解析器和插件,提供类jest的api,方便上手。官网链接Vue Test Utils
:Vue Test Utils 是 Vue.js 官方的单元测试实用工具库,可以实现vue单文件的测试。官网链接
3.快速起步
3.1.安装vitest
npm install -D vitest @vue/test-utils jsdom
3.2.添加配置
在vite.config.js
添加test配置项,详细配置参考配置文档
import { defineConfig } from 'vite'
export default defineConfig({
// ...
test: {
environment: 'jsdom',
},
})
3.3.package.json新增测试命令
{
"scripts": {
"test": "vitest"
}
}
3.4.一个单元测试示例
待测试的组件HelloWorld.vue
<template>
<h1 class="name">{{ name }}</h1>
</template>
<script setup>
import { ref } from 'vue';
const name = ref('hello world!');
</script>
<style scoped>
</style>
在项目的根目录新建__test__
目录,并在目录下新建hellowworld.test.js
import {
describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import HelloWorld from '@/components/HelloWorld.vue';
describe('测试示例', () => {
it('hellow world', () => {
const wrapper = mount(HelloWorld);
expect(wrapper.html()).toMatchSnapshot(); // 快照测试
expect(wrapper.find('.name').text()).toBe('hello world!');
});
});
我们使用常见的describe
和it
语法,由 vitest 提供。describe
表示测试会包含什么。it
表示该段的测试主题。随着我们为组件添加更多特性,在测试中就会添加更多it
块。
我们用mount
渲染组件,获取wrapper
的变量,再通过find
获取class属性为name的元素,断言文案是hello world!。
命令行运行npm run test
,完成一次单元测试。
接下来将介绍如何对vue组件进行单元测试。
4.案例实践
4.1.组件渲染
mount和shallowMount
Vue Test Utils
提供两种渲染方式,mount和shallowMount。区别是mount
会渲染子组件,shallowMount
把子组件渲染为stub组件。
假如单元测试用例不涉及子组件功能测试的话,使用shallowMount
更合理。
mount
和shallowMount
的第二个参数提供可以传入propsData、slots等内容,可以挂载到组件实例上,详见挂载选项。
通常一个describe块只需要渲染一次组件,因此我们可以使用beforeEach
钩子函数实现组件的共享。
import {
describe, expect, it, beforeEach, afterEach
} from 'vitest';
import { mount } from '@vue/test-utils';
import HelloWorld from '@/components/HelloWorld.vue';
describe('测试示例', () => {
let wrapper = null;
beforeEach(() => {
wrapper = mount(HelloWorld); // 挂载组件
});
it('hellow world', () => {
expect(wrapper.find('.name').text()).toBe('hello world!');
});
it('hellow world isVisible', () => {
wrapper.find('.name').isVisible();
});
});
获取dom元素和子组件
Vue Test Utils
提供类似jQuery的css选择器方式获取dom。
import Parent from './Parent.vue'
import Child from './Child.vue'
let wrapper = mount(Parent); // 挂载组件
wrapper.find('.name'); // 获取类名为name的元素
wrapper.findAll('.namelist'); // 获取类名为namelist的元素集合
wrapper.findComponent(Child); // 获取parent中的一个child子组件
wrapper.findComponentAll(Child); // 获取parent中的一个child子组件集合
获取组件实例
可以通过vm
获取组件的实例,可以获取和调用组件的数据和方法。
wrapper.vm; // 获取根组件实例
wrapper.findComponent(Child).vm; // 获取子组件实例
wrapper.findComponent(Child).vm.$emit('change'); // 触发子组件事件
wrapper.findComponent(Child).vm.getList(); // 触发子组件的getList事件
其它属性
mount提供html、class等信息的获取,详见wrapper-methods
4.2.props测试
mount
和shallowMount
的挂载选项提供propsData参数实现子组件的props值传递。同时可以使用setProps
动态更改props值。
待测试的组件Props.vue
<template>
<div class="name">
{{name}}
</div>
</template>
<script setup>
const props = defineProps(['name'])
</script>
<style lang="scss" scoped>
</style>
测试文件props.test.js
import {
describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import Props from '@/components/Props.vue';
describe('props测试示例', () => {
it('测试name传值', async () => {
const name = '张三';
const newName = '李四';
const wrapper = mount(Props, {
// 往子组件传递name值
propsData: {
name
}
});
expect(wrapper.find('.name').text()).toBe(name);
await wrapper.setProps({ name: newName }) // 中途修改props的值
expect(wrapper.find('.name').text()).toBe(newName);
});
});
4.3.dom事件测试
Vue Test Utils
提供trigger
API实现鼠标、键盘的时间触发。要注意的是,trigger
会返回一个Promise,当这个Promise被解决时,才确保组件已经被更新。
下面是一个点击事件的示例。待测试的组件Count.vue
<template>
<div>
<button class="add" @click="add">点我</button>
<p class="count">{{count}}</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0);
function add() {
count.value++;
}
</script>
<style lang="scss" scoped>
</style>
测试文件trigger.test.js
import {
describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import Count from '@/components/Count.vue';
describe('trigger测试示例', () => {
it('测试点击事件', async () => {
const wrapper = mount(Count);
await wrapper.find('.add').trigger('click'); // 触发click事件
expect(wrapper.find('.count').text()).toBe('1');
});
});
4.4.emit事件测试
Vue Test Utils
提供emitted
API获取已经触发的事件。
待测试的组件Emit.vue
<template>
<div>
<button class="btn" @click="clickMe">点我</button>
</div>
</template>
<script setup>
const emit = defineEmits(['change'])
function clickMe() {
emit('change', true)
}
</script>
<style lang="scss" scoped>
</style>
测试文件emit.test.js
import {
describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import Emit from '@/components/Emit.vue';
describe('emit测试示例', () => {
it('change事件触发测试', () => {
const wrapper = mount(Emit);
wrapper.find('.btn').trigger('click');
expect(wrapper.emitted().change).toMatchObject([[true]]); // 获取change事件
});
});
4.5.插槽测试
Vue Test Utils
提供slots
选项往组件中放入插槽
普通插槽
待测试的组件Slot.vue
<template>
<div>
<slot></slot>
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
</style>
测试文件slot.test.js
import {
describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import Slot from '@/components/Slot.vue';
describe('测试示例', () => {
it('普通插槽', () => {
const wrapper = mount(Slot, {
slots: {
default: '<div class="content">我是内容</div>' // 插槽内容
}
});
expect(wrapper.find('.content').text()).toBe('我是内容');
});
});
具名插槽
slots
选项的key对应<slot>
的name
属性,从而实现具名插槽。
待测试的组件NameSlot.vue
<template>
<div>
<header>
<slot name="header" />
</header>
<main>
<slot name="main" />
</main>
<footer>
<slot name="footer" />
</footer>
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
</style>
测试文件nameslot.test.js
import {
describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import NameSlot from '@/components/NameSlot.vue';
describe('测试示例', () => {
it('普通插槽', () => {
const wrapper = mount(NameSlot, {
slots: {
header: '<div class="header">Header</div>',
main: '<div class="content">Main Content</div>',
footer: '<div class="footer">Footer</div>'
}
});
expect(wrapper.find('.content').text()).toBe('Main Content');
expect(wrapper.find('.header').text()).toBe('Header');
expect(wrapper.find('.footer').text()).toBe('Footer');
});
});
4.6.表单测试
setValue
可以设置一个文本控件或select元素的值并更新 v-model
绑定的数据。
待测试的组件Login.vue
,这是一个登录组件,输入账号密码进行登录。
<template>
<div>
<section>
<input class="account" v-model="account" type="text" placeholder="请输入账号">
</section>
<section>
<input class="password" v-model="password" type="password" placeholder="请输入密码">
</section>
<section>
<button class="login" @click="login">登录</button>
</section>
</div>
</template>
<script setup>
import { ref } from 'vue';
const account = ref('');
const password = ref('');
const emit = defineEmits(['loginSuccess', 'loginFail']);
function login() {
if(account.value === '123' && password.value === '1234') {
emit('loginSuccess');
} else {
emit('loginFail');
}
}
</script>
<style lang="scss" scoped>
</style>
测试文件login.test.js
import {
describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import Login from '@/components/Login.vue';
describe('测试示例', () => {
it('登录', async () => {
const wrapper = mount(Login);
wrapper.find('.account').setValue('123'); // 填写账号
wrapper.find('.password').setValue('1234'); // 填写密码
wrapper.find('.login').trigger('click'); // 点击登录
expect(wrapper.emitted('loginSuccess')[0]).toEqual([]);
});
});
4.7.mock异步请求
在业务组件中,经常有异步请求的逻辑。假如单元测试中真实地调用接口,成本是比较大的,可以使用vitest提供的mock功能替代接口调用。
待测试的组件Request.vue
。组件的逻辑很简单,点击按钮,请求数据,并把数据显示在页面上。
<template>
<div>
<button class="btn" @click="clickMe">点我</button>
<p class="name">{{name}}</p>
</div>
</template>
<script setup>
import { getList } from '@/api/request.js'
import { ref } from 'vue';
const name = ref('');
async function clickMe() {
const res = await getList();
name.value = res.data.name;
}
</script>
<style lang="scss" scoped>
</style>
添加mock文件。如下图所示,在被mock文件A的目录下新建一个__mocks__
文件夹,文件夹新增一个文件B,B名称与A一致。
// 文件B的内容
export const getList = () => new Promise(resolve => {
resolve({
data: { name: '李四' }
});
})
测试文件request.test.js
import {
describe, expect, it, vi
} from 'vitest';
import { mount } from '@vue/test-utils';
import Request from '@/components/Request.vue';
import flushPromises from 'flush-promises';
vi.mock('@/api/request.js'); // mock接口
describe('请求测试示例', () => {
it('测试异步请求', async () => {
const wrapper = mount(Request);
await wrapper.find('.btn').trigger('click'); // 触发click事件
await flushPromises(); // 等待异步完成
expect(wrapper.find('.name').text()).toBe('李四');
});
});
vitest
提供vi.mock
方法mock文件,只需传入路径,vitest会自动寻找__mocks__
文件夹下存在同名文件。
要注意的是,异步请求后需要刷新所有处于pending状态或resolved状态的Promise,推荐使用flush-promises
解决。
4.8.处理定时器
假如代码中存在setTimeout
和setInterval
,且不想等待的时间太长,可以使用vi.useFakeTimers
模拟定时器,减少等待的时间。
下面是一个倒计时的例子。
待测试的组件CountDown.vue
<template>
<div>
<button class="btn" @click="start">开启倒计时</button>
<p class="seconds">{{seconds}}s</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
const seconds = ref(0); // 倒计时的秒数
const timer = ref(null);
// 开启倒计时
function start() {
if(seconds.value !== 0) return;
clearInterval(timer.value);
countDownCallback();
timer.value = setInterval(countDownCallback, 1000);
}
function countDownCallback() {
if (seconds.value === 0) {
seconds.value = 5;
} else if (seconds.value - 1 <= 0) {
seconds.value = 0;
clearInterval(timer.value);
} else {
seconds.value--;
}
}
</script>
<style lang="scss" scoped>
</style>
测试文件countdown.test.js
import {
describe, expect, it, vi
} from 'vitest';
import { mount } from '@vue/test-utils';
import CountDown from '@/components/CountDown.vue';
import flushPromises from 'flush-promises';
describe('倒计时测试示例', () => {
it('测试倒计时', async () => {
const wrapper = mount(CountDown);
vi.useFakeTimers(); // 启用模拟计时器
await wrapper.find('.btn').trigger('click'); // 开启倒计时
await flushPromises();
expect(wrapper.find('.seconds').text()).toBe('5s');
await vi.advanceTimersByTime(3000); // 倒计时快进3s
await flushPromises();
expect(wrapper.find('.seconds').text()).toBe('2s');
vi.runAllTimers(); // 调用每个被创建的计时器,直到计时器队列为空
await flushPromises();
expect(wrapper.find('.seconds').text()).toBe('0s');
vi.useRealTimers(); // 关闭模拟计时器
});
});
示例中,通过vi.useFakeTimers
模拟计时器,同时通过vi.advanceTimersByTime
快进倒计时,大大减少等待时间。最后通过vi.runAllTimers
执行所有倒计时。
使用时要注意两点:
- 每次使用后,需要调用
vi.useRealTimers()
关闭模拟计时器。 mount
方法一定在vi.useFakeTimers
前执行。
4.9.pinia
Vue Test Utils中使用pinia
pinna由state、getters、actions三部分组成,单元测试只需关注getters和actions。
getters
:直接修改state值,断言getters。actions
:直接调用action,断言修改的state值和返回值。
一个pinia示例
import { defineStore } from 'pinia'
export const useUserInfo = defineStore('userinfo', {
state() {
return {
name: '张三',
age: 14
}
},
getters: {
doubleAge: (state) => state.age * 2,
},
actions: {
changeUserInfo(name, age) {
this.name = name;
this.age = age;
},
},
})
测试文件userinfostore.test.js
pinna
提供setActivePinia
api方便我们直接激活pinna。
import {
describe, expect, it, beforeAll, afterEach
} from 'vitest';
import { setActivePinia, createPinia } from 'pinia'
import { useUserInfo } from '@/store/userinfo';
describe('pinia测试示例', () => {
let store = null;
beforeAll(() => {
setActivePinia(createPinia()); // 激活pinia
store = useUserInfo()
})
afterEach(() => {
store.$reset(); // 每次测试后,重置一下
})
it('测试changeUserInfo', () => {
store.changeUserInfo('李四', 15);
expect(store.name).toBe('李四');
expect(store.age).toBe(15);
});
it('测试doubleAge', () => {
// 修改age的值
store.$patch({
age: 16
});
expect(store.doubleAge).toBe(32);
});
});
4.10.组件中的pinia测试
在项目中,因为pinia
是一个vue插件,我们需要通过app.use
安装插件。
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
app.mount('#app');
Vue Test Utils
允许你使用global.plugins
安装插件。
下面的示例,使用了userinfo的state、getters和actions。
<template>
<div>
<p class="name">姓名: {{name}}</p>
<p class="age">年龄: {{age}}</p>
<p class="doubleAge">两倍年龄: {{doubleAge}}</p>
<button class="change" @click="change">改变信息</button>
</div>
</template>
<script setup>
import { useUserInfo } from '@/store/userinfo.js';
import { storeToRefs } from 'pinia';
const userinfo = useUserInfo();
const { name, age, doubleAge } = storeToRefs(userinfo);
function change() {
userinfo.changeUserInfo('李四', 15);
}
</script>
<style lang="scss" scoped>
</style>
测试文件userinfo.test.js
。我们直接使用global.plugins
挂载pinia就可以了。
import {
describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import UserInfo from '@/components/UserInfo.vue';
import { createPinia } from 'pinia';
import flushPromises from 'flush-promises';
const pinia = createPinia()
describe('测试示例', () => {
it('vue中的pinia测试', async () => {
const wrapper = mount(UserInfo, {
global: {
plugins: [pinia] // 安装pinia
}
});
expect(wrapper.find('.name').text()).toBe('姓名: 张三');
expect(wrapper.find('.age').text()).toBe('年龄: 14');
expect(wrapper.find('.doubleAge').text()).toBe('两倍年龄: 28');
wrapper.find('.change').trigger('click'); // 改变store
await flushPromises();
expect(wrapper.find('.name').text()).toBe('姓名: 李四');
expect(wrapper.find('.age').text()).toBe('年龄: 15');
expect(wrapper.find('.doubleAge').text()).toBe('两倍年龄: 30');
});
});
mock pinia
这时可能有小可爱会问,actions的代码上一节就测过了,岂不是重复了?其实我们也可以使用mock的方式分离业务组件和store的测试。
同样的,我们新建一个userinfo的mock文件__mocks__/userinfo.js
。
import { defineStore } from 'pinia';
import { vi } from 'vitest';
export const state = {
name: '张三',
age: 14
}
export const actions = {
changeUserInfo: vi.fn() // mock函数
}
export const useUserInfo = defineStore('userinfo', {
state() {
return state
},
getters: {
doubleAge: (state) => state.age * 2,
},
actions
})
测试文件我们修改成这样。
import {
describe, expect, it, vi
} from 'vitest';
import { mount } from '@vue/test-utils';
import UserInfo from '@/components/UserInfo.vue';
import { createPinia } from 'pinia';
import flushPromises from 'flush-promises';
import { actions } from '@/store/userinfo.js';
const pinia = createPinia()
vi.mock('@/store/userinfo.js'); // mock
describe('测试示例', () => {
it('vue中的pinia测试', async () => {
const wrapper = mount(UserInfo, {
global: {
plugins: [pinia] // 安装pinia
}
});
expect(wrapper.find('.name').text()).toBe('姓名: 张三');
expect(wrapper.find('.age').text()).toBe('年龄: 14');
expect(wrapper.find('.doubleAge').text()).toBe('两倍年龄: 28');
wrapper.find('.change').trigger('click'); // 改变store
await flushPromises();
expect(actions.changeUserInfo).toHaveBeenLastCalledWith('李四', 15); // 断言被调用的参数
});
});
我们采用mock函数vi.fn()
记录被调用的次数,从而实现测试的独立性。