前言
上一篇我们讲解了单测的基本理论与vue的单测入门,所谓千里之行,始于足下,我们已经完成了安装到入门,并完成了第一个单测用例,每一个出色的测试用例都是从第一个开始的。我们在学习任何技能的时候,一般都是先通过理论学习,然后再结合理论进行实践,本篇我们就来探讨一下在vue中进行单元测试的最佳实践以及规范
使用Chrome进行单测代码调试
在我们编写单元测试的过程中,避免不了出现一些代码的错误问题,此时我们想定位代码问题,只能通过命令行或者report,那么怎么模拟测试的运行环境、将测试的代码进行调试呢?node+chrome提供了这种能力,实现步骤如下:
1.在代码内需要调试的地方添加debugger
// increment.spec.js
// 导入测试工具集
import { mount } from "@vue/test-utils";
import Increment from "@/views/Increment";
describe("Increment", () => {
// 挂载组件,获取包裹器
const wrapper = mount(Increment);
const vm = wrapper.vm;
// 模拟用户点击
it("button click should increment the count", () => {
expect(vm.count).toBe(0);
const button = wrapper.find("button");
debugger;
button.trigger("click");
expect(vm.count).toBe(1);
});
});
2.在package.json的scripts添加执行debug脚本
"test:unit:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --no-cache --runInBand",
3.执行如下命令
npm run test:unit:debug
4.打开Chrome浏览器,点击调试图标,即可打开调试窗口
点击开始调试按钮,你将获得与客户端代码调试一样的体验
vue中的组件
无论是vue或者react、ng项目,都存在组件的概念,组件通常分为UI组件、业务组件,不同类型的组件,对于测试的侧重点不一样,一般UI组件更关注界面以及交互的通用性,业务组件可能更关注业务功能的复用,大型的应用经常发生的事情就是,随着时间的增加,UI逻辑与业务逻辑越来越混乱,关注点很零散,这样会导致低的覆盖率,而单元测试将迫使你将UI逻辑与业务逻辑分开,这将对单元测试十分有益,所以保持二者分离十分有必要。
好的单元测试必须遵守AIR原则:
-
A-Automatic(自动化原则) 单元测试应该是自动运行,自动校验,自动给出结果
-
I-Independent(独立原则) 单元测试应该是独立运行,互相之间无依赖,对外部资源无依赖,多次运行之间无依赖
-
R-Repeatable(可重复原则) 单元测试是可重复运行的,每次的结果都稳定可靠
UI组件
UI组件又称为基础组件,一般指的是提供基础的样式、逻辑、交互与通用能力的封装,并保持一定的可扩展性,通用性,前端同学为了提升开发效率,一般都会选择常见的UI库,类似ElementUI,AntDesign,或者公司内部的组件库,当然,随着项目的推进,通常也会沉淀出团队内的组件,它是前端组件的最小颗粒度,不受到业务的影响,所以称之为基础组件
对于 UI 组件来说,不推荐一味追求行级覆盖率,因为它会导致我们过分关注组件的内部实现细节,从而导致琐碎的测试。 取而代之的是,我们推荐把测试撰写为断言你的组件的公共接口,并在一个黑盒内部处理它。一个简单的测试用例将会断言一些输入 (用户的交互或 prop 的改变) 提供给某组件之后是否导致预期结果 (渲染结果或触发自定义事件)。
业务组件
业务组件一般指的对一套业务功能的封装,其中可能会用到多个UI组件,主要是为了单个或者多个项目中的类似场景的复用,业务组件就是为了减少业务代码开发过程中的重复工作,对业务逻辑进行封装,根据业务需求提供一定的扩展性、松耦合、扁平化的数据结构等。
局限性
单元测试不一定适合所有组件,每个项目,不适合的事情做起来反而会适得其反,所以写单元测试之前,先考虑清楚这个组件是不是应该写单元测试,比如代码里面充斥着颗粒度低,耦合度高的代码,你会发现你需要花费大量的时间去完成覆盖率,这会让我们花费大量时间去维护两套代码,所以我们要权衡利弊,考虑针对系统中比较稳定的、核心的组件写单元测试
如何测试组件
对于要测试的组件来说,一个很重要的问题就是,这个组件是做什么的,他的输入输出是什么? 一旦确定输入和输出,我们就有了测试方向,可以将组件视为黑盒或者一个函数,他们接受输入,形成输出,测试不同的输入对于输出的影响,这是核心点,下面列出测试范围:
UI组件
- 生命周期
- 输入参数props
- 渲染文本测试
- 事件交互测试
- 异步测试
业务组件
- 生命周期
- 输入参数props
- 渲染文本测试
- dom测试
- 涉及到逻辑的内联样式测试
- 事件交互测试
- mock数据
- 异步测试
- 流程模拟
- vuex测试
- 路由模拟测试
原则:语句覆盖率达到 60% ;核心模块的语句覆盖率和分支覆盖率至少都要达到 80%
上述测试范围的具体实现在Vue Test Utils官方文档已经写的非常详细了,基本都可以参考其接口进行实现,附上文档地址:
Vue Test Utils文档地址:vue-test-utils.vuejs.org/zh/
实现案例
UI组件
UI组件实例
MyButton.vue
<template>
<button :class="['my-button', `my-button--${type}`, { 'my-button--disabled': disabled }]" :disabled="disabled" @click="handleClick">
<i :class="icon" v-if="icon"></i>
<span v-if="$slots.default"><slot></slot></span>
</button>
</template>
<script>
export default {
name: "my-button",
props: {
type: {
type: String,
default: "default",
},
disabled: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: "",
},
},
methods: {
handleClick(evt) {
this.$emit("click", evt);
},
},
};
</script>
<style scoped lang="less">
.my-button {
border: 1px solid #dcdfe6;
color: #606266;
white-space: nowrap;
cursor: pointer;
background: #fff;
display: inline-block;
padding: 12px 20px;
font-size: 14px;
border-radius: 4px;
}
.my-button-primary {
color: #fff;
background-color: #409eff;
border-color: #409eff;
}
.my-button--success {
color: #fff;
background-color: #67c23a;
border-color: #67c23a;
}
.my-button--disabled {
cursor: not-allowed;
}
</style>
MyButton.spec.js
import { mount } from "@vue/test-utils";
import MyButton from "@/components/MyButton";
describe("Button", () => {
it("render button", () => {
const wrapper = mount(MyButton, {
slots: {
default: "ok",
},
});
const button = wrapper.find("button");
expect(button.exists()).toBe(true);
expect(button.text()).toBe("ok");
expect(button.classes("my-button--default")).toBe(true);
});
it("render a primary button", () => {
const wrapper = mount(MyButton, {
slots: {
default: "ok",
},
propsData: {
type: "primary",
},
});
const button = wrapper.find("button");
expect(button.classes("my-button--primary")).toBe(true);
});
it("render a disabled button", () => {
const wrapper = mount(MyButton, {
slots: {
default: "ok",
},
propsData: {
disabled: true,
},
});
const button = wrapper.find("button");
expect(button.classes("my-button--disabled")).toBe(true);
expect(button.attributes("disabled")).toBe("disabled");
});
it("icon", () => {
const wrapper = mount(MyButton, {
slots: {
default: "ok",
},
propsData: {
icon: "correct",
},
});
const correct = wrapper.find("button .correct");
expect(correct.exists()).toBe(true);
});
it("click", async () => {
const wrapper = mount(MyButton, {
slots: {
default: "ok",
},
});
const button = wrapper.find("button");
await button.trigger("click");
expect(wrapper.emitted().click).toBeTruthy();
});
});
UI组件运行结果
业务组件
组件分析
这是一个选择版本的组件,如图所示:
- 默认:
- 鼠标移入:
- 点击编辑:
- 点击删除后 向外部发出事件
测试思路
- 断言props对页面渲染的影响,覆盖props相关条件分支语句,断言vm._data改变
- 模拟鼠标移入场景,触发事件 mouseenter,断言dom渲染,vm._data改变
- 模拟鼠标移出场景,触发事件mouseleave,断言dom渲染,vm._data改变
- 模拟鼠标移入场景,触发事件 mouseenter
- 模拟点击编辑按钮,触发事件click,断言相关vm._data改变
- 触发自定义事件“visible-change”,断言相关vm._data改变
- 触发下拉Item的click事件,断言自定义事件“updatePackageVersion”触发
- 模拟搜索输入框输入,模拟触发input的 “change”事件,断言vm._data改变
- 模拟点击删除按钮,断言自定义事件“deletePackage ”被触发
实例
Dependency.vue
<template>
<div class="dependency" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
<p class="dependency-selected">
<span class="dependency-name">{{ pkg.name }}</span>
<span class="dependency-version" v-if="!clientId || version !== '' || appType === 2">
<span class="dependency-latest">{{ version }}</span>
</span>
</p>
<div v-if="isInOperation && ((pkg.key !== 'platform' && appType === 2) || appType === 0)" class="dependency-operations">
<span class="dependency-operation" @click="handleDeletePackage"><i class="mp-icon-trash-2"></i></span>
<el-dropdown trigger="click" @command="handleCommand" @visible-change="handleVisibleChange">
<span class="dependency-operation" ref="setting"><i class="mp-icon-edit-2"></i></span>
<el-dropdown-menu slot="dropdown" class="versions-menu">
<li class="app-version-filter">
<el-input
placeholder="版本号"
v-model="versionKeywords"
icon="search"
debounce="500"
:on-icon-click="handleVersionsKeywordChange"
@change="handleVersionsKeywordChange"
>
</el-input>
</li>
<li class="versions-menu-options">
<ul class="versions-sub-menu">
<el-dropdown-item
:class="[{ 'dep-selected': option.ver === version }]"
:command="option"
v-for="(option, index) in stableVersions"
:key="index"
>{{ option.ver }}</el-dropdown-item
>
</ul>
</li>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</template>
<script>
export default {
name: 'application-dependency',
props: {
appType: Number,
clientId: String,
pkg: Object
},
data() {
return {
versionKeywords: '',
defaultVersions: [],
stableVersions: [],
version: '',
latest: '',
stable: '',
beta: '',
isOperationShow: false,
isInOperation: false,
currentVersion: '',
type: 0,
types: [
{
label: '最新版本',
value: 0
},
{
label: '内测版本',
value: 2
}
]
}
},
computed: {
versions() {
const pkg = this.pkg
let versions = []
if (pkg.versions) {
versions = pkg.versions
} else {
if (pkg.version) {
versions = [
{
state: 5,
ver: pkg.version
}
]
}
}
return versions
}
},
watch: {
versions() {
this.stableVersions = this.getStableVersions()
}
},
created() {
const stableVersions = this.getStableVersions()
const latest = stableVersions[0]
const pkg = this.pkg
this.stableVersions = stableVersions
this.defaultVersions = stableVersions
if (latest) {
if (pkg.key === 'platform') {
this.version = pkg.currentVersion || latest.ver
} else {
this.version = latest.ver
}
}
},
methods: {
getStableVersions() {
if (this.versions.length === 0) {
return this.versions
}
return this.versions.filter(version => version.state === 5)
},
handleVersionsKeywordChange() {
this.stableVersions = this.defaultVersions.filter(version => version.ver.indexOf(this.versionKeywords) > -1)
},
handleVisibleChange(visible) {
if (visible) {
this.isInOperation = true
this.isOperationShow = true
} else {
this.isInOperation = false
this.isOperationShow = false
this.stableVersions = []
this.versionKeywords = ''
}
},
handleCommand(cmd) {
this.version = cmd.ver
this.$emit('updatePackageVersion', {
key: this.pkg.key,
version: this.version,
isLatest: false
})
},
handleMouseEnter() {
this.isInOperation = true
},
handleMouseLeave() {
if (!this.isOperationShow) {
this.isInOperation = false
}
},
handleDeletePackage() {
this.$emit('deletePackage', this.pkg.key)
}
}
}
</script>
Dependency.spec.js
// 导入测试工具集
import { mount, createLocalVue } from '@vue/test-utils'
import Dependency from '@/components/Dependency'
import ElementUI from 'element-ui'
const localVue = createLocalVue()
localVue.use(ElementUI)
describe('Dependency', () => {
it('测试渲染组件文本', () => {
const pkg = {
key: 'platform',
appId: 60531,
name: '你太帅了',
version: 'latest',
currentVersion: null,
versions: [
{
ver: '3.0.5.24',
state: 5
},
{
ver: '3.0.5.23',
state: 5
},
{
ver: '3.0.5.22',
state: 5
}
]
}
const wrapper = mount(Dependency, {
propsData: {
pkg
},
localVue
})
// expect(wrapper.element).toMatchSnapshot()
expect(wrapper.find('.dependency-name').text()).toBe(pkg.name)
expect(wrapper.find('.dependency-latest').text()).toBe('3.0.5.24')
expect(wrapper.vm.versions.length).toBe(3)
expect(wrapper.vm.stableVersions.length).toBe(3)
})
it('pkg.versions不存在,版本列表为当前pkg.version,长度为1', () => {
const pkg = {
key: 'platform',
appId: 60531,
name: '你太帅了',
version: 'latest',
currentVersion: null
}
const wrapper = mount(Dependency, {
propsData: {
pkg
},
localVue
})
expect(wrapper.vm.versions.length).toBe(1)
})
it('pkg.key不等于platform', () => {
const pkg = {
key: 'product',
appId: 60531,
name: '产品',
version: 'latest',
currentVersion: null,
versions: [
{
ver: '3.0.5.24',
state: 5
},
{
ver: '3.0.5.23',
state: 5
},
{
ver: '3.0.5.22',
state: 5
}
]
}
const wrapper = mount(Dependency, {
propsData: {
pkg
},
localVue
})
expect(wrapper.vm.version).toBe('3.0.5.24')
})
it('clientId存在、version不存在、appType不等于2,不会渲染版本文字', () => {
const pkg = {
key: 'platform',
appId: 60531,
name: '你太帅了',
version: 'latest',
currentVersion: null,
versions: []
}
const wrapper = mount(Dependency, {
propsData: {
pkg,
clientId: '1111111',
appType: 1
},
localVue
})
expect(wrapper.find('.dependency-version').exists()).toBe(false)
})
it('mouseenter后:渲染dropDown、点击修改按钮、点击选择版本、搜索、点击删除按钮', async () => {
const wrapper = getMouseEnterWrapper()
const vm = wrapper.vm
const dependencyDiv = wrapper.find('.dependency')
// 触发mouseenter
await dependencyDiv.trigger('mouseenter')
expect(vm.isInOperation).toBe(true)
expect(wrapper.find('.dependency-operations').exists()).toBe(true)
await dependencyDiv.trigger('mouseleave')
expect(vm.isInOperation).toBe(false)
// 触发mouseenter
await dependencyDiv.trigger('mouseenter')
const elDropDown = wrapper.findComponent({ name: 'el-dropdown' })
// 点击修改按钮
elDropDown.vm.$emit('visible-change', true)
expect(vm.isOperationShow).toBe(true)
const menuItem = wrapper.find('.versions-sub-menu li')
await menuItem.trigger('click')
// 触发emit,更新外部数据
expect(wrapper.emitted().updatePackageVersion).toBeTruthy()
elDropDown.vm.$emit('visible-change', false)
expect(vm.isInOperation).toBe(false)
expect(vm.isOperationShow).toBe(false)
expect(vm.stableVersions.length).toBe(0)
expect(vm.versionKeywords).toBe('')
const elInput = wrapper.findComponent({ name: 'el-input' })
vm.versionKeywords = '3.0.5.23'
elInput.vm.$emit('change')
expect(vm.stableVersions.length).toBe(1)
// 点击删除
const dependencyOperation = wrapper.find('.dependency-operation')
await dependencyOperation.trigger('click')
// 触发emit,更新外部数据
expect(wrapper.emitted().deletePackage).toBeTruthy()
})
})
function getMouseEnterWrapper() {
const pkg = {
key: 'platform',
appId: 60531,
name: '你太帅了',
version: 'latest',
currentVersion: null,
versions: [
{
ver: '3.0.5.24',
state: 5
},
{
ver: '3.0.5.23',
state: 5
},
{
ver: '3.0.5.22',
state: 5
}
]
}
const wrapper = mount(Dependency, {
propsData: {
pkg,
appType: 0
},
localVue
})
return wrapper
}
业务组件运行结果
测试接口请求的案例
List.vue
<template>
<div class="list-wrapper">
<div class="list-item" v-for="item in listData">
<span class="list-name">{{ item.name }}</span>
<span class="list-img">{{ item.img }}</span>
<span class="list-price">{{ item.price }}</span>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'list',
data() {
return {
listData: []
}
},
created() {
this.getList()
},
methods: {
async getList() {
const response = await axios.get('mock/service')
this.listData = response.data
}
}
}
</script>
<style></style>
List.spec.js
import { mount } from '@vue/test-utils'
import List from '@/components/List'
import flushPromises from 'flush-promises'
// 模拟接口返回数据
const mockData = {
'mock/service': [{ name: 'songm', price: '10000', img: 'http://aa.png' }]
}
jest.mock('axios', () => ({
get: jest.fn(url => Promise.resolve({ data: mockData[url] }))
}))
describe('List', () => {
it('模拟请求:', async () => {
const wrapper = mount(List)
await flushPromises()
expect(wrapper.vm.listData.length).toBe(1)
expect(wrapper.findAll('.list-item').length).toBe(1)
expect(
wrapper
.findAll('.list-name')
.at(0)
.text()
).toBe('songm')
})
afterEach(() => {
jest.clearAllMocks()
})
})
这里使用了 flush-promises 来等待Promise的状态刷新,使用jest mock接口,根据接口参数进行数据返回模拟数据
注意:axios接口从外部文件引入同样适用,上面的测试用例无需任何更改
api/test.js
import axios from 'axios'
export const getList = () => axios.get('mock/service')
List.vue
<template>
<div class="list-wrapper">
<div class="list-item" v-for="item in listData">
<span class="list-name">{{ item.name }}</span>
<span class="list-img">{{ item.img }}</span>
<span class="list-price">{{ item.price }}</span>
</div>
</div>
</template>
<script>
import { getList } from '@/api/test'
export default {
name: 'list',
data() {
return {
listData: []
}
},
created() {
this.getList()
},
methods: {
async getList() {
const response = await getList()
this.listData = response.data
}
}
}
</script>
<style></style>
测试Vuex的案例
TestVuex.vue
<template>
<button @click="handleIncrement">{{ count }}</button>
</template>
<script>
import { mapActions, mapState } from 'vuex'
export default {
name: 'test-vuex',
computed: {
...mapState(['count'])
},
methods: {
...mapActions(['changeCount']),
handleIncrement() {
const count = this.count + 1
this.changeCount(count)
}
}
}
</script>
<style></style>
TestVue.spec.js
import { mount, createLocalVue } from '@vue/test-utils'
import TestAction from '@/components/TestAction'
import Vuex from 'vuex'
import store from '@/store'
const localVue = createLocalVue()
localVue.use(Vuex)
describe('TestAction', () => {
it('点击button,触发changeCount调用', async () => {
const wrapper = mount(TestAction, { store, localVue })
await wrapper.find('button').trigger('click')
expect(wrapper.vm.count).toBe(1)
})
})
测试vuex的时候,我们并不关心action做了什么,或者store是什么,我们应该关心action在什么时候触发,以及预期的值是什么,我们可以直接测试我们的store模块,也可以模拟store模块,当然,如果我们并不关心界面情况,可以不通过 Vue Test Utils 和 Vuex 测试它们,可以把它们当成js模块来测试
测试Vue-router的案例
import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
import router from '@/router'
import App from '@/App.vue'
const localVue = createLocalVue()
localVue.use(VueRouter)
describe('App', () => {
it('测试router', async () => {
const wrapper = shallowMount(App, {
localVue,
router
})
expect(wrapper.vm.$route.path).toBe('/')
await router.push('/about')
expect(wrapper.vm.$route.path).toBe('/about')
})
})
在router或者vuex的测试中,最好使用localVue进行安装,避免不必要的麻烦,当我们安装vuie-router后,router-link 和 router-view 组件就被注册了。这意味着我们无需再导入可以在应用的任意地方使用它们。
总结
综合来看,单元测试的好处很多,我们在前面也讲过了,可劲酒虽好,也不要贪杯哦。我们在日常工作中,还是要以功能为主,单测为辅,不能颠倒了主次,导致本末倒置,最后花费大量的时间去维护两套逻辑,所以尽量覆盖项目中的核心组件与核心场景。 另外阻碍我们写单元测试的通常不是能力问题,而是时间问题,现今互联网节奏巨快,通常没有足够的时间去写好单测,那么如何能交付一份满意的单测答卷呢,技巧很重要,把握尺度,熟练技巧,摸清套路,我想你一定可以得心应手。
参考
《Testing Vue.js Applications》 ---- Edd Yerburgh