在实际项目中做前端单元测试
本篇篇幅较长,各位大佬可以收藏再看
在一个已有 Vue CLI 创建的项目中配置 Jest,添加Vue Test Utils
vue add unit-jest
配置Jest
在项目的根目录下找到jest.config.json
文件,修改如下(部分配置已注释,可按需打开)
module.exports = {
preset: "@vue/cli-plugin-unit-jest",
// 开启测试报告
collectCoverage: true,
// 统计哪里的文件
collectCoverageFrom: ["**/src/views/**", "!**/node_modules/**"],
// 告诉jest针对不同类型的文件如何转义
transform: {
"^.+\.vue$": "vue-jest",
// '^.+\.(vue)$': '<rootDir>/node_modules/vue-jest',
'^.+\.js$': '<rootDir>/node_modules/babel-jest',
'.+\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
'^.+\.jsx?$': 'babel-jest',
'^.+\.ts?$': 'ts-jest'
},
// 告诉jest需要解析的文件
moduleFileExtensions: [
'js',
'ts',
'jsx',
'json',
'vue'
],
// 告诉jest去哪里找模块资源,同webpack中的modules
moduleDirectories: [
'src',
'node_modules'
],
// 告诉jest在编辑的过程中可忽略哪些文件,默认为node_modules下的所有文件
transformIgnorePatterns: [
'<rootDir>/node_modules/'
+ '(?!(vue-awesome|vant|resize-detector|froala-editor|echarts|html2canvas|jspdf))'
],
// 别名,同webpack中的alias
// moduleNameMapper: {
// '^src(.*)$': '<rootDir>/src/$1',
// '^@/(.*)$': '<rootDir>/src/$1',
// '^block(.*)$': '<rootDir>/src/components/block/$1',
// '^toolkit(.*)$': '<rootDir>/src/components/toolkit/$1'
// },
// snapshotSerializers: [
// 'jest-serializer-vue'
// ],
// 告诉jest去哪里找我们编写的测试文件
testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
// '**/tests/unit/**/Test.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
],
// 在执行测试用例之前需要先执行的文件
// setupFiles: ['jest-canvas-mock']
};
页面结构
要测试的页面数据(此代码只留下重要部分)
<div v-if="numInfo.num">
<div class="saleCard" v-if="promotionValue.showNumberOrderCode">
<van-field v-model="orderCode" label="靓号订单查询:">
<template #button>
<van-button @click="getSaleCardOrder(1)">查询</van-button>
</template>
</van-field>
</div>
<div class="saleCard" v-if="promotionValue.showOutCallOrder">
<van-field v-model="sysOrderId" label="酬金订单查询:">
<template #button>
<van-button @click="getSaleCardOrder(2)">查询</van-button>
</template>
</van-field>
</div>
<van-field v-model.trim="formData.name" label="姓名:" />
<van-field v-model="formData.contactNo" label="手机号码:" />
<van-field v-model="formData.authCode" v-if="promotionValue.showVerifyCode" label="验证码:">
<template #button>
<van-button @click="getSmsCode">{{codeText}}{{time}}</van-button>
</template>
</van-field>
<van-field v-model="formData.idCardNo" label="身份证号:" v-if="promotionValue.showIdCardNo" />
<van-field :value="address" label="城市:" />
<van-field v-model.trim="formData.address" label="详细地址:" />
<van-field label="支付方式:" label-align="right" v-if="numInfo.salePrice !=='价格面议'&&promotionValue.mergerPay&&numInfo.salePrice !=='0'">
<template #input>
<div v-if="promotionValue.payTypes.includes('wx')" />
<span>微信支付</span>
</div>
<div v-if="promotionValue.payTypes.includes('alipay')" />
<span>微信支付支付宝支付span>
</div>
</template>
</van-field>
<van-checkbox v-model="checked">
我已阅读并同意
<a @click="openPage(1)">《个人信息保护政策》、</a>
<a @click="openPage(2)">《个人信息收集证明》、</a>
<a @click="openPage(3)">《单独同意书》、</a>
<a @click="openPage(4)">《入网许可协议》</a>
</van-checkbox>
<img src="../assets/img/btn.gif" class="submitBtm" @click="submit"> // 提交图片
</div>
页面总共有9个输入框和一个复选框,还有一个提交按钮的图片,不考虑显隐条件,在页面默认情况界面如下:
因为页面有判断条件,所以v-if判断之后只剩4个输入框。
书写测试用例
1. 找到tests/unit/example.spec.js
因为整个页面是通个一个变量控制的,所以测试有号码的情况下,页面元素展示是否正确
import { mount } from '@vue/test-utils'
import PageForm from "@/views/pageForm.vue";
import Vue from 'vue';
import Util from '@/utils';
Vue.prototype.$util = Util;
const Vant = require('vant')
// import Vant from 'vant';
Vue.use(Vant);
describe('PageForm.vue', () => {
const wrapper = mount(PageForm)
it('renders vanField number', async () => {
await wrapper.setData({'numInfo': {num : 1305555555}})
expect(wrapper.findAll('[class="van-cell__title van-field__label van-field__label--right"]')).toHaveLength(3)
})
})
通过mount挂载页面
import Util from ‘@/utils’; Vue.prototype.$util = Util; 全局使用公共函数
通过import导入vant会报错,暂时使用require方式
setData 设置页面数据,async和await获取更新后的页面
通过findAll获取全部class类名的标签,toHaveLength断言数量
运行单元测试:
npm run test:unit
结果如下:
测试通过
2.显示第一个隐藏的靓号订单查询
再增加一个测试方法
...
describe('PageForm.vue', () => {
const wrapper = mount(PageForm)
it('renders vanField number', async () => {
await wrapper.setData({'numInfo': {num : 1305555555}})
expect(wrapper.findAll('[class="van-cell__title van-field__label van-field__label--right"]')).toHaveLength(4)
})
it('renders vanField showNumberOrderCode', async () => {
await wrapper.setData({'promotionValue': {showNumberOrderCode : true}})
expect(wrapper.findAll('[class="van-cell__title van-field__label van-field__label--right"]')).toHaveLength(5)
})
})
再次运行单元测试:
npm run test:unit
结果如下:
最后,两个测试都通过了
3.显示酬金订单查询
...
describe('PageForm.vue', () => {
const wrapper = mount(PageForm)
it('renders vanField number', async () => {
await wrapper.setData({'numInfo': {num : 1305555555}})
expect(wrapper.findAll('[class="van-cell__title van-field__label van-field__label--right"]')).toHaveLength(4)
})
it('renders vanField showNumberOrderCode', async () => {
await wrapper.setData({'promotionValue': {showNumberOrderCode : true}})
expect(wrapper.findAll('[class="van-cell__title van-field__label van-field__label--right"]')).toHaveLength(5)
})
it('renders vanField showOutCallOrder', async () => {
await wrapper.setData({'promotionValue': {showOutCallOrder : true}})
expect(wrapper.findAll('[class="van-cell__title van-field__label van-field__label--right"]')).toHaveLength(6)
})
})
运行单元测试:
npm run test:unit
结果如下:
三个测试用例都通过了,其他隐藏的输入框的测试用例和上面一样
可配置用例
- 在tests目录下新建testData.js文件(准备了两组测试数据,自行粘贴)
const rendersVanField = [
{
arr: [
{
key: 'showNumberOrderCode',
bool: false, // 靓号订单查询
},
{
key: 'showOutCallOrder',
bool: true, // 酬金订单查询
},
{
key: 'showVerifyCode',
bool: false, // 验证码
},
{
key: 'showIdCardNo',
bool: false, // 身份证号
},
{
key: 'mergerPay',
bool: false, // 支付方式
},
],
expected: 5 // 期望值
},
{
arr: [
{
key: 'showNumberOrderCode',
bool: true, // 靓号订单查询
},
{
key: 'showOutCallOrder',
bool: true, // 酬金订单查询
},
{
key: 'showVerifyCode',
bool: true, // 验证码
},
{
key: 'showIdCardNo',
bool: true, // 身份证号
},
{
key: 'mergerPay',
bool: true, // 支付方式
},
],
expected: 9 // 期望值
},
]
export {
rendersVanField,
}
- 修改测试用例
import { mount } from '@vue/test-utils'
import PageForm from "@/views/pageForm.vue";
import Vue from 'vue';
import Util from '@/utils';
Vue.prototype.$util = Util;
const Vant = require('vant')
// import Vant from 'vant';
Vue.use(Vant);
import { rendersVanField } from "../testData";
rendersVanField.forEach((el, i) => {
describe(`PageForm${i}.vue`, () => {
const wrapper = mount(PageForm)
it('renders vanField number', async () => {
await wrapper.setData({'numInfo': {num : 13073072255}})
// el.arr.forEach(async (item) => {
// await wrapper.setData({'promotionValue': {[item.key] : item.bool}})
// })
for (let index = 0; index < el.arr.length; index++) {
await wrapper.setData({'promotionValue': {[el.arr[index].key] : el.arr[index].bool}})
}
expect(wrapper.findAll('[class="van-cell__title van-field__label van-field__label--right"]')).toHaveLength(el.expected)
})
})
})
里面这层循环不用forEach而用for,是出现了循环之后设置的数据没有生效,所以改用for
运行单元测试:
npm run test:unit
结果如下:
结果显示两条单元测试只通过了一条,为什么第二条测试少了一个输入框呢?查看结构发现支付状态是有多个条件判断显示的
<van-field label="支付方式:" label-align="right" v-if="numInfo.salePrice !=='价格面议'&&promotionValue.mergerPay&&numInfo.salePrice !=='0'">
修改单元测试
...
import { rendersVanField } from "../testData";
rendersVanField.forEach((el, i) => {
describe(`PageForm${i}.vue`, () => {
const wrapper = mount(PageForm)
it('renders vanField number', async () => {
await wrapper.setData({'numInfo': {num : 13073072255}})
for (let index = 0; index < el.arr.length; index++) {
if ([el.arr[index].key] === 'mergerPay') {
await wrapper.setData({
'promotionValue': {[el.arr[index].key] : el.arr[index].bool},
'numInfo': {salePrice: '100'}
})
} else {
await wrapper.setData({'promotionValue': {[el.arr[index].key] : el.arr[index].bool}})
}
}
expect(wrapper.findAll('[class="van-cell__title van-field__label van-field__label--right"]')).toHaveLength(el.expected)
})
})
})
最后运行单元测试,可以发现两条都通过了
4.模拟Axios模块
项目中有与后端接口交互获取数据的清空,结合这个页面发现有一个变量判断显示,但是上面用例的显示是写死的,在这里可以改造成通过接口获取数据
在src/api目录下新建testAxios.js文件
// /src/api/testAxios
const axios = require("axios");
function fetchNumInfoData() {
return axios
.get("/numInfo")
.then((res) => res.data);
}
module.exports = {
fetchNumInfoData,
};
现在我想要测试fetchUserData
函数获取数据,但是又并不实际请求接口时,可以使用jest.mock
来模拟axios模块
...
const numInfo = require("@/api/testAxios.js");
const axios = require("axios");
jest.mock('axios');
rendersVanField.forEach((el, i) => {
describe(`PageForm${i}.vue`, () => {
const wrapper = mount(PageForm)
it('should fetch numInfo', async () => {
const numInfoData = {
num: 13073072255,
};
const res = { data: numInfoData };
axios.get.mockResolvedValue(res);
return numInfo.fetchNumInfoData().then(async (resp) => {
await wrapper.setData({'numInfo': numInfoData})
expect(resp).toEqual(numInfoData);
});
})
it('renders vanField number', async () => {
await wrapper.setData({'numInfo': {num : 13073072255}})
...
})
})
})
一旦我们对模块进行了模拟,我们可以用get函数提供的一个mockResolvedValue方法,以返回我们需要测试的数据;通过模拟后,达到了我想要的效果,axios实际上并没有去真正发送请求去获取/numInfo
的数据
运行单元测试,两组数据中四条用例都通过了
5.测试点击提交是否勾选同意协议
新增测试用例submit agreement
,因为是新的describe,所以需要复制一个前面的显示页面的用例,否则页面初始化是隐藏的,获取不到元素
...
rendersVanField.forEach((el, i) => {
...
})
describe(`PageFormSubmit.vue`, () => {
const wrapper = mount(PageForm)
it('should fetch numInfo', async () => {
const numInfoData = {
num: 13073072255,
};
const res = { data: numInfoData };
axios.get.mockResolvedValue(res);
return numInfo.fetchNumInfoData().then(async (resp) => {
await wrapper.setData({'numInfo': numInfoData})
expect(resp).toEqual(numInfoData);
});
})
it('submit agreement', async () => {
const submitBtn = wrapper.find('[class="submitBtm"]')
await submitBtn.trigger("click")
expect(wrapper.vm.checked).toBe(true)
})
})
运行单元测试,发现与预期一致
6.测试表单提交校验方法
首先在utils下新建一个form-check.js文件,写入校验方法
const ruleList = {
phone: value => {
if (!value) return '请输入手机号';
if (/^1[0-9]{10}$/.test(value)) return true;
return '手机号码不正确'
},
verifyCode: value => {
if (!value) return '请输入验证码';
if (value.length === 4 || value.length === 6) return true;
return "验证码错误";
},
name: value => {
if (!value) return '请输入姓名';
if (/^[\u4e00-\u9fa5]{2,20}$/.test(value)) return true;
if (value.length < 2 || value.length > 20) return '姓名长度不能小于2或超过20';
return '姓名必须为汉字'
},
idCard: value => {
if (!value) return '请输入身份证号';
if (/(^\d{8}(0\d|10|11|12)([0-2]\d|30|31)\d{3}$)|(^\d{6}(18|19|20)\d{2}(0\d|10|11|12)([0-2]\d|30|31)\d{3}(\d|X|x)$)/.test(value)) return true;
return '请输入正确的身份证号码'
},
address: value => {
if (!value) return '请输入详细地址';
//地址信息不得含特殊字符
let roadReg = /^[\u4E00-\u9FA5A-Za-z0-9_—()()-]+$/gi;
if (!roadReg.test(value)) return '地址信息不得含特殊字符哟';
let roadReg2 = /^[A-Za-z0-9]+$/gi;
if (roadReg2.test(value)) return '地址信息不得纯为英文字母或数字哟';
if (value.length < 4) return '地址不能太短哟';
return true;
},
city: value => {
if (!value) return '请选择城市';
return true;
},
birthDate: value => {
if (!value) return '请选择生日';
return true;
},
agreement: value => {
if (!value) return '请勾选同意相关协议';
return true;
},
agreementCity: value => {
if (!value) return '请勾选同意相关协议';
return true;
},
agreementRegion: value => {
if (!value) return '请勾选同意相关协议';
return true;
},
randomCode: (value, item) => {
if (!value) return '请输入验证码';
if (item.value.toUpperCase() !== item.codeValue.toUpperCase()) {
item.getCode()
return '验证码错误';
}
return true
}
}
export default ruleList
编写第一个校验名字用例
....
import checkForm from '@/utils/form-check'
describe(`PageFormSubmit.vue`, () => {
...
it('submit checkForm', async () => {
expect(checkForm.name('刘二牛')).toBe(true)
})
})
运行单元测试,校验通过:
再输入一个错误的数据测试
....
import checkForm from '@/utils/form-check'
describe(`PageFormSubmit.vue`, () => {
...
it('submit checkForm', async () => {
expect(checkForm.name('1231')).toBe(true)
})
})
没有意外,没通过
但返回值是不确定的汉字,不方便测试添加测试数据,所以可以稍微改造一下
....
import checkForm from '@/utils/form-check'
describe(`PageFormSubmit.vue`, () => {
...
it('submit checkForm', async () => {
expect(checkForm.name('1231')).not.toBe(true)
})
})
这样是可以通过的
测试表单的其他方法
....
import checkForm from '@/utils/form-check'
describe(`PageFormSubmit.vue`, () => {
...
it('submit checkForm', async () => {
expect(checkForm.name('刘二牛')).toBe(true)
expect(checkForm.phone('15966523256')).toBe(true)
expect(checkForm.address('广州天河区xxx')).toBe(true)
expect(checkForm.verifyCode('1311')).toBe(true)
expect(checkForm.idCard('22222219990218481X')).toBe(true)
})
})
下图所示,正确的数据都通过了测试
7.编写可配置测试数据
在testData.js新增测试数据
...
const formData = [
[
{
key: 'name',
value: '刘二牛',
expected: true // 期望值
},
{
key: 'phone',
value: '刘二牛',
expected: false // 期望值
},
{
key: 'address',
value: '广州天河区xxx',
expected: true // 期望值
},
{
key: 'verifyCode',
value: '1311',
expected: true // 期望值
},
{
key: 'idCard',
value: '22222219990218481X',
expected: true // 期望值
},
],
]
export {
rendersVanField,
formData,
}
修改用例:
....
import checkForm from '@/utils/form-check'
import { rendersVanField, formData } from "../testData";
describe(`PageFormSubmit.vue`, () => {
...
formData.forEach((el, i) => {
it(`submit checkForm${i}`, async () => {
el.forEach(item => {
if (item.expected) {
expect(checkForm[item.key](item.value)).toBe(item.expected)
} else {
expect(checkForm[item.key](item.value)).not.toBe(item.expected)
}
})
})
})
})
结果如下:
总结
前端框架迭代不断,一个健壮的前端项目应该有单元测试的模块,保证了我们的项目代码质量和功能的稳定;但是也并不是所有的项目都需要有单元测试,毕竟编写测试用例也需要成本;因此如果项目符合下面的几个条件,就可以考虑引入单元测试:
- 长期稳定的项目迭代,需要保证代码的可维护性和功能稳定;
- 页面功能相对来说说比较复杂,逻辑较多;
- 对于一些复用性很高的组件,可以考虑单元测试;