持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情
之前两篇是一些环境搭建和语法基础
下面开始结合实际组件进行组件的单元测试编写
开始
新建组件
比如我们有一个JestUnitTest
的组件
<template>
<!-- 基础测试 -->
<h1>{{ msg }}</h1>
<button @click="setCount">{{ count }}</button>
<!-- 测试异步请求 -->
<button class="loadUser" @click="loadUser">load</button>
<p v-if="user.loading" class="loading">Loading</p>
<div v-else class="userName">{{ user.data && user.data.name }}</div>
<!-- 测试store -->
<button class="storeTest" @click="storeTest">load</button>
<!-- 测试router -->
<button class="routerTest" @click="routerTest">router</button>
<!-- 测试组件库message -->
<button class="comMessage" @click="comMessage">message</button>
</template>
<script lang="ts">
import { defineComponent, reactive, ref } from 'vue';
import { useUserStore } from '@/store/index';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import axios from 'axios';
export default defineComponent({
name: 'JestUnitTest',
props: {
msg: String,
},
emits: ['emitTest'],
setup(props, context) {
const todo = ref('');
const userStore = useUserStore();
userStore.setName('23123');
const user = reactive({
data: null as any,
loading: false,
error: false,
});
const count = ref(1);
const setCount = () => {
context.emit('emitTest', count.value);
count.value++;
};
const storeTest = () => {
userStore.increment();
};
const loadUser = () => {
user.loading = true;
axios
.get('http://localhost:7777/userMsg')
.then((resp) => {
user.data = resp.data;
})
.catch(() => {
user.error = true;
})
.finally(() => {
user.loading = false;
});
};
const router = useRouter();
const routerTest = () => {
router.push('/test');
console.log(123);
};
const comMessage = () => {
message.success('This is a normal message');
};
return {
count,
setCount,
todo,
user,
storeTest,
loadUser,
routerTest,
comMessage,
};
},
});
</script>
所有测试用例都在下文,可以随时翻上来看具体方法和dom
开始测试
首先我们测试组件正常接受参数msg,并且展示正确
describe('JestUnitTest.vue', () => {
it('测试参数接受正确', () => {
const wrapper = shallowMount(JestUnitTest, {
props: { msg: '测试参数' },
});
console.log(wrapper.html());
expect(wrapper.get('h1').text()).toBe('测试参数');
});
});
测试用例通过
此时打印也可以看出 search组件内部的内容并没有被渲染,也符合我们之前说的shallowMount 只会渲染组件本身
测试点击事件成功触发并且修改参数正确
it('测试点击事件', async () => {
const wrapper = shallowMount(JestUnitTest, {
props: { msg: '测试参数' },
});
await wrapper.get('button').trigger('click');
expect(wrapper.get('button').text()).toBe('2');
});
测试用例通过
触发事件使用 tigger
因为vue修改dom是异步的所以需要await 触发事件
提取公共逻辑
发现两个测试用例中有重复逻辑
我们想将逻辑提取出来
jest的几个生命周期钩子
- beforeAll(()=>{}) 所有用例执行前,执行一次
- afterAll() 所有用例执行后,执行一次
- beforeEach() 所有用例执行前,执行多次
- afterEach() 所有用例执行后,执行多次
下面将wrapper的逻辑提取到beforeAll里面
let wrapper: VueWrapper<any>;
describe('JestUnitTest.vue', () => {
beforeAll(() => {
wrapper = shallowMount(JestUnitTest, {
props: { msg: '测试参数' },
});
});
it('测试参数接受正确', () => {
expect(wrapper.get('h1').text()).toBe('测试参数');
});
it('测试点击事件', async () => {
await wrapper.get('button').trigger('click');
expect(wrapper.get('button').text()).toBe('2');
});
});
测试组件中emit触发事件
it('测试组件触发事件', async () => {
await wrapper.get('button').trigger('click');
console.log(wrapper.emitted());
const events = wrapper.emitted('emitTest');
expect(events?.[0]).toEqual([1]);
});
emitTest事件被触发,value为二维数组,因为上面有测试点击的用例,加上本次用例里面的触发点击,所以一共触发了两次,所以数组长度为2
测试组件中的异步请求和展示
请求我们这里可以用jsonplaceholder
或者使用json-server
启动一个json服务来模拟返回。
这里我们用json-server
启动
json-server tests/test/test.json --port 7777
然后mock一下请求第三方库,这里用的是axios
import axios from 'axios';
jest.mock('axios');
const mockAxios = axios as jest.Mocked<typeof axios>;// 使axios保留原来的类型,同时增加mock的类型
describe('JestUnitTest.vue', () => {
...
it('测试组件中的异步请求', async () => {
mockAxios.get.mockResolvedValueOnce({ data: { name: '被mock的返回' } });
await wrapper.get('.loadUser').trigger('click');
expect(mockAxios.get).toHaveBeenCalled();//测试get 方法是否被执行
expect(wrapper.find('.loading').exists()).toBeFalsy();// 测试loading是否还存在
});
afterEach(() => {
mockAxios.get.mockReset();
});
}
afterEach 里面可以调用mockReset方法重置下mock,避免副作用
这时候有个报错,发现
expect(wrapper.find('.loading').exists()).toBeFalsy()
没有通过
按理说,当数据返回后,loading应该消失,现在没有通过用例,说明用例的执行并没有等到结果回来。
这时候需要借助第三方库flush-promises
改造一下
import flushPromises from 'flush-promises';
it('测试组件中的异步请求', async () => {
mockAxios.get.mockResolvedValueOnce({ data: { name: '被mock的返回' } });
await wrapper.get('.loadUser').trigger('click');
expect(mockAxios.get).toHaveBeenCalled();
await flushPromises();
expect(wrapper.find('.loading').exists()).toBeFalsy();
});
现在测试用例符合预期了
测试pinia/vuex
下面继续来测试下我们项目中用到的状态库
这里用到的是pinia
,vuex大致处理差不多,有些细节不一样,后续增加vuex的说明
我们的userStore文件如下
import { defineStore } from 'pinia';
import { UserStore } from './index.type';
export const useUserStore = defineStore('myStore', {
state: (): UserStore => {
return {
userMsg: null,
isLogin: true,
name: 'test',
n: 0,
};
},
actions: {
setUserMsg(user: UserStore['userMsg']) {
this.userMsg = user;
},
setName(name: string) {
this.name = name;
},
increment(amount = 1) {
this.n += amount;
},
},
getters: {},
});
然后新建一个测试文件,store.spec.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
// https://github.com/vuejs/pinia/blob/v2/packages/testing/src/testing.spec.ts
import { mount } from '@vue/test-utils';
import { createTestingPinia, TestingOptions } from '@pinia/testing';
import { defineStore } from 'pinia';
import JestUnitTest from '@/components/JestUnitTest.vue';
import { useUserStore } from '@/store/modules/user/index';
describe('JestUnitTest.vue', () => {
function factory(options?: TestingOptions) {
const wrapper = mount(JestUnitTest, {
global: {
plugins: [createTestingPinia(options)],
},
props: { msg: '测试参数' },
});
const myStore = useUserStore();
const counter = useCounter();
return { wrapper, myStore };
}
it('测试store的actions方法', async () => {
const { myStore } = factory({
stubActions: false,
});
myStore.setName('hhaha');
expect(myStore.setName).toHaveBeenCalled();
expect(myStore.name).toBe('hhaha');
myStore.increment();
expect(myStore.n).toBe(1);
expect(myStore.increment).toHaveBeenCalledTimes(1);
expect(myStore.increment).toHaveBeenLastCalledWith();
});
it('测试组件中触发store', async () => {
const { myStore, wrapper } = factory({ stubActions: false });
await wrapper.get('.storeTest').trigger('click');
expect(myStore.increment).toHaveBeenCalled();
expect(myStore.increment).toHaveBeenCalledTimes(1);
expect(myStore.n).toBe(1);
});
});
可以看到我们这里的pinia虽然是第三方库,但是我们并没有像axios一样去mock的他的实现,因为这个实际调用不会产生其他副作用。
如果我们想让pinia中的acion修改state生效我们全局注入的时候要传入参数stubActions: false
pinia我们使用的plugins 的全局注入进行测试
而vuex,我们可以使用provide的方式,其他用例基本一样
wrapper = mount(UserProfile, {
props: { msg: '测试参数' },
global: {
provide: {
store
}
}
})
vue-router和第三方组件库的处理
为什么将vue-router
和第三方组件库比如ant-design-vue
放一起,是因为这里都是要mock他们的实现,而不像pinia
一样,直接实际调用
vuex测试
先mock一下,mock函数的第二个参数就是里面方法的具体实现
//mock vue-router同时重写push方法
const mockedRoutes: string[] = [];
jest.mock('vue-router', () => ({
useRouter: () => ({
push: (url: string) => mockedRoutes.push(url),
})
})
...
it('测试vue-router', async () => {
const { myStore, wrapper } = factory({ stubActions: false });
await wrapper.get('.routerTest').trigger('click');
expect(mockedRoutes).toEqual(['/test']);
});
第三方库,比如ant-design-vue 有一个message
方法,我们测试一下是否成功调用
// 引入message
import { message } from 'ant-design-vue';
//mock, 重写message方法
jest.mock('ant-design-vue', () => ({
message: {
success: jest.fn(),
},
}));
...
it('测试组件库mesage', async () => {
const { myStore, wrapper } = factory({ stubActions: false });
await wrapper.get('.comMessage').trigger('click');
expect(message.success).toHaveBeenCalled();
});
现在用例全部通过了
TDD
以上我们的开发方式都是先编写代码,然后对代码进行后续补充单元测试用例。
除了这种方式之外还有一种其他的开发方式-
TDD开发模式
Test Driven Development - 测试驱动开发
开发过程:
- 先根据需求写测试用例过
- 一开始测试用例全部是不通过状态
- 开始写具体实现
- 测试用例全部通过
大体是一个循环过程: 不可运行--->可运行--->重构--->不可运行--->可运行 ....
好处:
- 先编写代码再补充用例对开发人员的心智压力较大
- 可以将编程过程任务化,对进度进行精确把控
结语
通过三篇文章大致了解了vue3的单元测试
包括基础语法,环境搭建,以及如何结合具体组件进行测试
当然实际项目中可能会更加复杂,后续可能会继续更新一些复杂的组件测试用例。