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提供triggerAPI实现鼠标、键盘的时间触发。要注意的是,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提供emittedAPI获取已经触发的事件。
待测试的组件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提供setActivePiniaapi方便我们直接激活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()记录被调用的次数,从而实现测试的独立性。