测试分为单元测试、集成测试和功能测试。
单元测试(Unit Test),是一种对隔离的小型代码单元进行检查和验证的测试实践,也就是说一个测试单元往往是一个原子型函数。
单元测试在整个测试链路中处于最基础层面的测试实践,单元测试应该较好地覆盖到底层细节。对于同个需求,单元测试所要求的用例数量也是最多的。在上层的集成测试中,它的职责是确认所有单元之间正确进行协作。
单元测试是一种验证行为、设计行为,也是一种编写文档的行为,对于项目的稳定有以下优点:
-
保障代码质量和功能的实现的完整度。
-
提升开发效率,在开发过程中进行测试能让我们提前发现 bug 。
-
便于项目维护,后续任何代码更新也必须跑通测试用例,即使进行重构或开发人员发生变化也能保障预期功能的实现。
断言
在程序设计中,断言是一种一阶逻辑设计(判断结果为真为假的逻辑表达式)。断言用于检查程序在运行时是否满足期望,当程序运行到断言的位置时,若断言不为真,程序会中止运行,并抛出异常。
Node 自带 assert 断言模块,常见的断言库有 Should.js, Chai.js 等。
但是,这在大规模断言检查时并不友好,更通用的做法是,单元测试框架记录下抛出的异常并继续执行,最终生成测试报告。常用的第三方测试框架有mocha,jest。
测试用例设计
单元测试用例应该包含:
-
正常输入
离散覆盖参数值域。
-
边界输入
空值验证,零值验证,最大值验证。
-
非法输入
入参数据类型非法,内存溢出验证。
测试框架Jest
Jest是一个开箱即用的测试框架,内置了Expect断言、异步代码、Mock、Snapshot、测试覆盖率统计等全套测试功能,适用于 babel, typescript, node, react, vue, angular 等构建的项目。
安装
- 安装jest及相关依赖
yarn add --dev jest
-
添加test命令
在package.json 的scripts 里添加 "test": "jest"。
-
添加ts支持
yarn add --dev ts-jest @types/jest
在项目根目录下创建文件 jest.config.js,配置项详见 jestjs.io/docs/config…
编写测试文件
Jest 可以自动匹配并执行项目中所有使用 .test.js 或 .spec.js 命名的测试文件,所以我们在建立测试文件时应该遵循以下命名规范:测试文件名 = 被测试函数名 + .spec.ts。
例如被测试单元是 utils.ts 文件里的 fenToYuan 函数,那么对应的测试文件命名为 fenToYuan.spec.js。
/**
* 分转元
* @param {String|Number} value 数值,单位分
* @param {Boolean} isBeautify 是否将数值末尾的0去掉,如5.00转成5、5.20转成5.2,默认为true
*/
export function fenToYuan (value: any, isBeautify = true) {
if (!/^-?\d+$/.test(value)) {
return value || ''
}
const result = (Number(value) / 100).toFixed(2)
const beaut = result.replace(/^(\d+)(\.00)$/, '$1').replace(/^(\d+\.\d)0$/, '$1')
return isBeautify ? Number(beaut) : result
}
创建测试用例 fenToYuan.spec.ts:
import { fenToYuan } from '@/pages/index/shared/utils';
test('convert fen to Yuan', () => {
expect(fenToYuan(5.00, true)).toBe(0.05)
expect(fenToYuan(0, true)).toBe(0)
})
匹配器
Jest 里常用的匹配器有:
- 普通
- toBe() & not.toBe(),用的是Object.is() 方法
- toEqual() & not.toEqual(),值匹配,对象会比较每个键值对。
- 真值
- Boolean
- toBeTruthy() matches anything that an if statement treats as true
- toBeFalsy() matches anything that an if statement treats as false
- null
- toBeNull() 严格匹配
- undefined
- toBeUndefined() 严格匹配 undefined
- toBeDefined() 相反于 toBeUndefined
- Boolean
- 数字
- toBeCloseTo(value, numDigits) 浮点数比较
- 字符串
- toMatch()
- 可迭代对象
- toContain()
- 抛出错误
- toThrow()
异步测试
前端代码异步逻辑太常见了,比如请求、定时器等。Jest支持callback和Promise两种场景的异步测试,promise 支持不同的写法。
- callback
- promise
- resolves / rejects
- async / await
注意,Jest检测到异步测试时,默认等待时间是5秒,如果异步操作时长超过,需要通过 jest.setTimeout 设置等待时长.
DOM测试
Jest 附带了一个 jsdom 可模拟 DOM 环境的功能,就像在浏览器中一样,这意味着我们调用的每个 DOM API的观察方式都可以与浏览器中观察到的方式相同。
如果在 React 项目的单元测试中需要对 dom 进行操作,推荐安装 Airbnb 开源的 React 测试类库 enzyme,供了一套简洁强大的 API,并通过 jQuery 风格的方式进行 DOM 处理,开发体验十分友好,还获得了 React 官方的推荐。
模拟按钮点击:
import React from 'react';
import { shallow } from 'enzyme';
import Button from './Button';
describe('Test Button component', () => {
it('Test click event', () => {
const mockCallBack = jest.fn();
const button = shallow((<Button onClick={mockCallBack}>Ok!</Button>));
button.find('button').simulate('click');
expect(mockCallBack.mock.calls.length).toEqual(1);
});
});
钩子和作用域
测试时难免有些重复的逻辑,或者环境和变量的配置需要反复设置,可以通过钩子函数实现。
- 可重复的,只对所在分组 describe 下的测试生效
- beforeEach(),在每个测试开始前触发回调
- afterEach(),在每个测试完成后触发回调,可以还原某些 mock 数据
- 一次到位的
- beforeAll(),全局初始化
- afterAll(),全局清理
MOCK
Mock 可以分为 mock 数据、函数、事件、环境等。
在编写单元测试用例的过程中,模拟异步请求的环境和返回值非常普遍。
global.console = {
log: jest.fn(),
warn: jest.fn(),
info: jest.fn()
}
describe('report error without init cat', () => {
afterEach(() => {
jest.clearAllMocks()
})
test('with full params', () => {
const res = reportError(...fullParams)
expect(console.warn).toHaveBeenCalledWith(
'Not initCat yet',
new Error('test error')
)
expect(res).toBe(false)
})
})
通过 jest.fn jest.spyOn 实现 Mock 函数,Mock 函数可以和匹配器结合使用。
函数的属性:
// 这个函数只调用一次
expect(someMockFunction.mock.calls.length).toBe(1);
// 这个函数被第一次调用时的第一个 arg 是 'first arg'
expect(someMockFunction.mock.calls[0][0]).toBe('first arg');
// 这个函数被第一次调用时的第二个 arg 是 'second arg'
expect(someMockFunction.mock.calls[0][1]).toBe('second arg');
// 这个函数被实例化两次
expect(someMockFunction.mock.instances.length).toBe(2);
// 这个函数被第一次实例化返回的对象中,有一个 name 属性,且被设置为了 'test’
expect(someMockFunction.mock.instances[0].name).toEqual('test');
函数的返回值:
const myMock = jest.fn();
console.log(myMock());
// > undefined
myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);
console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true
函数调用验证:
// The mock function was called at least once
expect(mockFunc).toHaveBeenCalled();
// The mock function was called at least once with the specified args
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);
// The last call to the mock function was called with the specified args
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);
// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();
在开发和测试中,项目中可以配置完整的MOCK环境,此外也可以通过工具mock:
-
Charles
Throttle Setting, 模拟慢网速环境。
Map Remote / Local, 拦截并模拟请求的返回值。
测试覆盖率
Jest自带测试覆盖率功能,在 jest.config.js 配置文件中开启即可:
// jest.config.js
module.export = {
// 开启测试覆盖
collectCoverage: true,
// 指定覆盖文件
collectCoverageFrom: [
'src/**/*.ts',
'src/**/*.js',
'src/**/*.vue',
'src/**/*.tsx',
'!**/*.d.ts',
'!**/dist/**/*',
],
// 要求覆盖文件的覆盖率100%
coverageThreshold: coverTestFiles.reduce((obj, file) => {
obj[file] = {
statements: 100,
branches: 100
};
return obj;
}, {}),
};
开启测试覆盖后,执行Jest测试完成就会在项目根目录生成一个coverage目录,用浏览器打开其中的index.html文件查看测试覆盖报告。
编写单元测试的原则
- 对复杂函数进行拆分,可以使条件更容易被覆盖
- 对被测试函数中的调用全部进行mock,防止渗透
- 牢记常用的变异算子,编写单测时进行代入
- 对多条件嵌套或多逻辑运算符并列的情况,要进行排列组合
- 对照变异结果,完善单元测试