单元测试

285 阅读7分钟

单元测试 (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 支持 BabelTypeScriptNodeReactAngularVue 等诸多框架!

---摘自Jest官网

Jest会将方法和对象注入到测试文件的全局环境里, 所以你在使用的时候不再需要进行require或者import。 如果你习惯编写明确的导入,你可以在测试文件顶部添加 import {describe, expect, test} from '@jest/globals'

1. 全局函数

详细文档 全局设定

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都是不会失败的.

不适合场景

  • 不适合未做组件拆分的复杂场景
  • 不适合多页面之间联动的场景

参考文献