单元测试 (Unit Testing) 入门
什么是单元测试
单元测试是指对软件中的最小可测试单元进行检查和验证. 一个单元测试是一段自动化的代码, 这段代码调用被测试的工作单元, 之后对这个单元的单个最终结果的某些假设进行校验.
最小单元定义比较宽泛, 其实可理解成为一个函数, 功能组件, 类等都可称为单元.
单元测试的两个常用方法
TDD (Test-driven development): 测试驱动开发
TDD 的思想是根据需求先写测试用例,依照测试用例再去写功能代码。当增加或者修改某一项需求的时候,需要先修改测试用例,再依照测试用例去修改代码逻辑。
BDD (Behavior-driven development): 行为驱动开发
与 TDD 相反,BDD 是根据需求先进行开发,等到该功能开发完毕后,再开始编写测试代码进行测试。
BDD和TDD均有各自的适用场景,BDD一般更偏向于系统功能和业务逻辑的自动化测试设计,而TDD在快速开发并测试功能模块的过程中则更加高效,以快速完成开发为目的。
为什么要做单元测试???
目前公司现状:
graph LR
需求评审 --> 需求拆分 --> 后端 & 前端 --自测--> 联调 --> 提测
现在的开发流程中, 先进行需求评审, 之后前后端分别进入开发阶段, 大家会各写个的最后进行联调阶段. 虽然各自都会进行自测, 但是最后发现还是会有功能的遗漏或者有明显的bug等情况. 同样的最后进入到测试阶段, 仍然会有流程上的卡点, 让测试也无法进行, 影响效率等.
如果增加单元测试(TDD):
graph LR
A[需求评审] --> AB[需求拆分]
AB --分配到人--> B[编写测试用例]
B --> C[后端]
C --> E[单元测试]
E --> G[联调]
B --> D[前端]
D --> F[单元测试]
F --> G
G --> 提测
测试用例: 测试用例是为需求, 编写的一组测试输入, 执行条件以及输出结果的预期. 每一个测试点应该是根据详细的分析需求, 最终得到的每一个需要测试的功能点.
在流程上我们开发前, 分配好各自的任务, 没有着急的先去开发写代码. 优先分析需求和理解需求, 然后制定一个属于自己的测试用例(在测试用例制定这一步, 其实是帮助我们在开始开发前更好的理解需求, 提出疑问, 在最后的实现上少走一些弯路). 在编写完测试用例之后, 可以进入开发阶段, 最后根据测试用例编写单元测试所需的代码.
这种方式其实更适合我们在
fe-coms中对一些公共方法和组件编写的一些单元测试.
如果增加单元测试(BDD):
graph LR
A[需求评审] --> B[需求拆分]
B --> C[后端]
B --> D[前端]
D --> H[测试用例]
C --> H[测试用例]
H --> E[单元测试]
E --> G[联调]
G --> 提测
这种方法在流程上与我们现有的更相似, 只是在联调和提测前, 先编写测试用例和单元测试的代码, 已通过单元测试为提测标准.
这种方法更适合使用在我们的业务迭代和系统功能开发中进行测试.
单元测试怎么做 (原则)
- 从测试用例入手, 充分考虑好各种边界条件
- 避免编写永不失败和没有结果校验的测试
- 数据模拟实现, 且数据应该尽量真实
- 对于关键, 重点, 复杂的代码着重考虑测试的case
- 如发现一段代码单元测试的很难写, 或者是无从下手, 就要考虑这一段代码是否为
最小单元, 以助于对代码设计上的优化和精简. - 其实在编写单元测试的代码和开发需求的精力应该大致相同
测试框架
Jest
Jest 是一款优雅、简洁的 JavaScript 测试框架。Jest 支持 Babel、TypeScript、Node、React、Angular、Vue 等诸多框架!
---摘自Jest官网
Jest会将方法和对象注入到测试文件的全局环境里, 所以你在使用的时候不再需要进行require或者import。 如果你习惯编写明确的导入,你可以在测试文件顶部添加 import {describe, expect, test} from '@jest/globals'。
1. 全局函数
afterAll(fn, timeout)文件内所有测试完成后执行的钩子函数.afterEach(fn, timeout)文件内每个测试完成后执行的钩子函数.beforeAll(fn, timeout)文件内所有测试开始前执行的钩子函数.beforeEach(fn, timeout)文件内每个测试开始前执行的钩子函数.test(name, fn, timeout)test是将运行测试的方法. (通俗的说就是每一个case的测试回调函数)test('加法 1 + 2', () => { expect(1 + 2).toBe(3); });- ......
详细文档 全局设定
2. Expect 断言
判断一个值是否满足条件,你会使用到expect函数。 很少会单独调用expect函数, 因为通常会结合expect 和匹配器函数来断言某个值。
test('加法: 2 + 2', () => {
// expect
expect(2 + 2).toBe(4);
});
详细文档 Expect 断言
3. 匹配器
Jest使用“匹配器”的机制可以使用各种方法进行测试, 匹配器验证你的expect的值是否匹配成功.
test('加法 1 + 2 相反测试', () => {
expect(1 + 2).not.toBe(4);
});
.toBe匹配当前expect的返回值是否精确匹配..toEqual递归检查对象或数组的每个字段..toBeNull只匹配null..toBeUndefined匹配undefined..toBeCalled判断函数是否被调用..toBeCalledWith确保使用特定参数调用模拟函数。使用.toEqual使用的相同算法检查参数。expect.extend(matchers)自定义匹配器. 返回值为{pass: true |false, message: '通过或不通过的提示信息.'}.- ......
详细文档 匹配器的使用
4. 模拟函数
模拟函数也被称为“间谍”,因为它们让您可以监视由其他代码间接调用的函数的行为,而不仅仅是测试输出。可以使用 jest.fn() 创建一个模拟函数。如果没有给出实现,模拟函数将在调用时返回 undefined.
个人理解
jest.fn()其实可以监控当前函数的调用次数和参数值. 还可以模拟一些业务和不关心的代码, 做到解耦.
fn()返回一个函数, 函数返回值为undefined.fn().mockResolvedValue()返回一个异步结果.fn().mockResolvedValueOnce()函数将用作对模拟函数的一次调用的模拟实现。可以链接起来,以便多个函数调用产生不同的结果。- ......
详细文档 模拟函数
test("使用fn() mock一个 undefined 数据", () => {
let mockResult = jest.fn();
expect(mockResult()).toBeUndefined();
});
test("使用fn() mock 数据 观察是否被调用", () => {
let mockResult = jest.fn();
let useResult = mockResult();
expect(mockResult).toBeCalled();
});
test("使用fn() mock 数据 观察是否被调用 并且参数是否为当前参数{a: 1}", () => {
let mockResult = jest.fn();
let useResult = mockResult({a: 1});
expect(mockResult).toBeCalledWith({a: 1});
});
test('测试jest.fn()返回Promise', async () => {
let mockFn = jest.fn().mockResolvedValue('default');
let result = await mockFn();
// 断言mockFn通过await关键字执行后返回值为default
expect(result).toBe('default');
// 断言mockFn调用后返回的是Promise对象
expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})
test('测试jest.fn()返回Promise 链式调用2', async () => {
const asyncMock = jest
.fn()
.mockResolvedValue('default')
.mockResolvedValueOnce('first call')
.mockResolvedValueOnce('second call');
expect(await asyncMock()).toBe('first call');
expect(await asyncMock()).toBe('second call');
expect(await asyncMock()).toBe('default');
expect(await asyncMock()).toBe('default');
});
实际使用的例子
import {
querySequenceCode,
getSequenceCodeSync,
getBatchSequenceCodeSync,
getSequenceCode,
getBatchSequenceCode
} from '../sequence-code'
/**
* querySequenceCode
* 1. 可以获取到默认条数的code数量
* 2. 传入长度, 请求最终长度应为当前codes中剩余数量 - 需要的数量 + 默认数量
*/
test('1. 获取固定数量的code', async () => {
const codes = await querySequenceCode()
expect(codes).toHaveLength(100)
})
test('2. 获取设定长度的code', async () => {
await querySequenceCode()
const getCodes = await getBatchSequenceCode(30)
const codes = await querySequenceCode(30)
expect(getCodes).toHaveLength(30)
expect(codes).toHaveLength(100 + 30)
})
/**
* getSequenceCodeSync
* 1. 返回一个code (只能以英文字母和数字组成)
* 2. 循环内调用同步获取单个
*/
test('1. 返回一个code (只能以英文字母和数字组成)', async () => {
for (let i = 0; i < 10; i++) {
const codes = getSequenceCodeSync()
expect(codes).toMatch(/^[a-z0-9]+$/i)
}
})
/**
* getBatchSequenceCodeSync
* 1. 批量调用返回对应长度的code
*/
test('1. 批量调用返回对应长度的code', async () => {
const codes = getBatchSequenceCodeSync(20)
expect(codes).toHaveLength(20)
})
/**
* getSequenceCode
* 1. 异步获取单个code
* 2. 返回promise
* 3. resolve应该是一个code字符串
*/
test('1. 批量调用返回对应长度的code', async () => {
const codes = getSequenceCode()
await expect(codes).resolves.toMatch(/^[a-z0-9]+$/i)
})
Vue Test Utils
Vue Test Utils 是 Vue.js 官方的单元测试实用工具库。
未完待续....
总结
优点
- 能够将思考和分析的过程记录下来, 最终转化为测试用例, 有助于我们对需求的理解和判断需求是否合理.
- 大量减少手动操作, 来回自测的工作量.
- 大量减少功能遗漏, bug 频发的情况.
- 对代码重构效率有所提升, 只要重构后的代码可以通过单元测试, 基本上功能就可以保证正常.
缺点
- 对于开发而言, 增加了一定的开发成本, 开发周期上也有延长.
- 学习框架成本, 全局函数, 断言逻辑众多, 需要转变思路从测试角度出发, 去制定case. 往往开发自己定的case都是不会失败的.
不适合场景
- 不适合未做组件拆分的复杂场景
- 不适合多页面之间联动的场景