命令行工具集成测试实践

template (1).png

关于测试用例编写的的入门技巧和 api 解释,网上有很多文章都有解析,本文不再赘述

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

测试用例

优势

代码质量保障 & 增加信任

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

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

没有上述这些要素,用户很难接受你的产品。因为这代表作为使用方可能需要给工具踩坑,很显然他们肯定不愿意看到这种情况发生

作为测试用例,最重要的一点就是提升代码质量,使得他人有信心使用你开发的工具

(由信心产生的信任关系,对软件工程师来说至关重要)

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

重构的保障

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

可以选择黑盒测试的测试用例设计方法,只关心输入和输出,也不关心具体内部做了什么

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

扫描二维码关注公众号,回复: 13559555 查看本文章

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

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

增加代码阅读性

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

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

或者说测试用例是给软件开发者看的“文档”

image-20211205224155913

缺点

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

没有时间

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

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

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

所以我们需要对项目的类型做区分

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

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

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

不会写

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

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

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

说完优缺点,接着分享一下对一个复杂命令行工具编写测试用例时,遇到的坑以及解决方案和思考

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

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

在编写测试用例前,需要先模拟出用户使用命令行工具的方式

命令行运行

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

// index.spec.js
const cli = (argv = "", cwd = monorepoPath) => new Promise((resolve, reject) => {
  const subprocess = execa.command(`node cli.js ${argv}`, { cwd })
  subprocess.stdout.pipe(process.stdout)
  subprocess.stderr.pipe(process.stderr)
  Promise.resolve(subprocess).then(resolve)
})
  
test('main', async() => {
    await cli(`custom`,mockDir)
    expect(...)
})
复制代码

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

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

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

函数运行

先初始化命令行工具,再暴露一个名为 bootstrap 的启动函数,为命令行工具动态添加 sub command

// cli/index.ts
const { Command } = require("inquirer")
const program = new Command()
const bootstrap = (argv = process.argv, cwd = process.cwd()) => {
  // ...
  program.parse(argv)
}
export { main }
复制代码
// index.spec.js
const { bootstrap } = require('cli/index.ts')
test('index', async () => {
    await bootstrap(['node','index.js','custom'], mockDir)
    expect(...)
})
复制代码

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

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

工厂函数运行

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

// cli/index.ts
const createProgram = async (argv = process.argv) => {
  const program = new Command()
	// ...  
  return program
}
export { createProgram }
复制代码
// index.spec.js
const { createProgram } = require('cli/index.ts')
test('index', async () => {
    await createProgram(['node','index.js','custom'])
    expect(...)
}
复制代码

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

解决了命令行工具的初始化后,接着来看一些测试用例的 case

测试 help

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

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

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

参考:github.com/tj/commande…

// 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();
});
复制代码

重写后,如果需要对帮助的文案进行测试,还需要使用 configureOutput ,这点官方文档有提到

image-20211208155456802

修改测试用例如下

// index.spec.js
test('help option', () => {
    const program = createProgram(['node', entryPath, '-h'])
    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:
  init|i [options] [template]  拉取模板初始化项目
`)
      },
    })
    expect(() => {
      program.parse(['node', entryPath, '-h'])
    }).toThrow('(outputHelp)');
  })
复制代码

异步测试用例

此外命令行工具可能会有异步回调,所以测试用例需要支持异步的 case

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

// index.spec.js
+ test('help option', async () => {
+   const program = await createProgram(['node', entryPath, '-h'])
    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:
  init|i [options] [template]  拉取模板初始化项目
`)
      },
    })
+   try {
+     // program.action 存在异步回调时,需要用 parseAsync 异步解析
+     await program.parseAsync(['node', entryPath, '-h'])
+   } catch (e) {
+     // 根据 e.code,区分是帮助命令自身的报错,还是其他代码错误
+     if (e.code) {
+       expect(e.code).toBe('commander.helpDisplayed')
+     } else {
+       throw e
+     }
+   }
  })
复制代码

对于异步测试用例,推荐设置超时时间,防止因为代码编写错误,导致进程休眠

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

jest.setTimeout(10000); // 10 second
test('will fail', () => {
  expect(true).toBe(false);
});
复制代码
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  testTimeout: 10000
}
复制代码

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

