51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

手机号码,快捷登录

查看: 663|回复: 0
打印 上一主题 下一主题

[转贴] 如何为 Node.js 命令行工具添加单元测试

[复制链接]
  • TA的每日心情
    无聊
    4 天前
  • 签到天数: 1050 天

    连续签到: 1 天

    [LV.10]测试总司令

    跳转到指定楼层
    1#
    发表于 2022-8-1 11:00:31 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
    背景
      实习入职以来第一个遇到的比较有意思的问题:如何为 CLI 添加单元测试?在此之前,不仅对于 Node 如何实现 CLI 一窍不通,对单元测试也是一窍不通??。需要添加单测的 CLI 工具基于 oclif,这是一个非常简便好用、能快速上手的 CLI 开发框架,相比于历史悠久应用甚广的 commander.js ,它提供了更好的 multi command 支持,便于扩展的 Commander 类,内置的前处理后处理 hook,使得开发者能够专注于功能命令的开发。(当然,这并不代表 commander.js 就不能实现一样的优雅开发,事实上团队内部一个基于 commander.js 的 CLI 的架构封装的非常巧妙精致,各种 IoC 手到擒来,并实现了前处理后处理和命令基类,让人受益匪浅。不过本文的探索过程与 oclif 框架有一定关系,因此先在此处说明。)在官方文档中,它推荐使用 @oclif/test 进行单测,但在使用的过程中,我依然遇到了一些问题。本文将记录从零探索的过程,并且给出我的解决方案。
      尝试
      @oclif/test
      既然是官方文档推荐,就不能不体验一下。文档中给出的例子非常简单:
    1.  // 来源于文档
    2.   import {expect, test} from '@oclif/test'
    3.   describe('auth:whoami', () => {
    4.     test
    5.     .nock('https://api.heroku.com', api => api
    6.       .get('/account')
    7.       // user is logged in, return their name
    8.       .reply(200, {email: 'jeff@example.com'})
    9.     )
    10.     .stdout()
    11.     .command(['auth:whoami'])
    12.     .it('shows user email when logged in', ctx => {
    13.       expect(ctx.stdout).to.equal('jeff@example.com\n')
    14.     })
    15.   })
    复制代码
    这个例子使用 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. test
    2.       .nock("https://example.com", api => api
    3.       .post('/api/User')
    4.           //...
    5.       )
    6.   // 这里不能再接`.nock`了
    复制代码
    当然,它也并非一无是处。使用.stdout()来 mock 输出还是非常方便的。另外仔细地翻看各个仓库文档,可以发现它可以通过插桩代理支持 mock 用户输入(stdin)的,例如使用 cli-ux获取输入时,可以这么写:
    1. test
    2.       .stub(cli, "prompt", () => async () => mockUserName)
    3.   .stub(cli, "prompt", () => async () => mockPassword)
    4.       .stdout()
    5.       .command(["auth:login"])
    6.       .it("should login successfully", (ctx) => {
    7.         expect(ctx.stdout).toContain(`login successfully`);
    8.       });
    复制代码
    简单来说,它缺失了最重要的能力:函数或模块级别的 mock ,而非单纯代理 http 请求。
      jest
      Jest 是一个非常流行的测试框架,并且它提供了优秀的函数&模块级别的 mock 能力,这恰好就是我们所需要的。利用它的代理函数和模块能力,可以像这样来模拟 api 的调用:
      // 这也是那常见的一长串 {__esModule: true, ...originalModule, ...} 的简洁写法。
    1.  jest.mock("../path/to/api", () => ({
    2.     ...jest.requireActual("../path/to/api"),
    3.     functionNeedToMock: jest.fn().mockResolvedValue({
    4.       MockKey: 'mockValue'
    5.     }),
    6.     anotherFunctionNeedToMock: jest.fn().mockResolvedValue(true),
    7.   }));
    复制代码
    上面的这段代码就可以代理../path/to/api这个模块中的functionNeedToMock和anotherFunctionNeedToMock两个函数,而不修改其它的函数。
      另外一个问题是如果需要代理的是位于Command 类之内的函数,例如在下面这个GetShoppingCartStatus类中:
    1. // 修改自文档
    2.   import Command from '@oclif/command'
    3.   export class GetShoppingCartStatus extends Command {
    4.     // 需要代理的函数
    5.     async function checkLoginStatus() {/* ... */}
    6.      
    7.     async run() {
    8.       try {
    9.         await checkLoginStatus()
    10.         /* some other code */
    11.       } catch (err) {
    12.         if (err.statusCode === 401) {
    13.           this.error('not logged in', {exit: 100})
    14.         }
    15.         throw err
    16.       }
    17.     }
    18.   }
    复制代码
    我们需要代理的是checkLoginStatus这个函数,那么我们就需要使用spyOn来“监听”GetShoppingCartStatus这个类的原型,示例:
    1. const checkLoginStatus = jest
    2.     .spyOn(GetShoppingCartStatus.prototype, "checkLoginStatus")
    3.     .mockImplementation(() =>
    4.       {/* ... */}
    5.     );
    复制代码
    spyOn是一个非常强大的功能,它在 CLI 工具的单测中有一个更为重要的作用,那就是“监听”stdout。可以像这样来获取stdout的输出结果:
    1.  let stdout;
    2.   beforeEach(() => {
    3.     stdout = [];
    4.     jest
    5.       .spyOn(process.stdout, "write")
    6.       .mockImplementation((val) => stdout.push(val));
    7.   });
    8.   afterEach(() => jest.restoreAllMocks());
    复制代码
    当需要断言的时候,就使用stdout就可以了。
      总结
      现在,我们已经明白了两种框架的优劣:
      ·@oclif/test直接代理 http 请求的方式有很大的局限性,但它链式的调用方式使得模拟用户输入和监听输出很方便;
      · jest能够进行函数&模块级别的 mock,但用它捕获 stdout、模拟处理用户输入却显得繁琐。
      各取长处,对于这样一个命令:
    1. // 假设其命令为 run:upload
    2.   import Command from '@oclif/command'
    3.   import { cli } from 'cli-ux';
    4.   import { zipDir } from '../utils'
    5.   export class UploadFilesCommand extends Command {
    6.     // 需要代理的函数
    7.     async function checkLoginStatus() {/* ... */}
    8.     // 用户输入
    9.     static flags = {
    10.         path: flags.string({ char: 'p', description: '文件路径'})
    11.     }
    12.     async run() {
    13.       try {
    14.         await checkLoginStatus()
    15.         await cli.prompt('请输入文件路径', { required: true })
    16.         await zipDir(/* ... */)
    17.         /* some other code */
    18.       } catch (err) {
    19.         if (err.statusCode === 401) {
    20.           this.error('not logged in', {exit: 100})
    21.         }
    22.         throw err
    23.       }
    24.     }
    25.   }
    复制代码
    我们可以这样利用这两个框架:
    1.  // uploadFiles.test.js
    2.   import cli from "cli-ux";
    3.   import { test } from "@oclif/test";
    4.   // 代理 zipDir 函数
    5.   jest.mock("../path/to/utils", () => {
    6.     return {
    7.       zipDir: jest.fn().mockResolvedValue(true),
    8.     };
    9.   });
    10.   // 代理 checkLoginStatus 函数
    11.   const checkLoginStatus = jest
    12.     .spyOn(UploadFilesCommand.prototype, "checkLoginStatus")
    13.     .mockImplementation(() => {/* ... */});
    14.   // 测试
    15.   describe('run:upload', () => {
    16.     test
    17.       .stub(cli,'prompt', () => async () => 'a/b/c') // 对 cli 插桩,由于是链式调用,如果有多个用户输入,可以插桩多次
    18.       .stdout()
    19.       .command(['run:upload'])
    20.       .it('should upload files successfully', (ctx) => {
    21.         expect(ctx.stdout).toEqual(/* or other assets */);
    22.       });
    23.   });
    复制代码
    在这段代码中,我们既利用了 jest 模拟函数和模块的功能,也使用了 @oclif/test 简洁地处理输入输出,融合了两种框架的优点,我们就可以顺畅地进行单测编写了。





    分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
    收藏收藏
    回复

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-11-25 01:34 , Processed in 0.066070 second(s), 23 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表