前言
最近在给项目代码补单测,React 项目,第一反应就是成熟的解决方案 Jest + enzyme。按说读读 API,三两下应该就能写好,然而过程中却有点磕碰,遇到一些始料未及的问题。有些是关于 enzyme 的使用方法问题,有些则是对单测思路的理解不够。现在整理出来给大家参考哈~
准备
这里没啥好说,通过 github 指引安装好 Jest 和 enzyme 并做好配置即可:
Jest: https://github.com/facebook/jest
enzyme: https://github.com/enzymejs/enzyme
记得,根据 enzyme 的指引在 Jest 的配置里做统一配置(https://github.com/enzymejs/enzyme/blob/master/docs/guides/jest.md),就不需要每个单测文件去写应用 Adapter 的代码了。
好用的 VSCode 插件
VSCode 有很多好用的插件,做 Jest 单测可以装 Jest Runner 插件,随时执行用例,只执行某个 describe 甚至某个 it,非常方便,甚至可以逐步 debug:
如果你的 Jest 的配置文件不是根目录的 jest.config.js ,则要在插件设置里修改:
建议把覆盖率开关给关掉(毕竟你有很多单测文件单测用例,没法使用同一个单测覆盖配置吧)。
最实用的 API:debug
Jest + enzyme 入手简单,看看官方 demo 就知道怎么写,各个 API 看官方文档就可以了。但是在你开始写单测之前,郑重推荐 enzyme 的 debug API。使用方法:
const component = shallow(<Test />);
console.log(component.debug());
配合 console.log,它可以将 enzyme 渲染出来的 React 组件的 DOM 结构,看下图就知道了。在你写单测报错百思不得其解的时候,把这个打印出来可以发现并解决大部分问题!实乃居家必备!
shallow 还是 mount
喜欢 enzyme 的一个原因就是它提供 shallow 渲染方式,也叫浅渲染。顾名思义:
mount
:会对 React 组件进行完整渲染,包括每个子组件,最后生成的结构是我们熟悉的 DOM 结构的样子;
shallow
: 只会渲染一层。碰到子组件就当做 web component 的节点看,原样保留。如上节图示。
做单测的一个原则,是要尽量保证被测对象的纯净性。测一个文件就尽量不要引入其他文件,测一个函数就尽量不要涉及其他函数。同理,测一个 React 组件就尽量不要引入其他组件的逻辑,其他组件就应该在它自己的用例里测试。
所以,除了一些特殊情况,尽量只用 shallow。至于特殊情况,下文会提到。
mock、mock、mock!
上节提到要保证被测对象纯净性,那要测文件 A 确实需要 import 文件 BCD 才能跑起来,怎么办?都 mock 掉就完事了。
Jest 提供了不少 mock 用的函数,最主要有:
jest.fn
:对函数进行 mock,执行一个空函数,不执行原函数,返回 jest mock function。也可以传参替换成执行你传入的函数;
jest.spyOn
:跟 jest.fn 差不多,只不过它会执行原函数,同返回 jest mock function;
jest.fn().mockImplementation
:对带原型的函数进行 mock;
jest.mock
:对模块进行 mock。
mock 完使用 toHaveBeenCalled、toHaveBeenCalledWith、toHaveBeenCalledTimes 来检测,用法见 官方文档 即可。然而在使用过程中,还有不少小技巧。
1. mock 模块有些小坑
首先,路径怎么写?jest.mock 第一个参数是要 mock 的包的路径,那这个路径是写相对于单测文件的路径,还是被测试文件里引用的路径?
经测试验证,这里的路径是相对于单测文件的,只要单测文件通过这个路径可以 import 到对应的文件即可。比如:
单测文件和被测文件的路径不必写成一样,只要文件系统能对应到同个文件即可。
接着,别在 describe 里面对模块进行 mock,没用,不生效。Jest 会将 jest.mock
语句提升到代码文件的最前面,也就没法在代码中间动态 mock 成不同的样子。
再有,使用 jest.mock 的第二个参数做模块自定义 mock 时,不能使用外部变量。如下图:
也同样没法从其他文件引入对象作为要 mock 的结果:
之所以这样估计也是因为 jest.mock
自动提升到代码文件最前面的特性。
2. 网络请求怎么 mock?
直接对做网络请求的包 mock 掉嘛,如 axios,然后同步返回编好的假数据。
这时有同学会问了,那我同个请求同样的参数调用,希望测到成功、失败等多种情况,怎么办??
使用点小技巧即可:
3. 我就是需要动态 mock 模块怎么办?
前文提到不要在 describe 里面使用 jest.mock
,那如果实际开发中确实需要对一个文件反复 mock 呢?比如要测试文件 A,里面用了文件 ua.ts,它会返回当前页面的相关 ua 信息。而单测里面需要不断切换 ua 做不同的测试用例。
此时有两种方案可以考虑:
方案1: hack 的小技巧,像上面 mock 网络请求一样,mock 这个模块的时候留一个自定义的接口如 setUa
,然后就可以在每个单测用例前调用切换参数。虽是 hack 且可能会稍微影响到 TS 但好用;
方案2:使用官方接口 jest.doMock,这个接口就不会自动提升到代码顶部,可以动态 mock,但使用时就要很注意:所有依赖于要 mock 文件的代码最好在 mock 之后才 import 进来,这样才能用上 mock 代码。改成动态 import 可参考官方文档。
两者各有优劣,自行根据情况选择哈。
4. mock 模块统一管理
官方示例是在要 mock 的文件同级创建 __mocks__
文件夹放同名文件,使用 jest.mock('xxx')
即可自动匹配。
但是我们的项目,希望单测的文件都统一放在 <rootDir>/test/unit
下面,单测的 mock 文件统一放在 <rootDir>/test/unit/__mocks__
下面,怎办?Jest 提供了相关配置。
首先改 Jest 的配置,增加 roots:
接着在 __mocks__
目录下根据 mock 时写的路径一层层建目录和文件即可。比如,使用时 jest.mock('aa/bb')
,只要建立 __mocks_/aa/bb.ts
即可自动匹配替换。
这里要特别强调一个点:当我们对 npm 包做上述处理时,Jest 会自动替换:
也就是说不管你写不写 jest.mock('xxx')
这一句,只要 __mocks__
目录下有对应文件,Jest 就会自动替换,所有用例文件及它们引入的被测文件都生效。一般情况下这样做也没什么问题,毕竟外部 npm 包一般比较稳定。但是如果我们项目中使用了 lerna 来管理代码,那就不一样了。
举个例子,A 同学要测试的文件用了项目的 lerna 包 @project/package1,他在__mocks__
目录放了个 mock 文件替换。此时 BCDEF 等等其他同学的用例可能就跑不起来了,因为他们测试的源文件引用了那个包,被自动替换成 mock 文件,而那个 mock 文件不符合他们场景的需要,于是 GG 了。即是说,lerna 包没法像项目的普通文件一样,通过写不写 jest.mock
由单测文件决定要不要 mock,而是被全自动 mock。
谨慎对 lerna 包进行 mock。
一些小 Tips
Tips1:ref 函数怎么触发
写 React 组件用到 ref 并不奇怪,特别是函数形式:
但是用 enzyme 测试的时候却发现 ref 函数没执行?!
执行 ref 函数,要用 enzyme 的 mount 方法来加载组件。这就是我前文提到的特殊情况。
Tips2:谨慎对待 setState
在 componentDidMount、事件触发等情况下调用 setState,这是很常见的。大家都知道 React 的 setState 方法是异步的,不过 enzyme 的不同模式对 setState 的处理不一样。
在 shallow 渲染模式下,调用 setState 会同步更新 state 的值:
但是如果用 mount 模式,组件内部调用 setState 会异步生效,此时要强制更新:
所以,尽量使用 shallow。
Tips3:用好纯函数组件
有这样一个例子:有一个函数根据传进来的参数不同,返回不同的 React 组件。现在对它做单测:
第一个用例成功了,第二个却失败了??通过 console.log(component.debug())
发现按钮 B 的 Test 组件被展开了。不是说用 shallow 不会对子组件做展开吗?
其实 shallow 会展开第一层节点,如 shallow(<CompA />)
会展开 CompA 组件 ,而这里函数执行后第二个用例 Test 组件变为第一层组件被展开,于是没法像按钮 A 一样做测试了(子组件对 name 属性消化用在别的地方了)。
怎么改?在 Test 外面多包一层?更好的做法还是使用纯函数组件:
总结
总的来说 Jest + enzyme 确实好用,本文只是我的一些浅显经验,肯定有更多的技巧和好用的工具待解锁,欢迎大家留言交流哈~