Better

Ethan的博客,欢迎访问交流

愉快的进行单元测试吧

昨晚睡得有点早,导致今天凌晨就起立了哈哈,那就来写篇博客吧,较为系统的总结下这段时间写单元测试碰到的问题吧。

正确认识单元测试

什么是单元测试

  • 单元测试相对于集成测试来说,测试粒度更小一些。集成测试的测试对象是整个系统或者某个功能模块。比如测试用户注册、登录功能是否正常,是一种端到端的测试
  • 单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行
  • 考验的是程序员思维的缜密程度,看能否设计出覆盖各种正常和异常情况的测试用例,来保证代码在任何预期或非预期的情况下都能正确执行

为什么要写单元测试

  • 有效地为重构保驾护航,也是保证代码质量最有效的两个手段之一(另一个是 Code Review)
  • 能有效地帮你发现代码中的 bug
  • 能帮你发现代码设计上的问题:如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠框架很高级的特性才能完成,往往就意味着代码设计得不够合理,比如没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等
  • 单元测试是对集成测试的有力补充:程序的运行往往出现在一些边界条件、异常情况下,单测可以利用 mock 的方式返回我们需要模拟的异常,来测试代码的在异常情况下的表现
  • 写单元测试的过程本身就是代码重构的过程:落地持续重构的一个有效途径,编写单元测试就相当于是对代码的一次自我 Code Review,可以发现设计上的不足,以及代码编写方面的问题,比如一些边界条件处理不当等
  • 阅读单元测试能帮助你快速熟悉代码
    • 阅读代码最有效的手段,就是先了解业务背景和设计思路,然后再去看代码,这样就会轻松许多
    • 没有文档和注释的情况下,单元测试就起到了替代性作用,借助单元测试,不需要深入阅读代码,便能知道代码实现了什么功能,哪些情况需要考虑,有哪些边界情况需要处理
  • 单元测试是 TDD 可落地执行的改进方案:先写代码、紧接着写单元测试,最后根据单元测试反馈出来的问题,再回过头去重构代码,这个开发流程更加容易被接受,更加容易落地执行,而且兼顾了 TDD 的优点

如何编写单元测试:针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将这些测试用例翻译成代码的过程

你需要注意的是

  • 只要覆盖率高就够了吗:将覆盖率作为唯一指标是不合理的,更重要的是要看中测试用例是否覆盖了所有可能的情况
  • 写单元测试需要了解代码的实现逻辑吗:不要依赖被测试函数的具体实现逻辑,只关心被测函数实现了什么功能。不要针对实现编写单元测试,否则一旦对代码进行重构,在外部行为不变的情况下,对代码的实现进行了修改,那原本的单测都会运行失败,也就起不到为重构保驾护航的目的了

单元测试的定义:测试程序员自己编写的代码逻辑的正确性,并非是端到端的集成测试,它不需要测试所依赖的外部系统的逻辑正确性。

关于 Mock:如果代码依赖了外部系统或者不可控组件,比如需要依赖数据库、网络通信、文件系统等,就需要将被测代码与外部系统解依赖,这种解依赖的办法就叫做 mock。所谓 mock 就是用一个假服务替换真正存在的服务

什么时候需要 Mock:通常因为这个对象参与逻辑执行(依赖它输出的数据做后续的计算)但又不可控,对于可控的对象,是可以不需要 Mock 的

Test Library

原则:从用户实际使用角度出发的 UI 组件测试框架

核心库:DOM Testing Library

  • 通过 label 文本寻找表单元素
  • 通过 text 文本寻找链接和按钮
  • 通过 data-testid 寻找那些没有明确的 label 和 text 元素

生态

  • 指定 UI 框架包装器:React,Angular 和 Vue(React Testing Library)
  • use-event 模拟浏览器事件
  • js-dom 添加自定义的 Jest 匹配器

几个关键 API

render 函数

  • asFragment:获取快照
  • container:渲染组件的容器元素
  • baseElement:整个 HTML 元素
  • unmount
  • getBy/getAllBy:无匹配项会报错
  • queryBy/queryAllBy:无匹配项返回 null 和空数组
  • findBy/findAllBy:返回 Promise
  • ByLabelText/ByPlaceholderText/ByText/ByAltText/ByTitle/ByDisplayValue/ByRole/ByTestId

query 的参数可以是字符串,正则或函数

当找不到指定元素时,可以使用 container 或 baseElement 查看元素是否真实存在

fireEvent 触发事件

  • fireEvent(node, event)
  • fireEvent[eventName](node)

扩展的匹配器

  • toMatchSnapshot:匹配快照
  • toHaveTextContent:文本元素
  • toHaveAttribute:是否有某个属性
  • toBeDisabled
  • toContainElement
  • toBeInTheDocument
  • toMatchInlineSnapshot
  • ……

