背景:上周组内在进行如何提高前端的代码质量的话题做了头脑风暴、提出的观点很多,比如:加强codeReview、标明测试回归的范围啊等等、其中一点提高了单元测试、虽然这个东西出现了很久但是鲜有在业务迭代中使用、不过学学还是好的、不然出去都不知道这是个啥岂不是很尴尬?
好吧 言归正传、让我们开启单元测试之旅
单元测试是什么?
单元测试 是针对 程序的最小单元 来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。一个单元可能是单个程序、类、对象、方法等。
--维基百科
单元测试能解决什么问题?
较少的bug能够快速定位问题提高代码的质量减少调试的时间成本为以后更好的重构...
前端领域的现状
听过翻阅资料得知、前端使用比较多的主要为Mocha 、Jest 、就star来说Jest可以说是遥遥领先、且生态比较丰富、接下来展开看一下jest是如何得到前端同学们的青睐的。
hello Jest
通过上图不难发现 Jest的
无需配置、快照、隔离性、接口模拟等都是非常亲民的、上手非常快基本是零基础完成可以驾驭。话不多说直接上手。
准备环境
mkdir Jest-1 && cd Jest-1 && npm init -y
yarn add --dev jest
// or
npm install --save-dev jest
// package.json
{
"name": "jese-1",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest"
},
"author": "",
"license": "ISC",
"devDependencies": {
"jest": "^27.5.1"
}
}
上述代码直接在本地创建了一个Jest-1的文件夹,然后手动安装Jest,在根目录创建一个__tests__的目录
/__tests__/sum.test.js
function sum(a, b) {
return a + b;
}
it('加法测试', () => {
expect(sum(1,2)).toBe(3)
})
test('两个数组是否相等', () => {
expect([1,2,3]).toStrictEqual([1,2,3]);
});
test('两个数组一定不相等', () => {
expect([1,2,3]).not.toStrictEqual([5]);
});
test('小数点相加', () => {
const value = 0.1 + 0.2;
expect(value).toBeCloseTo(0.3);
});
test('是否等于null', () => {
expect(null).toBeNull(null);
});
执行:yarn test or npm run test 就可以看到效果;就这么简单;
你可能不知道那些toBe、toStrictEqual、toStrictEqual是什么意思 没关系!直接转到文档一探究竟、如果你之前了解过jQuery的话、那么对链式调用一定很熟悉、通过以上代码基本的简单的测试流程、接下来只是需要我们了解它的基本语法和配置即可。
Jest的生命周期
describe(name, fn):描述块,讲一组功能相关的测试用例组合在一起it(name, fn, timeout):别名test,用来放测试用例afterAll(fn, timeout):所有测试用例跑完以后执行的方法beforeAll(fn, timeout):所有测试用例执行之前执行的方法afterEach(fn):在每个测试用例执行完后执行的方法beforeEach(fn):在每个测试用例执行之前需要执行的方法
代码示例:
beforeAll(() => console.log('1 - beforeAll'));
afterAll(() => console.log('1 - afterAll'));
beforeEach(() => console.log('1 - beforeEach'));
afterEach(() => console.log('1 - afterEach'));
test('', () => console.log('1 - test'));
describe('Scoped / Nested block', () => {
beforeAll(() => console.log('2 - beforeAll'));
afterAll(() => console.log('2 - afterAll'));
beforeEach(() => console.log('2 - beforeEach'));
afterEach(() => console.log('2 - afterEach'));
test('', () => console.log('2 - test'));
});
// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach
// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll
借用文档的中的例子走一下、基本生命周期的执行顺序一目了然,知道了生命周期的过程、那么对一些场景就有了解决方案,比如:
示例代码:
function foo() {
console.log("每次测试之前都要调用我");
}
function bar(str) {
const string = `hello Jest`;
return string.indexOf(str) !== -1;
}
function isArrs(str) {
const arrs = [1, 2, 3, 4, 5];
return arrs.includes(str)
}
function descBefore () {
return new Promise((resolve) => {
setTimeout(() => {
resolve('我在作用域中')
},1000)
})
}
beforeAll(() => console.log('啥也别说、我是第一个'));
beforeEach(() => {
foo();
});
afterAll(() => console.log('总算全部都执行完了'));
test("是否存在字符串-1", () => {
expect(bar("hello")).toBeTruthy();
});
it("是否存在字符串-2", () => {
expect(bar("Jest")).toBeTruthy();
});
describe("执行作用域", () => {
beforeEach(() => {
// return descBefore()
});
test("是否存在数字1", () => {
expect(isArrs(1)).toBe(true);
});
test("是否存在数字2", () => {
expect(isArrs(2)).toBe(true);
});
});
处理异步
就前端而言、同步的执行逻辑并没有需要我们注意的、但是围绕这业务迭代,处理请求相关的数据进行页面的渲染才是我们经常遇到的、Jest这里也给我们提供了很多的方案:
示例代码-1:
// src/template2/index.js
const time = 1000; // 阈值
// 超过请求的阈值进行限制:
// https://stackoverflow.com/questions/49603939/message-async-callback-was-not-invoked-within-the-5000-ms-timeout-specified-by
// jest.setTimeout(300000);
// 成功的状态
function fetchData() {
const data = {
code:1,
massage:"成功",
data:[
{
id:1,
name:"北京"
},
{
id:2,
name:"青岛"
}
]
}
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(data)
},time)
})
}
// 失败的状态
function fetchErrorData() {
const data = {
code:1,
massage:"成功",
data:[
{
id:1,
name:"北京"
},
{
id:2,
name:"青岛"
}
]
}
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('Code error')
},time)
})
}
module.exports = {
fetchData,
fetchErrorData
};
// src/template2/promise.test.js
/**
* toEqual : 深度对比两个对象是否相等 < https://jestjs.io/zh-Hans/docs/expect#toequalvalue >
* toMatch : 匹配以检查字符串是否与正则表达式匹配。<https://jestjs.io/zh-Hans/docs/expect#tomatchregexp--string >
*
*/
const { fetchData, fetchErrorData } = require('../../src/template2');
const _test = {
code:1,
massage:"成功",
data:[
{
id:1,
name:"北京"
},
{
id:2,
name:"青岛"
}
]
}
// 测试成功的状态
test('测试接口返回成功的的状态-1',() => {
return fetchData().then(data => {
expect(data).toEqual(_test)
})
})
// ! 测试失败的状态
test('测试异步数据是否返回失败',() => {
expect.assertions(1);
return fetchErrorData().catch(e => expect(e).toMatch('error'));
});
// 另外的方法实现:
test('测试接口返回成功的的状态-2', () => {
return expect(fetchData()).resolves.toEqual(_test);
});
// Async/Await
test('测试接口返回成功的的状态-1',async () => {
const result = await fetchData()
expect(result).toEqual(_test)
})
test('测试异步数据是否返回失败', async () => {
expect.assertions(1);
try {
await fetchErrorData();
} catch (e) {
expect(e).toMatch('error');
}
});
经过简单的模拟 我就可以实现异步请求的操作、这里需要注意两个点:
模拟函数
通过上面的代码可以知道、虽然我们可以进行异步的请求、在实际的业务开发中、我们不可能等待接口开发完毕在进行单元测试的编写、一定是在完成某一段业务逻辑后编写相应的单元测试、这样的情况就造成了接口并没有完成、但是我需要模拟接口的请求、那么就需要用到了mock函数、当然mock函数不单单是为了模拟异步的数据形成、还有很多的功能、想要了解的小伙伴可以查看相应的文档
准备环境:
mkdir Jest-mock && cd Jest-mock && npm init -y
yarn add @babel/core @babel/preset-env axios babel-jest jest json-server -D
.babelrc.js
module.exports = {
// See https://babeljs.io/docs/en/babel-preset-env#targets
presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};
db.json
db文件和json-server依赖相关联、本意是本地创建一个server环境、详细了解查阅相关文档
{
"userRole":[
{"roleId":"1","roleName":"超级管理员"},
{"roleId":"2","roleName":"后台管理人员"}
],
"user":{
"data":{
"name":"demo",
"age":"18"
}
}
}
package.json
{
"name": "jese-2",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest",
"server": "json-server --watch db.json"
},
"author": "",
"license": "ISC",
"dependencies": {},
"devDependencies": {
"@babel/core": "^7.17.5",
"@babel/preset-env": "^7.16.11",
"axios": "^0.26.0",
"babel-jest": "^27.5.1",
"jest": "^27.5.1",
"json-server": "^0.17.0"
}
}
以上环境配置完毕、创建业务代码:
src/users.js
import axios from 'axios';
class Users {
static all() {
return axios.get('http://127.0.0.1:3000/user').then(resp => {
return resp.data
});
}
}
export default Users;
创建测试文件:
Jest-mock/__tests__/users.test.js
import Users from '../users';
import axios from 'axios';
// jset 接管
jest.mock('axios');
const users2 = {
"data":{
"name":"demo",
"age":"18"
}
};
const users1 = {
"data":{
"name":"demo",
"age":"18"
}
};
it('测试接口', async () => {
axios.get.mockResolvedValue(users1);
const data = await Users.all()
expect(data).toEqual(users2)
});
通过以上的实例可以看到、Jest拦截了axios的接口状态、然后通过mockResolvedValue函数进行了模拟的操作进行了接口数据的拦截、虽然实现了数据的mock,但是这种方案不适合在项目里自定义接口请求模块。
为了模拟我们自己手写的自定义的模块,我们可以这样:
把之前的src/users.js放在创建好的moduls文件夹中、同时在当前的文件夹中创建__mocks__文件夹、里面存放同名的测试数据、结构如下:
替换前:
.
├── __tests__
│ └── users.test.js
├── db.json
├── package.json
├── users.js
└── yarn.lock
替换后:
├── __tests__
│ └── users.test.js
├── db.json
├── moduls
│ ├── __mocks__
│ │ └── users.js
│ └── users.js
├── package.json
└── yarn.lock
使用mock数据进行测试
//Jest-mock/__tests__/users.test.js
import Users from '../moduls/users';
// jset 接管
jest.mock('../moduls/users.js');
const usersInfo = {
"data":{
"name":"demo",
"age":"18"
}
};
it('测试接口', async () => {
const data = await Users.all()
expect(data).toEqual(usersInfo)
});
根据业务需求创建mock数据
// Jest-mock/moduls/__mocks__/users.js
module.exports={
all(){
return new Promise(function(resolve){
resolve({
"data":{
"name":"demo",
"age":"18"
}
})
})
}
}
这样做的好处不言而喻、虽然本质上就是存放的位置不同、在相应的文档可以得到体现,维护性和业务清晰度都有了很高的提升,尤其在多人维护中往往可以提升很大的效率。
单元测试覆盖率图解
- %Stmts是语句覆盖率(statement coverage):是不是每个语句都执行了?
- %Branch分支覆盖率(branch coverage):是不是每个 if 代码块都执行了?
- %Funcs函数覆盖率(function coverage):是不是每个函数都调用了?
- %Lines行覆盖率(line coverage):是不是每一行都执行了?
Jest接入vue、react
目前不管是vue、还是react一些成熟的框架都继承了单元测试、比如:Vue-cli、nuxtjs、create-react-app等、需要注意的就是编写组件单元测试和常规单元测试的区别、其实原理大同小异、这里的我举一个简单的例子大家可以了解一下、详细的可以根据自己的业务进行翻阅,Vue Test Utils
Vue
// Jest-Vue/test/NuxtLogo.test.js
import { mount } from '@vue/test-utils'
import TimerTool from '@/components/sum/TimerTool.vue'
/**
* toContain: 检查一个字符串是另外一个字符串的子字符串 < https://jestjs.io/zh-Hans/docs/expect#tocontainitem >
* wrapper : 常用方法集合 < https://v1.test-utils.vuejs.org/zh/api/wrapper/#%E5%B1%9E%E6%80%A7 >
*/
describe('检查组件是否正常的加载', () => {
const wrapper = mount(TimerTool) // 获取vue的实例对象。
it('是否正确的呈现正确的节点', () => {
expect(wrapper.html()).toContain(`<span class="count">0</span>`)
})
it('是否存在button', () => {
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
})
describe('模拟用户的操作', () => {
const wrapper = mount(TimerTool)
// console.log(wrapper.html())
it('检查默认值', () => {
expect(wrapper.vm.count).toBe(0)
})
it('检查计数器的文本是否更新',() => {
const button = wrapper.find('button');
button.trigger('click')
expect(wrapper.vm.count).toBe(1)
})
})
详细了解可点击完整实例代码。
React
// jest-react/src/hidden-message.test.js
import '@testing-library/jest-dom'
// NOTE: jest-dom adds handy assertions to Jest and is recommended, but not required
import * as React from 'react'
import {render, fireEvent, screen} from '@testing-library/react'
import HiddenMessage from './hidden-message'
test('shows the children when the checkbox is checked', () => {
const testMessage = 'Test Message'
render(<HiddenMessage>{testMessage}</HiddenMessage>)
expect(screen.queryByText(testMessage)).toBeNull()
fireEvent.click(screen.getByLabelText(/show/i))
expect(screen.getByText(testMessage)).toBeInTheDocument()
})
详细了解可点击完整实例代码。
单元测试带来什么影响
说了这么多、其实我们应该知道不管什么模块、或者什么功能都有双面性、有利必有弊 只是我们在权衡两者的时候,选择了当前项目最优的结果。
场景受限
要谨记单元测试不是万能的、并不能解决全部的问题、受限于测试范围和场景以及数据、只能满足单模块内部的功能验证的需求;比如: 对于一些异常的请求返回状态 收到三方的服务控制、根据无法得知进行单元测试的编写;
时间成本:
简单的函数处理对其整个的需求排期或许影响不大、但是如果是整个需求链路的单元测试编写、那么耗费的时间成本将是开发排期的1/3左右
性能成本:
不能解决或者发现模块可靠性、性能相关、多线程访问等相关的问题、这几类的问题还是从设计上分析、编码时需要注意;
编写单元测试的建议
- 越重要的代码、越要写单元测试.
- 代码做不到单元测试、多思考如何改进、而不是放弃.
- 边写业务代码、边写单元测试、而不是完成整个功能在写.
- 多思考如何改进、简化测试代码.