如何测试一个命令行工具

2,928 阅读10分钟

Frame 2

本文着重讲一些前端测试的进阶技巧,以及通过大量 demo 代码展示如何对较为复杂的命令行工具进行集成测试

之前发过一篇同名文章,但是认为做工太粗糙,本文为重新思考,排版后发布的版本

为什么需要测试用例

优点

1. 代码质量保障 & 增加信任

放眼整个 github,一个成熟的工具库必须具备

  • 完善的测试用例(jest/mocha...)
  • 友好的文档系统(官网/demo)
  • 类型声明文件 d.ts
  • 持续集成环境(git action/circleci...)

没有上述这些要素,用户在使用时候可能会遇到各种 BUG,很显然他们肯定不愿意看到这种情况发生,最终导致用户很难接受你的产品

测试用例最重要的一点就是提升代码质量,使得他人有信心使用你开发的工具(由信心产生的信任关系,对软件工程师来说是至关重要的)

此外,测试用例可以直接视为现成的调试环境,在编写测试用例时,会逐渐弥补在需求分析环节未曾想到的 case

2. 重构的保障

代码需要大版本的更新时,拥有完善的测试用例能够在重构时起到至关重要的作用

选择黑盒测试的测试用例设计方法,只关心输入和输出,无需关心测试用例内部做了什么

黑盒测试- 软件测试教程™

对重构而言,如果最终向用户暴露的 api 没有改变,那么几乎也不需要任何改动,直接复用之前的测试用例

因此如果代码具有完善的测试用例,就能很大程度增强重构的信心,无需关心由于代码改动导致原有功能无法使用的问题

3. 增加代码阅读性

对于想要了解项目源码的开发者,阅读测试用例是一个高效的办法

测试用例能非常直观的展现出工具的功能,以及各种 case 情况下的行为

换一句话说,测试用例是给软件开发者看的“文档”

// Vuejs test cases
// https://github.com/vuejs/core/blob/main/packages/reactivity/__tests__/computed.spec.ts#L24
it('should compute lazily', () => {
  const value = reactive<{ foo?: number }>({})
  const getter = jest.fn(() => value.foo)
  const cValue = computed(getter)

  // lazy
  expect(getter).not.toHaveBeenCalled()

  expect(cValue.value).toBe(undefined)
  expect(getter).toHaveBeenCalledTimes(1)

  // should not compute again
  cValue.value
  expect(getter).toHaveBeenCalledTimes(1)

  // should not compute until needed
  value.foo = 1
  expect(getter).toHaveBeenCalledTimes(1)

  // now it should compute
  expect(cValue.value).toBe(1)
  expect(getter).toHaveBeenCalledTimes(2)

  // should not compute again
  cValue.value
  expect(getter).toHaveBeenCalledTimes(2)
})

缺点

凡事都有两面,说了优势,接着聊下缺点,帮助大家更好的判断项目是否应该编写测试用例

没有时间

开发者普遍不写测试用例,最常见的一点就是太过于繁琐

我写测试用例的时间,代码早就写完了,你说测试?交给 QA 吧,那是他们的职责

这种情况其实完全可以理解,平时开发时间都来不及,怎么可能腾出时间写测试用例?

所以需要根据项目的类型,判断编写测试用例的价值

对于 UI 修改频繁、生命周期短的项目,例如官网,活动页,个人不推荐编写测试用例

因为它们普遍具有时效性,页面结构的频繁变动会直接导致测试用例的频繁变动,另外这类项目普遍配备 QA 资源,一定程度上能够保证项目质量(自测还是必要的)

反之,对于工具库、组件库,由于功能变化少,并且一般没有 QA 资源,如果已经存在一定的用户规模,推荐补充测试用例

不会写

编写测试用例需要学习测试框架的语法,因此需要一定学习成本(没有时间学也是导致不会写的诱因)

