使用 jest 和 @oclif/test 为基于 oclif 的 CLI 工具编写单元测试。
背景
实习入职以来第一个遇到的比较有意思的问题:如何为 CLI 添加单元测试?在此之前,不仅对于 Node 如何实现 CLI 一窍不通,对单元测试也是一窍不通😥。需要添加单测的 CLI 工具基于 oclif,这是一个非常简便好用、能快速上手的 CLI 开发框架,相比于历史悠久应用甚广的 commander.js ,它提供了更好的 multi command 支持,便于扩展的 Commander
类,内置的前处理后处理 hook,使得开发者能够专注于功能命令的开发。(当然,这并不代表 commander.js 就不能实现一样的优雅开发,事实上团队内部一个基于 commander.js 的 CLI 的架构封装的非常巧妙精致,各种 IoC 手到擒来,并实现了前处理后处理和命令基类,让人受益匪浅。不过本文的探索过程与 oclif 框架有一定关系,因此先在此处说明。)在官方文档中,它推荐使用 @oclif/test 进行单测,但在使用的过程中,我依然遇到了一些问题。本文将记录从零探索的过程,并且给出我的解决方案。
尝试
@oclif/test
既然是官方文档推荐,就不能不体验一下。文档中给出的例子非常简单:
1 | // 来源于文档 |
这个例子使用 nock
(nock,一个用来模拟 http 请求的包)模拟发送一个 http 请求,然后 mock 标准输出stdout
,再执行真正的命令 auth:whoami
,最后从ctx
中获取stdout
进行断言。这里引入的test
和 expect
是封装的 oclif/fancy-test,而它又是基于 Mocha,简言之就是一个能更少写 setup/teardown 的链式调用单测库,expect
使用的是 Chai 语法。
看到如此简单的示例,我不禁满头问号,其主要依赖的 mock 方式是直接代理 http 请求,这固然是非常符合直觉的,因为每一个命令中确实都需要发送 http 请求,然而发送 http 请求有时可能也是一件山路十八弯的事,简要列举一些问题:
直接 mock http 请求要求给出详细的路径,比如
https://exmaple.com/api/User
和get、post等方法,然后模拟返回值,非常死板对于封装了多层的 api 调用(例如 api 调用可能需要经过各种签名,还可能是通过 sdk 调用,最终暴露出来的已经是不知道转发了多少层的小小的接口),如果直接这样写单测的话,会很难找到具体需要调用哪个接口。且各个请求的路径可能完全一样,操作通过 Body / Header 中的某些字段区分,这就导致很难精准 mock
无法做到连续 mock 多个 http 调用,这意味着下面的情况😢:
1
2
3
4
5
6test
.nock("https://example.com", api => api
.post('/api/User')
//...
)
// 这里不能再接`.nock`了
当然,它也并非一无是处。使用.stdout()
来 mock 输出还是非常方便的。另外仔细地翻看各个仓库文档,可以发现它可以通过插桩代理支持 mock 用户输入(stdin)的,例如使用 cli-ux
获取输入时,可以这么写:
1 | test |
简单来说,它缺失了最重要的能力:函数或模块级别的 mock ,而非单纯代理 http 请求。
jest
Jest 是一个非常流行的测试框架,并且它提供了优秀的函数&模块级别的 mock 能力,这恰好就是我们所需要的。利用它的代理函数和模块能力,可以像这样来模拟 api 的调用:
1 | // 这也是那常见的一长串 {__esModule: true, ...originalModule, ...} 的简洁写法 |
上面的这段代码就可以代理../path/to/api
这个模块中的functionNeedToMock
和anotherFunctionNeedToMock
两个函数,而不修改其它的函数。
另外一个问题是如果需要代理的是位于Command
类之内的函数,例如在下面这个GetShoppingCartStatus
类中:
1 | // 修改自文档 |
我们需要代理的是checkLoginStatus
这个函数,那么我们就需要使用spyOn
来“监听”GetShoppingCartStatus
这个类的原型,示例:
1 | const checkLoginStatus = jest |
spyOn
是一个非常强大的功能,它在 CLI 工具的单测中有一个更为重要的作用,那就是“监听”stdout
。可以像这样来获取stdout
的输出结果:
1 | let stdout; |
当需要断言的时候,就使用stdout
就可以了。
总结
现在,我们已经明白了两种框架的优劣:
@oclif/test
直接代理 http 请求的方式有很大的局限性,但它链式的调用方式使得模拟用户输入和监听输出很方便;jest
能够进行函数&模块级别的 mock,但用它捕获stdout
、模拟处理用户输入却显得繁琐。
各取长处,对于这样一个命令:
1 | // 假设其命令为 run:upload |
我们可以这样利用这两个框架:
1 | // uploadFiles.test.js |
在这段代码中,我们既利用了 jest 模拟函数和模块的功能,也使用了 @oclif/test 简洁地处理输入输出,融合了两种框架的优点,我们就可以顺畅地进行单测编写了。
补充
在探索的过程中,我发现关于给 Node 开发的 CLI 添加单测的资料少得可怜,这不能不说是一种遗憾。此外,我还走了很大一圈弯路,包括因为项目本身 ts 版本导致的 ts-jest 无法使用,使用各种奇怪的 stdin
方法,用奇怪的方式试图直接调用 API 等等。由于缺乏经验,在编写单测的过程中,我发现很难把握 mock 的粒度,例如一个内部用到5个函数的命令,我甚至需要 mock 掉4个,导致大部分代码都没有被执行到,这或许意味着命令级别的单测要建立在 api 正确的基础之上。当然它反映的问题更在于,我不知道单测应该测什么,或许在不知不觉间,我已经在测试实现细节了。此外,CLI 几乎是不可避免地会用到一些 fs 方法,在 mock 某些方法的时候,我发现整个工具直接 crash 了,导致只能 mock 一个很大粒度的函数。这可能是在编码的时候就需要注意的问题。最后,或许更应当关注的是哪些函数被调用了,而直接断言CLIstdout
可能根本就不是一个好的选择,因为各种成功/失败/出错
根本就不是有规则的、不能变动的输出啊😲!
Reference
David Díaz | Testing OCLIF apps with Jest (martianwabbit.com)
Unit testing node CLI apps with Jest | by Jon Short | Medium
- 本文标题:如何为 Node.js 命令行工具添加单元测试
- 本文作者:WoodenStone
- 创建时间:2022-06-05 21:00:03
- 本文链接:https://woodenstone.github.io/Projects/how-to-add-unit-test-for-nodejs-cli-tools/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!