实践

有时候 UI 组件的测试,元素的变化并不是在该组件内部。比如 Popover,ToolTip 等元素可能会出现在 body 级别,这时候通过 render 函数返回的 get/find/query 系列是找不到元素的,这时候需要可以借助 library 提供的 screen 对象,api 是一致的。

Jest

作为一个单元测试框架,都会具备如下基本的能力

  • 匹配器 Matchers
    • 通用:浅比较、深比较
    • 真假:undefined、null、boolean
    • number 比较
    • string:toMatch
    • 数组与迭代器:包含
    • 异常
  • 异步能力
  • 装载与卸载:beforeEach/afterEach/beforeAll/afterAll,同时可通过 describe 限定作用域范围
  • 强大的 Mock 能力
  • setup 扩展能力

关于 setup,比如我们可以做如下事情

  • custom Render:比如全局提供 context,store 等
  • add custom queries
  • 默认会执行 afterEach(cleanup),为避免内存泄露考虑

关于处理异步的能力

  • waitFor
  • waitForElementToBeRemoved

具体使用看 doc 即可,比如

await waitFor(() =>
  expect(queryByTestId(container, 'printed-username')).toBeTruthy()
)

Jest 测试异步代码

  • 使用 done 参数
  • 返回 Promise 对象
  • 使用 resolves/rejects 匹配器
  • 使用 Async/Await 关键字

有时代码中会使用 setTimeout 创建定时器,jest 直接提供的 Timer Mocks 供使用

  • 在 test 文件中使用 jest.useFakeTimers();
  • 通过下述方式执行 Timers
    • jest.runAllTimers();
    • jest.runOnlyPendingTimers();
    • jest.advanceTimersByTime(ms);
  • 通过 jest.clearAllTimers() 清空定时器

jest 覆盖率理解

  • Stmts 语句覆盖率:比如有两个分支,一个 4 条语句,另一个 6 条语句,4 条的执行,则覆盖率 40%
  • Branch 分支覆盖率:比如有两个分支,其中一个执行了,则覆盖率 50%
  • Funcs 函数覆盖率:以函数作为计数单元
  • Lines 覆盖率:以行作为计数单元

接下来聊聊最为重要的 Mock 能力

Mock 能力

在 Jest 中,Mock 主要分为如下 3 类

  • Mock Functions
    • jest.fn => mockFn
    • mockFn.mock.(calls|results|instances)
    • mockFn.(mockReturnValueOnce|mockReturnValue|mockResolvedValue|mockImplementation|mockImplementationOnce|mockReturnThis|mockName)
  • Mock Module
    • jest.mock
  • Manual Mock
    • 约定文件夹名称为:__mocks__
    • 用户模块需要显示调用 jest.mock
    • node module 在 node_modules 同级建立 __mocks__ 文件即可,且无需显示调用 jest.mock。注:如果 mock 的是 Node code modules,比如 fs 或 path,依旧需要显示调用 jest.mock

在 Jest 中如果想捕获函数的调用情况,则该函数必须被 mock 或者 spy,jest.spyOn()是 jest.fn() 的语法糖,它创建了一个和被 spy 的函数具有相同内部代码的 mock 函数,使用它可以轻松监控一个对象函数的调用情况,函数原型:jest.spyOn(object, methodName)。

如何 Mock ES6 Class,主要用两种方式,分为自动 Mock 与手动 Mock

  • 自动 Mock:使用 jest.mock 直接 Mock 整个模块
  • 手动 Mock:在需要 Mock 的文件同级创建 __mocks__ 文件夹,然后创建同名文件即可,依旧需要使用 jest.mock 调用,但检测到 __mocks__ 文件夹且存在同名文件时,会优先使用手动 Mock

你还可以通过 jest.mock 中第二参数 moduleFactory 指定 mock,其实只是一种手动 Mock 的变体。这里有个限制,由于 jest 需要将 jest.mock 提升到文件顶部,因此对于工厂函数需要用到的变量,jest 提供了一个逃生舱,变量名使用 mock 开头,同样会自动将变量提升到文件顶部

理解为什么 Mock 一个类看上去比他本来的样子更复杂

  • 首先需要知道的是你也可以在 mock 中直接创建一个普通的类来替换原本的类,但你无法监控函数的调用情况
  • 如果你需要跟踪使用情况,你就需要用到 spy 技术,你需要用到 jest mock 的函数来替换普通的函数
    • 监测构造函数:你需要用到 jest.fn.mockImplementation
    • 监测自身函数:用 jest.fn 来创建函数

总的来说就是,我们为了监控函数的调用情况,才导致 mock 的类看上去比原本更复杂