好在市面上主流的测试框架大同小异,整体思想趋于一致,同时本身 breaking change 也不多。普通开发者可以一周上手,二周进阶。学习之后能够在任何前端项目中使用(learning once, write everywhere

与 Vue 和 React 的学习成本相比,再结合前面的优势来看,是不是一个非常划算的交易呢?

说完优缺点,接着分享一下对一个复杂命令行工具编写测试用例的一些经验

对命令行工具进行集成测试

我采取集成测试的方式进行测试,集成测试与单元测试的区别在于,前者更广,后者粒度更细,集成测试也可以由多个单元测试组合而成

既然是命令行工具,首先需要思考的是,模拟出用户使用命令行的行为

命令行运行

一开始我对测试用例的理解是,尽可能模拟用户原始的输入。因此我的思路是直接在测试用例中运行命令行工具

// cli.js
const { program } = require("commander")
program.command('custom').action(() => {
  // do something for test
})
program.parse(argv)
// index.spec.js
const execa = require("execa")
const cli = (argv = "") => new Promise((resolve, reject) => {
  const subprocess = execa.command(`node ./cli.js ${argv}`)
  subprocess.stdout.pipe(process.stdout)
  subprocess.stderr.pipe(process.stderr)
  Promise.resolve(subprocess).then(resolve)
})
  
test('main', async() => {
    await cli(`custom`)
    expect('something')
})

在子进程运行命令行工具,然后将子进程的输出打印到父进程中,最后判断打印结果是否符合预期

优点:更加符合用户使用命令行的方式

缺点:需要调试测试用例时,由于依赖子进程,导致 debugger 开启时性能极差,测试用例运行经常超时,甚至吞掉错误或者输出一些与测试用例本身无关的系统报错

函数运行

上个方案卡顿过于严重,被迫思考其他的解决方案

由于我采用 commander 实现 nodejs 的命令行工具,所以测试用例本质上只要让命令背后的 action 执行就可以了

image-20220301003350971

commander 文档中提到,调用 parse 方法传入命令行参数,就可以触发 aciton 的回调

因此我们暴露一个名为 bootstrap 的启动函数,接收命令行参数并传入 parse 中

// cli.js
const { program } = require("commander")
const bootstrap = (argv = process.argv) => {
  program.command('custom').action(() => {
    // do something for test
  })
  program.parse(argv)
  return program
}
export { bootstrap }
// index.spec.js
const { bootstrap } = require('./cli.js')
test('main', () => {
    const program = bootstrap(['node', './cli.js', 'custom'])
    expect(program.commands.map(i => i._name)).toEqual(['custom']) // pass
    expect('something')
})
  
test('main2', () => {
    const program = bootstrap(['node', './cli.js', 'custom'])
    expect(program.commands.map(i => i._name)).toEqual(['custom']) // fail, received ['custom', 'custom']
    expect('something')
})

优点:不依赖子进程,直接在当前进程运行测试用例,debugger 也没有问题,成功解决了性能瓶颈

缺点:代码存在副作用,所有测试用例共享同一个 program 实例,测试用例单独使用没有问题,但多个测试用例之间可能会互相干扰

工厂函数运行

吸取了上次的教训,直接暴露一个生成命令行工具的工厂函数

// cli.js
const { Command } = require("commander")
const createProgram = () => {
  const program = new Command()
  program.command('custom').action(() => {
    // do something for test
  })
  return program
}
export { createProgram }
// index.spec.js
const { createProgram } = require('./cli.js')
test('main', () => {
    const program = createProgram()
    program.parse(['node', './cli.js', 'custom'])
    expect(program.commands.map(i => i._name)).toEqual(['custom']) // pass
    expect('something')
}

test('main2', () => {
    const program = createProgram()
    program.parse(['node', './cli.js', 'custom'])
    expect(program.commands.map(i => i._name)).toEqual(['custom']) // pass
    expect('something')
}

这样每次运行测试用例,创建的都是独立的 program ,使得测试用例之间彼此隔离

解决了命令行工具的初始化后,接着来看几个针对命令行的特殊测试用例 case

测试帮助命令

当需要测试帮助命令( --help、-h ),或者对命令行参数的验证功能做测试时,由于 commander 会将提示文案作为错误日志输出到进程,并调用 process.exit 退出当前进程

这会使得测试用例提前退出,因此需要重写这个行为

commander 内部提供了重写的函数 exitOverride,使用后会抛出一个 js 错误替代原先进程的退出

// https://github.com/tj/commander.js/blob/master/tests/command.help.test.js
test('when specify --help then exit', () => {
  // Optional. Suppress normal output to keep test output clean.
  const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => { });
  const program = new commander.Command();
  program.exitOverride();
  expect(() => {
    program.parse(['node', 'test', '--help']);
  }).toThrow('(outputHelp)');
  writeSpy.mockClear();
});

重写退出的行为后,若要验证帮助命令的文案,还需要使用 commander 提供的 configureOutput

image-20211208155456802

接着修改测试用例

// index.spec.js
const { createProgram } = require('./cli.js')
test('help option', () => {
    const program = createProgram()
    // overwrite exit
    program.exitOverride().configureOutput({
      writeOut(str) {
        // / assert the message of the help command
        expect(str).toEqual(`Usage: index [options] [command]
Options:
  -V, --version                output the version number
  -h, --help                   display help for command
Commands:
  custom
`)
      },
    })
    expect(() => {
      program.parse(['node', './cli.js', '-h'])
    }).toThrow('(outputHelp)'); // assert the behavior of the help command
  })

测试异步用例

命令行工具可能存在异步的回调,测试用例也需要支持异步的 case

好在 Jest 对异步测试用例开箱即用,还是以帮助命令为例

// index.spec.js
+ test('help option', async () => {
    const program = createProgram()
    program.exitOverride().configureOutput({
      writeOut(str) {
        expect(str).toEqual(`Usage: index [options] [command]
Options:
  -V, --version                output the version number
  -h, --help                   display help for command
Commands:
  custom
`)
      },
    })
+   try {
+     // use 'parseAsync' for async callback hook
+     await program.parseAsync(['node', './cli.js', '-h'])
+   } catch (e) {
+     // According to code
+     // distinguish whether it is an error of the help command itself or other code errors
+     if (e.code) {
+       expect(e.code).toBe('commander.helpDisplayed')
+     } else {
+       throw e
+     }
+   }
  })

对于异步测试用例,推荐设置超时时间,防止因为代码编写错误,导致一直等待测试结果

Jest 默认超时时间为 5000ms,也可以通过配置文件/测试用例重写

jest.setTimeout(10000); // 10 second
test('main', async () => {
  await sleep(2000) // fail, timeout
  expect('something')
});
// jest.config.js
module.exports = {
  testTimeout: 10000
}

除了超时时间,添加断言的次数也是保证异步测试用例成功的一点

image-20211208160957212

expect.assertions 可以指定单个测试用例触发断言的次数,这对测试异常捕获的场景很有帮助

超时和预期次数不符都会让测试用例失败

// index.spec.js
+ jest.setTimeout(10000); // set timeout
  test('help option', async () => {
+   // Expect the assertion to fire twice,
+   // So an incorrect number of triggers/timeouts will cause the test case to fail
+   expect.assertions(2)
    const program = createProgram()
    program.exitOverride().configureOutput({
      writeOut(str) {
+     	// first assertion
        expect(str).toEqual(`Usage: index [options] [command]
Options:
  -V, --version                output the version number
  -h, --help                   display help for command
Commands:
  custom
`)
      },
    })
    try {
      // use 'parseAsync' for async callback hook
      await program.parseAsync(['node', './cli.js', '-h'])
    } catch (e) {
      if (e.code) {
+     	// second assertion
        expect(e.code).toBe('commander.helpDisplayed')
      } else {
        throw e
      }
    }
  })

测试运行中的变量

普通的测试场景里,验证变量的值可以借助运行导出函数的返回值来验证

// index.js
exports.drinkAll = function drinkAll(callback, flavour) {
  if (flavour !== 'octopus') {
    callback(flavour);
  }
}

// index.spec.js
const { drinkAll } = require("./index.js")
describe('drinkAll', () => {
  test('drinks something lemon-flavoured', () => {
    const drink = jest.fn();
    drinkAll(drink, 'lemon');
    expect(drink).toHaveBeenCalled();
  });

  test('does not drink something octopus-flavoured', () => {
    const drink = jest.fn();
    drinkAll(drink, 'octopus');
    expect(drink).not.toHaveBeenCalled();
  });
});

但命令行工具可能会依赖上下文的信息(arguments, options),不太适合将内部的各个函数拆解并导出,那么如何测试运行期间变量的值?

我使用了 debug + jest.doMock + toHaveBeenCalled

// cli.js
const debug = require("debug")('cli')
const { Command } = require("commander");

const createProgram = () => {
  const program = new Command()
  program.command('custom <arg1>').action((arg1) => {
    debug(arg1)
    // ...
  })
  return program
}
export { createProgram }
// index.spec.js
test('main', () => {
  const f = jest.fn();
  // mock debug module
  jest.doMock("debug", () => () => f); 
  // require createProgram after debug have been mocked 
  const createProgram = require("./index");
  const program = createProgram();
  program.parse(["node", "cli.js", "custom", "foo"]);
  expect(f).toHaveBeenCalledWith("foo"); // pass
}
  1. 使用 debug 模块打印需要被验证的参数(具有一定代码侵入性,但 debug 模块也可以用于日志的记录)

  2. 测试用例运行时通过 jest.doMock 劫持 debug 模块,使得 debug 执行时返回 jest.fn

  3. toHaveBeenCalled 对 jest.fn 进行入参的验证

为什么使用 jest.doMock 而不是 jest.mock ?

jest.mock 在运行期间会声明提升,导致无法使用外部的变量 f github.com/facebook/je…

模拟命令行交互

带有命令行交互的命令行工具是非常常见的场景

image-20220302155202752

vue-cli 的启发,在测试用例中,模拟用户输入变得非常简单,且没有任何的代码侵入性

  1. 创建 __mock__/inquirer.js,劫持并代理 prompt 模块,在重新实现的 prompt 函数中添加 Jest 的断言语句
// __mocks__/inquirer.js
// inspired by vue-cli
// https://gist.github.com/yyx990803/f61f347b6892078c40a9e8e77b9bd984
let pendingAssertions

// create data
exports.expectPrompts = assertions => {
  pendingAssertions = assertions
}

exports.prompt = prompts => {
  // throw an error if there is no data
  if (!pendingAssertions) {
    throw new Error(`inquirer was mocked and used without pending assertions: ${prompts}`)
  }
  const answers = {}
  let skipped = 0
  prompts.forEach((prompt, i) => {
    if (prompt.when && !prompt.when(answers)) {
      skipped++
      return
    }
    const setValue = val => {
      if (prompt.validate) {
        const res = prompt.validate(val)
        if (res !== true) {
          throw new Error(`validation failed for prompt: ${prompt}`)
        }
      }
      answers[prompt.name] = prompt.filter
        ? prompt.filter(val)
        : val
    }
    const a = pendingAssertions[i - skipped]
    if (a.message) {
      const message = typeof prompt.message === 'function'
        ? prompt.message(answers)
        : prompt.message
      // consume data
      expect(message).toContain(a.message)
    }
    if (a.choices) {
      // consume data
      expect(prompt.choices.length).toBe(a.choices.length)
      a.choices.forEach((c, i) => {
        const expected = a.choices[i]
        if (expected) {
          expect(prompt.choices[i].name).toContain(expected)
        }
      })
    }
    if (a.input != null) {
      // consume data
      expect(prompt.type).toBe('input')
      setValue(a.input)
    }
  })
  // consume data
  expect(prompts.length).toBe(pendingAssertions.length + skipped)
  pendingAssertions = null
  return Promise.resolve(answers)
}
  1. 在运行测试用例前,通过 expectPrompts 模拟用户遇到的问题以及答案(创建断言的条件)
// If we want to mock Node's core modules (e.g.: fs or path),
// then explicitly calling e.g. jest.mock('path') is required
// else it is not required
// jest.mock('inquirer')
const { expectPrompts } = require('inquirer')
const { createProgram } = require('./cli.js')
test('migrate command', () => {
    // create user input data
    expectPrompts([
      {
        message: 'select project',
        choices: [ 'project1', 'project2', 'project3', 'sub-root' ],
        choose: 1,
      },
    ])
    const program = createProgram()
    // when inquirer.prompt is triggerd
    // it will consumes user input data from __mocks__/inquirer.js
    program.parse(['node', './cli.js', 'custom'])
})
  1. 当代码运行 inquirer.prompt 时,代理并跳转到 __mock__/inquirer.js 自定义的 prompt,prompt 会根据先前 expectPrompts 创建好的问题和答案依次进行匹配(消费数据)
  2. 最后代理的 prompt 会返回与真实 prompt 相同的 answers 对象,使最终的行为趋于一致

小结

确保编写测试用例时,彼此互相独立,互不影响,没有副作用,拥有幂等性

可以从以下角度出发

  • 每次运行测试用例,创建新的 commander 实例

  • 允许单个测试用例使用单例模式,不允许多个测试用例使用同一个单例

  • 文件系统隔离

其他测试技巧

模拟工作目录

jest.spyOn(process, 'cwd').mockImplementation(() => mockPath))

jest.spyOn 跟踪对 process.cwd 的调用, jest.mockImplementation 重写 process.cwd 的行为,最终达到模拟工作目录的目的

// index.spec.js
const { createProgram } = require('./cli.js')
test('mock current work directory', () => {
  // when process.cwd() have been called
  // return mockPath
  const cwdSpy = jest.spyOn(process, 'cwd').mockImplementation(() => mockPath)) 
  const program = createProgram()
  program.parse(['node', './cli.js', 'custom'])
  expect(cwdSpy).toHaveBeenCalledTimes(2) // assets process.cwd() to have been called twice
});

不依赖 Jest api 的话,也可以将模拟工作目录作为 createProgram 工厂函数的参数传入

// cli.js
const { Command } = require("commander");
const createProgram = (cwd = process.cwd()) => {
  const program = new Command()
  // use cwd instead of process.cwd
  program.command('custom').action(() => console.log(cwd))
}
// index.spec.js
const { createProgram } = require('./cli.js')
test('mock current work directory', () => {
  const program = createProgram(mockPath)
  program.parse(['node', './cli.js', 'custom'])
  expect('something')
});

模拟文件系统

文件的读写也是含有副作用的操作,由于由于命令行工具可能涉及到文件修改,所以不能保证每次运行测试用例都是一个干净的环境。为了保证测试用例互相独立,需要模拟一个真实的文件系统。

这里选择 memory-fs,它可以将真实文件系统的操作,转为在内存中操作虚拟文件

image-20211208164044849

项目根目录新建 __mocks__ 文件夹

在 __mocks__ 文件夹下添加 fs.js,导出 memfs 模块

// __mocks__/fs.js
const { fs } = require('memfs')
module.exports = fs

Jest 默认将 __mocks__ 文件夹下的文件视为可以被模拟的模块

在测试用例中运行 jest.mock(fs),就可以劫持 fs 模块并代理到 __mocks__/fs.js 下

image-20211208163458872

通过 将 fs 代理为 memfs,解决了文件系统副作用的问题

静默错误日志

// index.spec.js
const { createProgram } = require('./cli.js')
test('mock current work directory', () => {
  const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation()
  const program = createProgram() 
  program.parse(['node', './cli.js', 'custom'])
  // silence when error happened
  expect(stderrSpy).toBeCalledWith('something error') 
});

jest.mockImplementation 不传任何参数,会静默处理 mock 后的函数

实现运行测试期间不输出错误日志的功能,使得测试用例运行时更干净

不输出错误日志并非吞掉错误,依然可以用 try/catch 验证错误的场景

// index.spec.js
const { createProgram } = require('./cli.js')
test('unknown command', () => {
  // overwrite standard error
  jest.spyOn(process.stderr, 'write').mockImplementation()
  try {
    const program = createProgram()
    program.parse(['node', './cli.js', 'unknown'])
  } catch (e) {
    expect(e.message).toEqual("error: unknown command 'unknown'")
  }
})

也可以用前面提到的 program.configureOutput 重写错误日志

// index.spec.js
const { createProgram } = require('./cli.js')
test('unknown command', () => {
  jest.spyOn(process.stderr, 'write').mockImplementation()
  try {
    const program = createProgram()
    // overwrite exit
    program.exitOverride().configureOutput({
      // overwrite commander error 
    	 writeErr: str => {},
    })
    program.parse(['node', './cli.js', 'unknown'])
  } catch (e) {
    expect(e.message).toEqual("error: unknown command 'unknown'")
  }
})

使用前:

image-20211211152843829

使用后:

image-20211211152906471

匹配部分字段

如果测试的对象里有很多属性,无法一一对其枚举验证,该怎么解决?

Jest 内置了部分匹配的能力,使用 expect.objectContaining 可以创建一个部分匹配的对象

Jest 只会匹配该对象中声明的属性,对于未声明的属性,统一视为通过

另外,在expect.objectContaining创建的对象中,允许使用 expect.any 验证对象属性的类型,适用于测试属性的类型固定,但不清楚具体属性值的场景

debug结合对运行时的变量进行测试

// cli.js
const debug = require("debug")('cli')
const { Command } = require("commander");
const createProgram = () => {
  const program = new Command()
  program.command('custom').action((arg1) => {
    const obj = {
      a: arg1,
      b: Date.now(),
      c: "l don't care",
      d: "l don't care",
      // ...
    }
    debug(obj)
    // ...
  })
  return program
}
export { createProgram }
// index.spec.js
test('main', () => {
  const f = jest.fn();
  // mock debug module
  jest.doMock("debug", () => () => f); 
  // require createProgram after debug have been mocked 
  const { createProgram } = require("./cli.js");
  const program = createProgram();
  program.parse(["node", "cli.js", "custom", "foo"]);
  expect(f).toHaveBeenCalledWith(
    expect.objectContaining({
        a: 'foo',
        // I don't know what b is
        // but it must be number
        b: expect.any(Number), 
      })
  ); // pass
}

除了expect.objectContaining,还有其他与部分匹配相关 API

生命周期钩子

Jest 提供以下几个钩子

  • beforeAll
  • beforeEach
  • afterEach
  • afterAll

生命周期钩子会在每次/全部测试用例之前/后触发,通过在钩子中添加公共代码,能够一定程度减少代码量

例如通过 beforeEach 钩子,在每个测试用例运行前 mock 代码,结束后,通过 jest.retoreAllMock 还原

// index.spec.js
describe('main', () => {
  // mock debug in every test case
  beforeEach(() => jest.doMock("debug", () => () => f))
  // remove mock for debug
  afterEach(jest.restoreAllMocks)
  
  test('main', () => {
    const { createProgram } = require("./cli.js");
    const program = createProgram();
    program.parse(["node", "cli.js", "custom", "foo"]);
    expect(f).toHaveBeenCalledWith("foo"); // pass
  })
  
   test('main2', () => {
    const { createProgram } = require("./cli.js");
    const program = createProgram();
    program.parse(["node", "cli.js", "custom", "bar"]);
    expect(f).toHaveBeenCalledWith("bar"); // pass
  })
})

Typescript 支持

为测试用例增加 Typescript 支持,可以获得更强的类型提示,并允许在运行测试用例前,对代码类型进行前置检查

截至目前,jest@29 对运行原生 ESM 模块的支持还是实验性

不用转译工具测试 ESM 模块参考文档 kulshekhar.github.io/ts-jest/doc…

  1. 添加 ts-jesttypescript@types/jest 类型声明文件
npm i ts-jest typescript @types/jest -D
  1. 添加 tsconfig.json 文件,并将之前安装的 @types/jest 添加到声明文件列表
{
  "compilerOptions": {
    "types": [ "jest" ],
  }
}
  1. 修改测试用例文件名后缀 index.spec.js - > index.spec.ts,并将 commonJS 引入修改为 ESM

测试覆盖率

可视化的方式展现测试用例运行、未运行的代码、行数

image-20211208121715768

image-20220302153710257

在测试命令后添加 coverage 参数

jest --coverage

运行后生成 coverage 的文件夹,包含测试覆盖率的报告

image-20211208122235810

另外测试覆盖率可以与 CI/CD 平台集成,每次发布工具后,同时生成测试覆盖率报告,并上传至 CDN

达到每次工具发版时,统计测试覆盖率的增长趋势

总结

编写测试用例是一个前期投入时间比较高(学习测试用例语法),后期收益也很高(持续保障代码质量,提高重构信心)的方式

适用于改动比较少,QA 资源比较少的产品,例如命令行工具,工具库

写测试用例的一个小技巧是,参考对应工具的 github 上的测试用例,往往官方的测试用例更齐全

对命令行工具进行集成测试,需要保证测试用例互相隔离,互不影响,保证幂等性

暴露一个创建 commander 实例的工厂函数,每次运行测试用例时创建一个全新的实例

使用 Jest 内置的 api,例如 jest.spyOn , mockImplementation, jest.doMock,对 npm 模块或者内置函数进行代理,对代码侵入性较小

参考资料

博客:

zhuanlan.zhihu.com/p/55960017

juejin.cn/post/684490…

What is the best way to unit test a commander cli?

itnext.io/testing-wit…

stackoverflow:

stackoverflow.com/questions/5…

github:

github.com/shadowspawn…

github.com/tj/commande…