很长一段时间以来,单元测试并不是前端工程师应具备的一项技能,但随着前端工程化的发展,项目日渐复杂化及代码追求高复用性等,促使单元测试愈发重要,决定整个项目质量的关键因素之一
1.单元测试的意义?
- 大规模代码重构时,能保证重构的正确性
- 保证代码的质量,验证功能完整性
2.主流的前端测试框架了解
2.1 框架对比(主流前三)
- Karma - 基于Node.js的JavaScript测试执行过程管理工具(Test Runner),让你的代码自动在多个浏览器(chrome,firefox,ie等)环境下运行
- Mocha - Mocha是一个测试框架,在vue-cli中配合chai断言库实现单元测试( Mocha+chai )
- jest -Jest 是 Facebook 开发的一款 JavaScript 测试框架。在 Facebook 内部广泛用来测试各种 JavaScript 代码
2.2 单元测试分类
- TDD - (测试驱动开发)侧重点偏向开发,通过测试用例来规范约束开发者编写出质量更高、bug更少的代码
- BDD - (行为驱动开发) 由外到内的开发方式,从外部定义业务成果,再深入到能实现这些成果,每个成果会转化成为相应的包含验收标准
简单来说就是TDD先写测试模块,再写主功能代码,然后能让测试模块通过测试,而BDD是先写主功能模块,再写测试模块
2.3 断言库
断言指的是一些布尔表达式,在程序中的某个特定点该表达式值为真,判断代码的实际执行结果与预期结果是否一致,而断言库则是讲常用的方法封装起来
主流的断言库有
-
assert (TDD)
assert("mike" == user.name);
-
expect.js(BDD) - expect() 风格的断言
expect(foo).to.be("aa");
-
should.js - BDD(行为驱动开发)风格贯穿始终
foo.should.be("aa"); //should
-
chai(BDD/TDD) - 集成了expect()、assert()和 should风格的断言
3.单元测试之 Jest 运用
Jest 是 Facebook 开源的一款 JS 单元测试框架,它也是 React 目前使用的单元测试框架,目前vue官方也把它当作为单元测试框架官方推荐 。 目前除了 Facebook 外,Twitter、Airbnb 也在使用 Jest。Jest 除了基本的断言和 Mock 功能外,还有快照测试、实时监控模式、覆盖度报告等实用功能。 同时 Jest 几乎不需要做任何配置便可使用。
我在项目开发使用jest作为单元测试框架,结合vue官方的测试工具vue-util-test
3.1 Jest 安装
npm install --save-dev jest
npm install -g jest
3.2 Jest的配置文件
(1)添加方式
-
自动生成 Jest.config.js
npx jest --init
然后会有一些选择,根据自己的实际情况选择
回车后会在项目目录下自动生成Jest.config.js配置文件,当然也可以选择第二种,手动创建
-
手动创建并配置 Jest.config.js
const path = require('path'); module.exports = { verbose: true, rootDir: path.resolve(__dirname, '../../../'), moduleFileExtensions: [ 'js', 'json', 'vue', ], testMatch: [ '<rootDir>/src/test/unit/specs/*.spec.js', ], transform: { '^.+\\.js$': 'babel-jest', '.*\\.(vue)$': 'vue-jest', }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, transformIgnorePatterns: ['/node_modules/'], collectCoverage: false, coverageReporters: ['json', 'html'], coverageDirectory: '<rootDir>/src/test/unit/coverage', collectCoverageFrom: [ 'src/components/**/*.(js|vue)', '!src/main.js', '!src/router/index.js', '!**/node_modules/**', ], };
配置解析:
- testMatch - 匹配测试用例的文件
- transform - 用
vue-jest
处理*.vue
文件,用babel-jest
处理*.js
文件 - moduleNameMapper - 支持源代码中相同的
@
->src
别名 - coverageDirectory - 覆盖率报告的目录,测试报告所存放的位置
- collectCoverageFrom - 测试报告想要覆盖那些文件,目录,前面加!是避开这些文件
(2)jest命令行工具
{
"name": "test",
"version": "1.0.0",
"scripts": {
"unit": "jest --config src/test/unit/jest.conf.js --coverage",
},
dependencies": {
"vue-jest": "^3.0.5",
},
"devDependencies":{
"@vue/test-utils": "^1.0.0-beta.13",
"babel-core": "^7.0.0-bridge.0",
"babel-jest": "^21.2.0",
"jest": "^21.2.1",
}
}
-
config - 配置jest配置文件路径
-
coverage - 生成测试覆盖率报告
coverage是jest提供的生成测试覆盖率报告的命令,需要生成覆盖率报告的在package.json添加--coverage参数
(3) 单元测试文件命名
以spec.js结尾命名,spec是sepcification的缩写。就测试而言,Specification指的是给定特性或者必须满足的应用的技术细节
(4)单元测试报告覆盖率指标
执行: npm run unit
配置后执行该命令会直接生成coverage文件并在终端显示各个指标的覆盖率概览

