# 单测(Unit Test)技巧

# 前言

本文目的是提高编写单测的效率,适合于有一定单测编写经验,但被单测困扰的同学。
后文的示例都在 unit-test-examples 仓库中。

# 单测的意义与价值

单测本质:将测试行为及结果固化下来,自动检查被测试代码的运行结果是否符合期望。

  • 单测是一种调试工具;在开发阶段验证代码是否符合期望,比浏览器中调试更有效率
  • 单测是一种项目文档;帮助了解SDK的API及如何使用
  • 单测能降低项目维护成本;拥有完整的单测用例后,单测执行结果会告诉你代码变更的影响范围
  • 单测能帮助你编写更加优秀的代码

# 编码技巧

# 单测的边界

单元测试的目标是你写的代码,测试你写的代码符合期望(白盒测试),边界之外代码若有意外尽量 Mock。
如何确定边界?

  1. 导入的第三方包
  2. 系统(Node.js, 浏览器)提供的部分 API,主要包括 fetch、document、fs

使用Mock的方法技巧参考后文。

# 编写易于测试的代码

优秀的代码有很多要素,优秀的代码肯定是易于测试的。

# 隔离副作用代码

函数副作用是指函数在正常工作任务之外对外部环境所施加的影响。

对JS来说,最常见的副作用是 网络请求、增删DOM节点、读写文件;
副作用不可避免,但可以隔离后只测试相对比较纯的函数。

展开查看示例代码
// 错误示例
function createList(arr) {
  const ulEl = document.createElement('ul')
  arr.forEach((it) => {
    const li = document.createElement('li')
    li.textContent = it
    ulEl.appendChild(li)
  })
  // 其他逻辑。。。
  document.querySelector('container').appendChild(ulEl)
}

// 正确示例;检测返回值 比 mock document要简单得多
function createList(arr) {
  const ulEl = document.createElement('ul')
  arr.forEach((it) => {
    const li = document.createElement('li')
    li.textContent = it
    ulEl.appendChild(li)
  })
  // 其他逻辑。。。
  return ulEl
}

// 对于汇总了副作用的函数,可以使用 Mock 方法进行测试
// 也可以考虑略过,不编写测试代码,权衡成本即可
function insertList() {
  const arr = [1, 2]
  document.querySelector('container').appendChild(createList(arr))
}

# 控制圈复杂度

圈复杂度简单来说就是逻辑分支越多,圈复杂度越高,单测用例覆盖代码越困难。

圈复杂度跟业务复杂度相关,无法完全避免,但可通过编码技巧降低或拆分成多个函数,降低单测难度。

展开查看示例代码
// 错误示例
function envStr2Code(env) {
  if (env === 'dev') {
    return 1
  } else if (env === 'test') {
    return 2
  } else if (env === 'prod') {
    return 3
  } else {
    return -1
  }
}

// 如果要覆盖 所有语句
expect(envStr2Code('dev')).toBe(1)
expect(envStr2Code('test')).toBe(2)
expect(envStr2Code('prod')).toBe(3)
expect(envStr2Code('unknown')).toBe(-1)

// 正确示例
function envStr2Code(env) {
  return ({ dev: 1, test: 2, prod: 3 })[env] ?? -1
}
// 类似的还有
function envCode2Str(code) {
  return [null, 'dev', 'test', 'prod'][code] ?? 'unknown'
}

参考: 圈复杂度优化

圈复杂度常用优化方法

  1. 算法优化
  2. 表达式逻辑优化
  3. 大函数拆小函数

# Jest 技巧

Jest是当前最流行的JS单测框架,下文介绍单测高频使用的技巧来提高编写单测代码的效率,未入门请阅读官方文档

# Expect(断言)

基础的值判断 .toBe.toEqual 等就不介绍了

  • 当检测的数据只知道类型,但具体值是不确定的,就使用expect.any(constructor)
  • 当只需要检测数据的特征,如字符串的子串、数组的值、对象的key,使用expect.stringContainingexpect.arrayContainingexpect.objectContaining
  • toHaveBeenCalled用于判断Mock函数(通常是jest.fn)是否执行,还有多个以toHaveBeenCalled开头的函数用于判断执行Mock函数的参数、次数

expect 示例

# Mock

  • fs;Mock 原生或第三方模块
  • Data.now;拦截 now 返回特定的时间戳
  • fetch;禁止单测发送 HTTP 请求,检测函数调用参数
  • location;Mock 全局只读属性
  • dom;拦截所有 DOM 节点的方法
  • Mock ES6 Class

# Timer

JS是单线程异步执行代码,所以需要API能精确控制定时器回调函数的执行时机,来完全掌控被测试代码的执行。

  • jest.useFakeTimers()所有timer 定时器都会停止运行,需手动控制来执行定时器的回调函数
    • jest.advanceTimersByTime(msToRun)相当于时间往前拨N毫秒,满足的条件的定时器回调函数将被执行
    • jest.advanceTimersToNextTimer(steps)相当于时间往前拨一定时间(不确定),恰好让第1..N个定时器回调被执行,是jest.advanceTimersByTime的快捷方式,控制次数而不是时间,参考解释
  • jest.useRealTimers()恢复真实定时器,jest.useFakeTimers()的反操作
  • jest.runAllTicks()执行所有微任务队列
  • jest.runAllTimers()执行所有宏任务队列

示例代码

# Snapshot 快照

快照经常用来检测 UI(DOM)结构是否符合期望,实际上只要检测的数据比较复杂(比如一个复杂JSON)就可使用快照来简化测试代码。

快照是把上次检测的值序列化为字符串保存到本地文件中,后续检测如果不一致,单测用例就会报错。

若结果变化符合期望,则需在交互界面按下【u】键去更新本地文件中的快照内容。

如果快照中包含随机数、时间戳、id之类每次都会变化的值,默认情况每次执行结果都与上次有差异,导致用例失败,可参考官方示例

复杂数据快照示例

# 配合 vscode

配合 vscode,在保存代码实时运行单测用例,反馈执行结果;且能在编辑器中随时断点 Debug。

参考 vscode 配置:.vscode/launch.json

# Watch 模式

jest 启用 Watch 模式,会监听文件变化自动执行单测用例。

--watch表示vscode中运行单测时启用 Watch 模式。

Debug 代码时,经常需要执行特定的用例,避免干扰。

介绍几个Jest交互模式下高频使用的快捷键:

  • f: 仅运行失败的单测用例
  • p: 仅执行测试文件名匹配的单测用例
  • t: 仅执行测试用例名(test('用例名'))匹配的用例
  • u: 更新快照文件(前文介绍了)
  • q: 退出