image-20211208160957212

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

超时和预期次数不符都会让测试用例失败(否则若代码执行成功,会提前退出测试用例,导致没有执行异常捕获的代码)

// index.spec.js
+ jest.setTimeout(10000);
  test('help option', async () => {
+		// 期望触发两次断言,这样触发次数不正确/超时都会导致测试用例失败 
+   expect.assertions(2)
    const program = await createProgram(['node', entryPath, '-h'])
    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:
  init|i [options] [template]  拉取模板初始化项目
`)
      },
    })
    try {
      // program.action 存在异步回调时,需要用 parseAsync 异步解析
      await program.parseAsync(['node', entryPath, '-h'])
    } catch (e) {
      // 根据 e.code,区分是帮助命令自身报错,还是其他代码错误
      if (e.code) {
        expect(e.code).toBe('commander.helpDisplayed')
      } else {
        throw e
      }
    }
  })
复制代码

环境隔离

这一点也是前面的小结

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

  • 每次创建新的 commander 实例

  • 没有单例模式

  • 文件系统隔离

其他技巧

mock 工作目录

jest.spyOn(process, 'cwd').mockImplementation(() => mockPath))
复制代码

也可以作为 createProgram 工厂函数的参数传入

// cli/index.ts
const createProgram = async (
  argv = process.argv,
  cwd = process.cwd()
) => {
  // 之后代码使用 cwd 变量替代 process.cwd
}

// index.spec.js
test('mock current work directory', () => {
  // jest.spyOn(process, 'cwd').mockImplementation(() => mockPath))
  createProgram(argv, mockPath)
});
复制代码

mock 日志

const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation()
//...
expect(stderrSpy).toBeCalledWith('something error')
// no error log
复制代码

jest.spyOn + mockImplementation 可以实现代理 + mock 函数

mockImplementation 不传任何参数,会静默处理 mock 后的函数,使测试用例更干净

例如测试命令行的异常处理,可以在完成断言的同时,不展示错误日志(也可以用前面提到的 program.configureOutput 重写错误日志)

   test('unknown command', async () => {
    jest.spyOn(process.stderr, 'write').mockImplementation()
    try {
      const program = await createTestProgram(['unknown'])
      program.exitOverride()
      // program.exitOverride().configureOutput({
     	// 	 writeErr: str => {},
    	// })
      await program.boot()
    } catch (e) {
      expect(e.message).toEqual("error: unknown command 'unknown'")
    }
  })
复制代码

使用前:

image-20211211152843829

使用后:

image-20211211152906471

mock 文件系统

文件系统是一个含有副作用的操作,由于文件可能被删除/修改,所以不能保证每次运行测试用例都是一个干净的环境

为了保证测试用例互相独立,需要模拟一个真实的文件系统,可以用 memory-fs。它支持在内存中操作文件,实现对真实文件的互相隔离

image-20211208164044849

npm i memfs -D
复制代码

项目根目录新建 __mock__ 文件夹

添加 __mock__/fs.js 文件,导出 memfs 模块

const { fs } = require('memfs')

module.exports = fs
复制代码

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

然后在测试用例中手动运行 jest.mock(fs),将测试用例中对 fs 的操作代理到 __mocks__/fs.js 下

image-20211208163458872

此时运行测试用例时,运行 fs 模块实际指向了 memfs,最终实现在没有修改源代码的情况下,完成了对 fs 模块的代理,解决了文件系统副作用的问题

mock 命令行交互

受到 vue-cli 的启发,在测试用例中,模拟用户输入变得非常简单,且代码侵入性为 0

  1. 创建 __mock__/inquirer.js,重写 prompt 模块,添加断言语句
const prompt = prompts => {
  const answers = {}
  let skipped = 0
  prompts.forEach((prompt, index) => {
    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[index - skipped]
    
   if (a.message) {
      const message = typeof prompt.message === 'function'
        ? prompt.message(answers)
        : prompt.message
      expect(message).toMatch(a.message)
    }

    const choices = typeof prompt.choices === 'function'
      ? prompt.choices(answers)
      : prompt.choices
    if (a.choices) {
      expect(choices.length).toBe(a.choices.length)
      a.choices.forEach((c, i) => {
        const expected = a.choices[i]
        if (expected) {
          expect(choices[i].name).toMatch(expected)
        }
      })
    }

    if (a.choose != null) {
      expect(prompt.type === 'list' || prompt.type === 'rawList').toBe(true)
      setValue(choices[a.choose].value)
    }
  })

  expect(prompts.length).toBe(pendingAssertions.length + skipped)
  pendingAssertions = null

  return Promise.resolve(answers)
}
复制代码
// 创建断言条件
const expectPrompts = assertions => {
  pendingAssertions = assertions
}
复制代码
  1. 在运行测试用例前,通过 expectPrompts 模拟用户输入(创建断言的条件)
// 代理 inquirer 到 __mocks__/inquirer.js
// __mocks__ 文件夹下若是非内建的 npm 模块,则会自动 mock,因此此处可以省略
// jest.mock('inquirer')

const { expectPrompts } = require('inquirer')
test('migrate command', async () => {
    expectPrompts([
      {
        message: '选择 cli 启动项目',
        choices:[ 'project1', 'project2', 'project3', 'sub-root' ],
        choose:1,
      },
    ])
    const program = await createProgram()
    // 代码里执行 inquirer.prompt 指向  __mocks__/inquirer.js 并消费数据
    await program.parseAsync([ 'node', entryPath, 'migrate' ])
})
复制代码
  1. 当代码运行 inquirer.prompt 时,代理并跳转到自定义 prompt,自定义 prompt 会根据之前 expectPrompts 预先设置好的期望问题和期望答案依次进行匹配(消费数据)
  2. 最后代理的 prompt 会返回真实 prompt 相同的 answers 对象,使代码继续运行

测试函数参数

Jest 内置的 toHaveBeenCalled 方法可以测试调用函数时的入参

function drinkAll(callback, flavour) {
  if (flavour !== 'octopus') {
    callback(flavour);
  }
}

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();
  });
});
复制代码

但官网的示例只适用于函数(drinkAll)可以被暴露出来的场景

对于命令行工具,很多函数都被集成在入口代码里,单独导出函数可能会导致缺失一些必要的上下文

对于集成测试来说,可以使用前面提到的 mock 功能,代理 console.log/console.debug,再对代理后的函数进行断言

// cli/index.ts
const createProgram = async (argv = process.argv) => {
  const program = new Command()
  const context = {
    program,
    a:1,
    b:2
  }
  // jest 运行时会将 NODE_ENV 设置为 test
  if(process.env.NODE_ENV === 'test'){
    console.debug(context)
  }
  return program
}
export { createProgram }
复制代码
// index.spec.js
const { createProgram } = require('cli/index.ts')
test('index', async () => {
    const debugSpy = jest.spyOn(console, 'debug').mockImplementation()
    await createProgram(['node','index.js','custom'])
    expect(debugSpy).toHaveBeenCalledWith({
      // 断言 program 的数据结构
      program,
      a:1,
      b:2
    })
}
复制代码

生命周期钩子

Jest 提供以下几个钩子,可以在每次、全部测试用例之前、后调用,一定程度减少代码量

  • beforeAll
  • beforeEach
  • afterEach
  • afterAll

例如通过 beforeEach 钩子,在每个测试用例运行前 mock 代码

结束后,通过 jest.retoreAllMock 还原

describe('index', () => {
  let debugSpy
  beforeEach(() => {
    debugSpy = jest.spyOn(console, 'debug').mockImplementation()
  })
  afterEach(jest.restoreAllMocks)

  test('test1', async () => {
    console.debug(123)
    expect(debugSpy).toHaveBeenCalledWith(123)
  })
})
复制代码

Typescript 支持

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

  1. 添加 ts-jest,typescript, @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

在测试命令后添加 coverage 参数

jest --coverage
复制代码

运行后生成 coverage 的文件夹,包含测试覆盖率的报告(静态资源)

image-20211208122235810

另外报告可以与 CI/CD 平台集成,在每次发布工具前,生成显示测试覆盖率报告的静态资源,并将其存储在 CDN

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

总结

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

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

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

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

jest 内置的模拟 npm、内建模块能力是一个比较推荐测试方式,对代码侵入性较小

参考资料

blog:

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…

猜你喜欢

转载自juejin.im/post/7041070141567664165