单元测试,顾名思义就是对软件中的最小可测试单元进行检查和验证。前段时间学习了单元测试后,采用vue-test-utils+jest写了一个简单的注册页,感触颇深,总结如下:
一、写单元测试的不便之处
大部分开发人员应该都是不乐意写单元测试的,一是因为,业务繁多的时候,能按时完成工作任务都有点吃力,没有多余的时间和精力再来写单元测试;二是因为,写单元测试真的麻烦,相当于记录下平时自己自测的所有步骤,业务一有变动,单元测试就得调整,不太适合一直需要变动和迭代的需求。
二、写单元测试的好处
任何事物都是有两面性的,尽管单元测试有诸多不便之处,但是它的好处也是显而易见的。
- 提高代码质量,减少bug,写单元测试的时候相当于在理一次业务需求,过程中可以对业务代码进行规整和优化。
- 快速定位问题所在,写完代码运行起来自测的时候,有时候遇到问题得一步步debugger,需要一定的时间,单元测试可以精准定位到bug所在。
- 提高合作开发效率,多人开发项目时,如果每个开发人员都根据自己的实际需求对公用组件进行了一定的扩展,后期是很不便于维护的,很难看懂组件的逻辑,扩展起来也容易出错。但是,如果写了单元测试的话,单元测试就相当于是一份很好的业务逻辑文档,有利于后续维护。 综上所诉,可根据自己的实际项目需求,对项目内变动较少的公用组件写单元测试,业务变动频繁的页面可综合考虑是否需要编写单元测试。
三、vue-test-utils + jest 实例
Vue Test Utils 是 Vue.js 官方的单元测试实用工具库。本文采用的测试运行器是Jest。
1、项目搭建
vue create <projectName>
自定义配置如下:
项目搭建完成后目录如下:
我们会发现这里比平时搭建的项目多了一个tests文件夹,下属文件夹unit里面的'**.spec.js'文件便是单元测试代码。
先运行起来看看
npm run test:unit
运行结果如下:
2、注册组件实践
注册页写的比较简单,代码如下:
<template>
<div class="form">
<div class="form__item">
<p class="label">请输入手机号码</p>
<input type="text" name="phone" class="input phone" v-model="phone" />
<p v-if="error1" class="error1">请输入正确的手机号码</p>
</div>
<div class="form__item">
<p class="label">请输入验证码</p>
<input
type="text"
name="capture"
class="input capture"
v-model="capture"
/>
<button class="btn1" @click="getCapture">获取验证码</button>
<p v-if="error2" class="error2">请输入正确的验证码</p>
</div>
<div class="form__item">
<button class="btn2" @click="register">登录</button>
</div>
</div>
</template>
<script>
import axios from "axios";
import Mock from "mockjs";
// mock获取验证码和登录接口
Mock.mock("/get-capture", {});
Mock.mock("/login", {});
export default {
data() {
return {
phone: "",
capture: "",
error1: false,
error2: false
};
},
methods: {
// 获取验证码
getCapture() {
// 没有输入手机号,则报错
if (!this.phone) {
this.error1 = true;
return;
}
this.error1 = false;
axios.get("/get-capture", {
phone: this.phone
});
},
// 登录
register() {
// 没有输入手机号,则报错
if (!this.phone) {
this.error1 = true;
return;
}
// 没有输入验证码,则报错
if (!this.capture) {
this.error2 = true;
return;
}
this.error1 = false;
this.error2 = false;
axios.get("/login", {
phone: this.phone,
capture: this.capture
});
}
}
};
</script>
<style scoped>
* {
margin: 0;
padding: 0;
}
.form__item {
padding: 10px;
}
.label {
padding-bottom: 10px;
margin: 0px;
}
.input {
padding: 4px;
margin-right: 10px;
}
.error1,
.error2 {
font-size: 12px;
color: red;
}
</style>
平时手动测试时,测试步骤一般如下:
- 没有输入手机号时,点击获取验证码按钮,页面提示"请输入正确的手机号码"
- 输入手机号时,点击获取验证码按钮,页面发送获取验证码请求
- 没有输入验证码时,点击登录按钮,页面提示"请输入正确的验证码"
- 输入验证码时,点击登录按钮,页面发送登录请求
- 没有输入手机号以及验证码时,点击登录按钮,页面提示"请输入正确的手机号码"
根据这个思路,我们一步步来写测试代码
挂载组件
Vue Test Utils 通过将它们隔离挂载,然后模拟必要的输入 (prop、注入和用户事件) 和对输出 (渲染结果、触发的自定义事件) 的断言来测试 Vue 组件。
首先我们需要挂载组件,得到包裹器,包裹器会暴露很多封装、遍历和查询内部的Vue组件实例的便捷的方法。
// 导入挂载组件的方法
import { mount } from "@vue/test-utils";
// 导入要测试的组件
import Register from "../../src/components/register";
describe("Register.vue", () => {
let wrapper;
beforeEach(() => {
// 通过mount方法挂载组件
wrapper = mount(Register);
});
afterEach(() => {
// 销毁该实例
wrapper.destroy();
});
//...测试用例
})
mount(component,options):创建一个包含被挂载和渲染的 Vue 组件的 Wrapper。如果我们不想同时挂载子组件的话,可以使用shallowMount(component,options),它和mount一样,创建一个包含被挂载和渲染的Vue组件的Wrapper,不同的是被存根的子组件。component是要挂载的组件,options是要传入的参数,可不传。
describe(name, fn):表示一组测试,如果没有describe,那整个测试文件就是一个describe。name是这组测试的名字,fn是这组测试要执行的函数。
beforeEach()、afterEach():Jest 为我们提供了四个测试用例的钩子beforeAll()(所有的测试用例之前执行一次)、afterAll()(所有的测试用例之后执行一次)、beforeEach()(每个测试用例之前执行一次)、afterEach()(每个测试用例之后执行一次)。
写测试用例
页面中需要通过axios发送请求,但是
因为Jest + Vue Test Utils这套环境中是没有 axios的,所以他不认 axios, 但是组件代码里面确实调用了axios, 那么我们就需要模拟一个 axios 出来。
我们不需要实际调用axios.get方法,因此调用jest.mock()方法将它mock掉,只需要判断该方法是否触发即可。
import axios from "axios";
jest.mock("axios", () => ({
get: jest.fn(() => Promise.resolve({}))
}));
jest.mock():用来mock整个模块中的方法。
jest.fn():该方法是创建Mock函数最简单的方式,可以自定义函数内部的实现及返回值,若是没有定义,则返回undefined。
接下来根据我们之前列出来的测试步骤,一一转换为相应的测试实例。
测试用例一:
it("btn1按钮被点击时,应该触发 getCapture", () => {
const mockFn = jest.fn();
// mock获取验证码函数
wrapper.setMethods({ getCapture: mockFn });
// 通过包裹器找到获取验证码按钮并触发点击
wrapper.find(".btn1").trigger("click");
// 判断获取验证码函数是否被调用
expect(mockFn).toBeCalled();
});
it(name,fn):该方法是定义一个测试用例,一个测试块里可以有多个测试用例,依次执行。name为要执行的测试用例的名称。
wrapper.setMethods(fn):通过该方法用mock函数代替正式的方法,然后就可以断言点击按钮后对应的方法有没有被触发,触发次数以及传入的参数等等。
wrapper.find(selector): 接收一个选择器作为参数,返回包裹器下匹配的第一个DOM节点或者vue组件。
wrapper.trigger(eventType,options):触发DOM事件,触发的事件是同步的,返回的是一个promise。eventType即触发的事件类型;options为传入的参数。
expect(value):断言,它接受一个参数,就是运行测试内容的结果,返回一个对象,这个对象来调用匹配器,匹配器的参数就是我们预期的结果。
toBeCalled():用来判断模拟的函数是否被调用。
我们把上面这个测试用例运行起来看看
图中显示总共运行了一个测试用例,并且通过了(图中报错原因是因为setMethods这个方法要被废弃了,需要使用其它的替代)。我们把触发点击那一行代码注释掉,再来运行看看。
由于我们没有触发点击,所以获取验证码函数并没有被调用,断言希望得到的次数是大于等于一,但是实际上是0,所以该测试用例不通过。
测试用例二:
it("getCapture被触发时,没有输入手机号,应显示错误", async () => {
// 找到获取验证码按钮,并触发点击
await wrapper.find(".btn1").trigger("click");
// 判断页面是否显示错误信息
expect(wrapper.find(".error1").isVisible()).toBe(true);
});
Vue 会异步的将未生效的 DOM 批量更新,避免因数据反复变化而导致不必要的渲染。所以页面中为了断言错误信息的变化,我们需要等待Vue完成更新,我们可以使用await Vue.nextTick(),或者await变更状态的方法。
isVisible():判断DOM元素是否可见(该DOM元素存在的前提下)。
toBe(value):判断值是否相等,不能用来判断对象,判断对象可以采用toEqual(value)。
测试用例三:
it("getCapture被触发时,输入手机号,不显示错误", async () => {
// 找到手机号码输入框,设置值
wrapper.find(".phone").setValue("15609877656");
// 找到获取验证码按钮,进行点击
await wrapper.find(".btn1").trigger("click");
// 页面应不显示错误信息
expect(wrapper.find(".error1").exists()).toBe(false);
});
setValue(value):给一个输入框或者下拉框设置value值,同时会改变该输入框v-model的值。
exists():判断DOM元素或组件是否存在。
测试用例四:
it("调起getCapture后,应该发起请求", async () => {
// 找到电话号码输入框,并设置值
wrapper.find(".phone").setValue("15609877656");
// 调用获取验证码函数
await wrapper.vm.getCapture();
// axios.get是否被调用(我们在最开始的时候mock了axios的get方法)
expect(axios.get).toBeCalled();
// 断言axios.get的调用参数
expect(axios.get).toBeCalledWith("/get-capture", { phone: "15609877656" });
});
toBeCalledWith(arg1, arg2, ...):检查调用函数传入的值是否匹配
接下来还有点击登录按钮的测试用例,如下:
it("btn2按钮被点击时,应该触发 register", () => {
const mockFn = jest.fn();
wrapper.setMethods({ register: mockFn });
wrapper.find(".btn2").trigger("click");
expect(mockFn).toBeCalled();
});
it("register被触发时,没有输入手机号,应显示手机号错误", async () => {
await wrapper.find(".btn2").trigger("click");
expect(wrapper.find(".error1").isVisible()).toBe(true);
});
it("register被触发时,输入手机号,没有输入验证码,应显示验证码错误", async () => {
wrapper.find(".phone").setValue("15609877656");
await wrapper.find(".btn2").trigger("click");
expect(wrapper.find(".error1").exists()).toBe(false);
expect(wrapper.find(".error2").isVisible()).toBe(true);
});
it("register被触发时,输入手机号,输入验证码,不显示错误", async () => {
wrapper.find(".phone").setValue("15609877656");
wrapper.find(".capture").setValue("5468");
await wrapper.find(".btn2").trigger("click");
expect(wrapper.find(".error1").exists()).toBe(false);
expect(wrapper.find(".error1").exists()).toBe(false);
});
it("调起register后,应该发起请求", async () => {
wrapper.find(".phone").setValue("15609877656");
wrapper.find(".capture").setValue("5468");
await wrapper.vm.register();
expect(axios.get).toBeCalled();
expect(axios.get).toBeCalledWith("/login", {
phone: "15609877656",
capture: "5468"
});
});
以上是所有的测试用例,我们运行起来看看
测试覆盖度
通过上图可以得知所有的测试用例均通过了,那我们怎么判断写的测试用例是否合理、是否全面呢?Jest有为我们提供相应的配置来生成测试代码覆盖率的报告,配置如下:
在jest.config.js中新增配置项
// 是否开启将测试覆盖率信息输出为报告
collectCoverage: true,
// 报告应从那些文件中收集
collectCoverageFrom: [
"**/views/*.{js,jsx,vue}",
"**/components/*.{js,jsx,vue}",
"!**/node_modules/**"
]
配置好之后,在运行看看
我们可以发现,相对之前的运行结果,这里多了一个表格,这个表格的内容就是测试覆盖率的报告。表格每一列的含义如下:
- 语句覆盖率(statement coverage)是否每个语句都执行了?
- 分支覆盖率(branch coverage)是否每个函数都调用了?
- 函数覆盖率(function coverage)是否每个if代码块都执行了?
- 行覆盖率(line coverage) 是否每一行都执行了?
另外,我们还可以设置当测试覆盖率达到多少时,测试才通过,在jest.config.js中添加如下配置:
coverageThreshold: {
"global": {
"branches": 100,
"functions": 100,
"lines": 100,
"statements": 100
}
}