其他高阶的 jest api

  • jest.genMockFromModule(moduleName):当你手动 Mock 时,使用该函数先得到自动 Mock 的版本,然后你可以修改部分
  • jest.requireActual(moduleName):获取真正的模块,使用该 api 可以做到一部分真实,一部分修改
  • jest.doMock(moduleName, factory, options):mock 函数会被提升到代码顶部,该 api 可以回避掉这个特性
  • jest.unmock(moduleName):总是返回真实的模块
  • jest.dontMock(moduleName):unmock 函数会被提升到代码顶部,该 api 可以回避掉这个特性

哪些踩的坑

className 为空问题

在进行组件 UI 测试的时候,我们经常会有判定是否存在 className 的需求,比如判断该元素是否加上 active 属性呀,但在实际测试中,碰到 className 总是为 undefined 问题。让我一筹莫展。

首先你需要知道的是 CRA 中关于 Jest 的配置,会对一些文件进行 transform,由于当前版本的 CRA 中是默认不支持 less 预处理器的,而我项目中正好使用的是 less,CRA 默认对 scss 文件是有使用 identity-obj-proxy 包进行代理的。该包的作用是将对象的访问直接返回对应的字符串,比如 styles.title 将会返回 title 字符串,这样的返回结果是符合预期,是我们所需要的。

既然问题找到了,参考 CRA 关于 Jest 配置的源码,很自然的想到,我们手动加上它,刚开始我的配置如下

"moduleNameMapper": {
    "^@/(.*)$": "<rootDir>/src/$1",
    "\\.(css|less)$": "identity-obj-proxy",
}

上面例子中第一个配置的目的是支持在 test 文件中通过 @ 符号来导入文件。最大的坑也就埋在这里,害我找到半夜。后面我才发现,Jest 对于 Mapper 的处理是有顺序的,如果被第一个进行处理,就不会走到下一个

我是怎么发现的呢,我尝试不使用 @ 符号导入 less 文件,而是通过相对路径的方式来导入,竟然生效了,我的天,太让人激动了。于是将配置换个顺序,改成如下配置即可

"moduleNameMapper": {
    "\\.(css|less)$": "identity-obj-proxy",
    "^@/(.*)$": "<rootDir>/src/$1",
}

关于 THREE.js 中存在的问题

很奇怪,如果待测试文件中,导入了 three/example 下的某些文件,Jest 会报错,暂时没有理解为什么,先 mark 一下,后续研究,感觉会是一个知识点

TODO:找到为什么这种情况会报错

暂时的解决办法,对 three/example 的导入进行几种收敛在一个文件中,比如 threeUtils 中,然后对 threeUtils 进行 mock,也就是说测试模式下,根本不走 three/example

使用 mockReturnValue 你需要注意的

我在使用 mockReturnValue 时候踩到一个大坑,导致自己怀疑人生,大概代码如下

jest.mock('@/utils/mesh', () => {
  const THREE = jest.requireActual('three');
  return {
    parseBufferJson: jest.fn().mockReturnValue(new THREE.Mesh()),
  };
});

我会调用这个函数,创建多个 Mesh,然后添加到场景中。但神奇的结果出现了,该元素好像永远只能添加一个,然后还神奇的触发了场景的 remove 函数。

原因就在于这种写法会导致永远只会调用一次 new Mesh,然后一直返回这同一个值。THREE.js 中针对已经添加在场景中的同一个 Mesh 会触发 remove 再 add。因此这种情形,请使用 mockImplementation 函数

jest.mock('@/utils/mesh', () => {
  const THREE = jest.requireActual('three');
  return {
    parseBufferJson: jest.fn().mockImplementation(() => new THREE.Mesh()),
  };
});

一切都美好了!

Mock 静态函数

开发过程中,碰到对于静态函数的使用,一开始我使用常规 mock 方式怎么也不生效,当然也是自己走进了误区。以为会很复杂,但其实是最简单的,比如 Array.isArray api 可直接 mock 如下

Array.isArray = jest.fn().mockXXX()

哈哈,是不是挺出乎意料的。

resetMocks

resetMocks 默认值为 false,但我项目中不知为何被我手动声明为 true 了,从而导致一个隐藏 bug。每次测试前自动重置模拟状态。相当于在每次测试之前调用 jest.resetAllMocks()。这将导致删除任何模拟的假实现,但不会恢复其初始实现

const obj = { test: jest.fn(() => []) };
it('test', () => {
  // 此时模块内测试,obj.test() 返回值为 undefined,就是因为 resetMocks 为 true 的原因。
  // 此时不建议在测试外进行 mock 定义,而是直接在测试 scope 内进行定义
})


留言