在网页中打开coverage目录下的index.html就可以看到具体每个组件的测试报告
- 语句覆盖率(statement coverage)是否每个语句都执行了?
- 分支覆盖率(branch coverage)是否每个函数都调用了?
- 函数覆盖率(function coverage)是否每个if代码块都执行了?
- 行覆盖率(line coverage) 是否每一行都执行了?
当我们完成单元测试覆盖率达不到100%,不用慌,不用过度追求100%的覆盖率,把核心的功能模块测通即可,当然如果你要设置最低的覆盖率检测,可以在配置中加入如下,如果覆盖率低于你所设置的阈值(80%),则测试结果失败不通过
//jest.config.js
coverageThreshold: {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
},
3.3 Jest的常用断言
expect(1+1).toBe(2)//判断两个值是否相等,toBe不能判断对象,需要判断对象要使用toEqual
expect({a: 1}).toEqual({a: 1});//会递归检查对象的每个字段
expect(1).not.toBe(2)//判断不等
expect(n).toBeNull(); //判断是否为null
expect(n).toBeTruthy(); //判断结果为true
expect(n).toBeFalsy(); //判断结果为false
expect(value).toBeCloseTo(0.3); // 浮点数判断相等
expect(compileAndroidCode).toThrow(ConfigError); //判断抛出异常
3.4 Jest + Vue Test Utils 测试组件实例
Vue Test Utils 是 Vue.js 官方的单元测试实用工具库,通过两者结合来测试验证码组件,覆盖各功能测试
//kAuthCode
<template>
<p class="kauthcode">
<span class="kauthcode_btn" v-if="vertification" @click="handleCode"> 获取验证码</span>
<span v-else>{{timer}} 秒重新获取</span>
</p>
</template>
<script>
export default {
name: 'KAuthCode',
props: {
phone: {
type: String,
require: true,
},
type: {
type: String,
default: '1',
require: true,
validator(t) {
return ['1', '2'].includes(t);// 1手机 2邮箱
},
},
validateType: {
type: String,
default: '1',
validator(t) {
return ['1', '2', '3'].includes(t);// 1 消息 2 表单 3自定义
},
},
},
data() {
return {
timer: 60,
vertification: true,
};
},
methods: {
handleCode() {
if (!this.phone) {
switch (this.type) {
case '1':
this.$Message.warning('手机号码不能为空');
break;
case '2':
this.$refs.formRef.validateField('code');
break;
default: break;
}
return;
}
this.getCode();
},
getCode() {
let response;
switch (this.type) {
case '1':
response = this.$api.login.getPhoneCode({ mobileNumber: this.phone });
break;
case '2':
response = this.$api.login.getEmailCode({ email: this.phone });
break;
default: break;
}
response.then(() => {
this.$Message.success('验证码发送成功');
this.vertification = false;
const codeTimer = setInterval(() => {
this.timer -= 1;
if (this.timer <= 0) {
this.vertification = true;
this.timer = 60;
clearInterval(codeTimer);
}
}, 1000);
});
},
},
};
</script>
<style lang="less" scoped>
.kauthcode {
span {
display: inline-block;
width: 100%;
}
}
</style>
测试文件
// kAuthCode.spec.js
import {createLocalVue, mount, shallowMount} from '@vue/test-utils';
import KAuthCode from '@/components/common/KAuthCode.vue';
import login from '@/service/modules/login.js';
import iviewUI from 'view-design';
const localVue = createLocalVue();
localVue.use(iviewUI);
const testPhone = '18898538706';
jest.mock('@/service/modules/login.js', () => ({
getPhoneCode: () => Promise.resolve({
data: {
answer: 'mock_yes',
image: 'mock.png',
}
})
}))
describe('KAuthCode.vue', () => {
const option = {
propsData: {
// phone: testPhone,
type: '2'
},
mocks: {
$api: {
login
},
},
};
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
});
const wrapper = mount(KAuthCode, option);
it('设置手机号码', () => {
const getCode = jest.fn();
option.methods = {getCode};
wrapper.find('.kauthcode_btn').trigger('click');
expect(wrapper.vm.phone).toBe(testPhone);
});
it('没有设置手机号码应报错', () => {
wrapper.setData({type:'2'});
const status = wrapper.find('.kauthcode_btn').trigger('click');
expect(status).toBeFalsy();
});
});
3.5 Vue-Test-Utils API
3.5.1 Wrapper
一个 Wrapper 是一个包括了一个挂载组件或 vnode,以及测试该组件或 vnode 的方法, 通过用mount(component,option)来挂载组件,得到wrapper包裹器,可通过
-
wrapper.vm
访问实际的 Vue 实例 -
wrapper.setData
修改实例 -
wrapper.find
找到相应的dom并触发事件`wrapper.find('.kauthcode_btn').trigger('click'); -
propsData
- 组件被挂载时对props的设置import {createLocalVue, mount, shallowMount} from '@vue/test-utils'; import KAuthCode from '@/components/common/KAuthCode.vue'; const option = { propsData: { // phone: testPhone, type: '2' }, mocks: { $api: { login }, }, }; const wrapper = mount(KAuthCode, option);
ps: 也可以通过shallowMount来挂载组件,区别在于shallowMount不会渲染子组件,详细区别,可以通过shallowMount和mount两个方法分别挂载同组件并进行快照测试后查看所生成文件内容
3.5.2 CreateLocalVue
返回一个 Vue 的类供你添加组件、混入和安装插件而不会污染全局的 Vue 类
import {createLocalVue, mount} from '@vue/test-utils';
import iviewUI from 'view-design';
const localVue = createLocalVue();
localVue.use(iviewUI);
3.5.3 测试钩子
beforeEach和afterEach - 在同一个describe描述中,beforeAll和afterAll会在多个it作用域内执行,适合做一次性设置
- beforeEach(fn) 在每一个测试之前需要做的事情,比如测试之前将某个数据恢复到初始状态
- afterEach(fn) 在每一个测试用例执行结束之后运行
- beforeAll(fn) 在所有的测试之前需要做什么
- afterAll(fn) 在测试用例执行结束之后运行
调用顺序: beforeAll => beforeEach => afterAll => afterEach
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
});
3.5.4 mock函数
三个与 Mock 函数相关的API,分别是jest.fn()、jest.spyOn()、jest.mock()
-
jest.fn() - 是创建Mock函数最简单的方式,如果没有定义函数内部的实现,jest.fn()会返回undefined作为返回值,当然你也可以给他设置返回值、定义内部实现或返回Promise对象,如下例:
// 断言mockFn执行后返回值为name it('jest.fn()返回值', () => { let mockFn = jest.fn().mockReturnValue('name'); expect(mockFn()).toBe('name'); })
//定义jest.fn()的内部实现并断言其结果 it('jest.fn()的内部实现', () => { let mockFn = jest.fn((a, b) => { return a + b; }) expect(mockFn(2, 2)).toBe(4); })
//jest.fn()返回Promise对象 test('jest.fn()返回Promise', async () => { let mockFn = jest.fn().mockResolvedValue('name'); let result = await mockFn();
// 断言mockFn通过await关键字执行后返回值为name expect(result).toBe('name');
// 断言mockFn调用后返回的是Promise对象 expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]"); })
-
jest.mock() - jest.mock 会自动根据被 mock 的模块组织 mock 对象。mock 对象将具有原模块的字段和方法
// kAuthCode.spec.js jest.mock('@/service/modules/login.js', () => ({ getPhoneCode: () => Promise.resolve({ data: { answer: 'mock_yes', image: 'mock.png', } }) })) it('设置手机号码', () => { const getCode = jest.fn(); option.methods = {getCode}; wrapper.find('.kauthcode_btn').trigger('click'); expect(getCode).toHaveBeenCalled() expect(wrapper.vm.phone).toBe(testPhone); });
需要用mock掉整个axios的请求,使用toHaveBeenCalled判断这个方法是否被调用就可以了
这个例子里面,我们只需关注getCode方法,其他可以忽略。为了测试这个方法,我们应该做到:
- 我们不需要实际调用axios.get方法,需要将它mock掉
- 我们需要测试是否调用了axios方法(但是并不实际触发)并且返回了一个Promise对象
- 返回的Promise对象执行了回调函数
注:有时候会存在一种情况,在同个组件中调用同个方法,只是返回值不同,我们可能要对它进行多次不同的mock,这时候需要在beforeEach使用restoreAllMocks方法重置状态
mock的目的:
- 设置函数返回值
- 获取获函数调用情况
- 改变原本函数的内部实现
4. ️踩坑点
1.触发事件 - 假设组件库使用的是iview中对<Checkbox>提供的@change事件,但是当我们进行
wrapper.trigger('change')时,是触发不了的。<Button>的@click()和<button>的@click也是有区别的。
2。渲染问题 - 组件库提供的组件渲染后的html,需要通过wrapper.html()来看,可能会与你从控
制台看到的html有所区别,为避免测试结果出错,还应console.log一下wrapper.html()看一下实际的渲染结果
来自北京昌平的用户 2天前
开始学习
来自辽宁沈阳的用户 2天前
楼下方便加下微信麽,交流下!
来自重庆万州的用户 4天前
优秀,加油!
来自安徽合肥的用户 4天前
学习了, 总结的很细很棒, 赞
来自浙江杭州的用户 8天前
继续讲撒 这个东西挺好玩啊
来自山西太原的用户 13天前
先Mark后看(●◡●)ノ
来自上海徐汇的用户 17天前
good
来自安徽合肥的用户 23天前
为何你如此秀
来自北京昌平的用户 27天前
写的真好,受用
来自福建福州的用户 30天前
学习了