如何高效、正确的为开源技术项目做单元测试?

发表于:2021-2-10 09:31

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:佚名    来源:知乎

  单元测试是指,对于软件中过的最小可测试单元在与程序其他部分相隔离的情况下进行检查和验证的工作,这里最小可测试单元通常指的是函数和类。单元测试是任何一个(内部)开源技术产品不可或缺的部分,但是并不是每个开发者都会做测试,他们可能更关注做出更好的功能,而不是帮助开发者用的更方便、更放心,因此如何高效、正确的做测试就显得非常有价值。本文从工具、方法两个部分,详细介绍技术产品测试方法,希望能对开源产品的维护者有所帮助。
  工欲善其事
  如果每次运行测试都要手动修改配置,每次都要等好久才能看到运行结果,这个测试开发体验会相当痛苦。软件工程大师 Martin Flower 在《测试驱动开发》一书中提到,测试要能频繁运行才能实现测试的价值,因此让测试流运行更加高效是开始测试前必须要做的事情。
  IDE/编辑器,吃饭的家伙一定要用最顺手的,对我而言 vscode 就是不二的选择,下面列举一些能够让测试提效N倍的插件 vscode 插件:
  prettier + eslint + 保存自动格式化 editor.formatOnSave: true,避免各种小问题,让代码更美。
  jest,这个插件能够提供保存时自动运行测试的能力,自动运行当前测试用例,在输出面板展示运行结果,同时在测试用例前展示测试通过状态标识,并将没通过的断言用红线标示出来,并展示期望值和实际值对比。
  更重要的一个功能是,在每个测试用例前添加快速 debug 入口,点击后直接展示 debug 页面,可以直接从当前窗口开始调试代码。
  有了这个插件,测试开发体验将会非常丝滑,让你爱上测试。但需要注意以下几个问题:
  1、配置好 tsconfig.json 以及 jest.config.js 两个文件,否则这个插件会不生效,具体不生效问题会在 vscode 输出面板中展示,一般常见的问题是路径配置不对。
  2、debug 时需要将 jest.config.js 中的 coverage 属性设置为 false。开启这个属性会在运行 jest 时收集代码覆盖率报告,而代码覆盖收集的原理是在运行时插入依赖收集脚本,这会导致代码不可调试。
  这三个插件就是单元测试开发提效的核心工具了,其他还有些辅助性的插件可以自行安装。
  常见单元测试误区
  第一个误区
  作为项目核心开发者,因为太过于了解功能,对每一个 if-else 分支和各种异常处理了如指掌,导致在设计测试用例时会写出 if-else 型的测试,每次遇到分支逻辑就会写一个测试用例,最后通过覆盖率报告,将没有运行到的代码单独写一个测试用例,这样的结果就是一旦代码重构,你依赖 if-else 写的测试会有大批挂掉,然后你需要对测试代码进行重构,陷入一个怪圈,最终放弃测试。
  第二个误区
  把实现 100% 测试覆盖率当作单元测试的目标。测试覆盖率能反应代码整体的测试覆盖程度,帮助你发现没有测到的逻辑,但是如果追求100%的测试覆盖率,你可能为了实现这个目标设计出一些坏的测试用例,这样的测试用例比 if-else 型测试用例更糟糕,它除了让覆盖率好看一点,没有任何用处。
  第三个误区
  只对输入参数设计测试用例。举个例子,有一个函数getTriangleType,它接受三个顶点数据,输出是三角形类型,如果只针对输入参数设计测试用例,你可能会设计如下测试用例:
  1、传入三个 null 值,预期返回 ‘None’,表示它不构成三角形。
  2、传入三个 [0, 0] 坐标,预期返回 'None',表示它不构成三角形。
  3、传入 [0, 0], [0, 1], [1, 0],预期返回 'IsoscelesRightTriangle',表示它构成等腰直角三角形。
  然后你觉得这个函数能够正确判断三角形类型了,就开始写下一个函数的测试用例了。其实不然,三角形的类型太多了,还有等边三角形、直角三角形、等腰三角形,所以这时候你需要对预期的输出设计输入数据,才能测试充分。
  单元测试用例设计方法
  测试方法受到环境环境、项目开发阶段、测试团队能力影响,在项目开发中有单元测试,系统集成时有集成测试,集成完毕后还会有系统测试。但是测试用例的设计方法如果要说几个最常用的,那一定是等价类划分、边界值分析和错误推测法。
  等价类划分
  等价类划分黑盒测试用例最基本的设计方法,在设计测试用例时,不考虑功能的内部实现,只依赖功能的输入输出去设计测试用例。所谓等价类就是所有输入数据的一个子集合,它的测试结果和全部输入数据的测试结果是等价的。等价类又分为有效等价类和无效等价类,有效等价类就是正确的输入,无效等价类就是其他任何可能的输入。对于一个函数它既能处理正确输入,也要处理异常输入。举个简单易懂的例子介绍设计等价类的三个步骤。
  如果我们要做一个学生成绩管理系统,考试的成绩满分100分,只有整数分数,60分及以上及格。在对这个系统进行测试的时候,我们可以按照下面的步骤设计测试用例。
  第一步,设计等价类
  有效等价类是:
  a. 0到100的一个整数数字
  无效等价类是:
  1.小于0负整数
  2.小于0的浮点数
  3.大于100的整数
  4.大于100的浮点数
  5.0到100之间的浮点数
  6.输入非数字字符
  第二步,设计测试用例
  在划分出有效等价类和无效等价类后,我们可以设计测试用例,编写测试用例时可以遵循以下规则:
  1、编写新的测试用例,尽可能多的覆盖尚未覆盖的有效等价类,直到所有有效等价类被覆盖。
  2、编写新的测试用例,每一条测试用例只覆盖一个无效等价类。
  基于这个规则,我们可以写出7条测试用例,测试用例如下:
  ·输入80,断言正常
  ·输入-1,断言失败
  ·输入-10.5,断言失败
  ·输入150,断言失败
  ·输入150.5,断言失败
  ·输入50.5,断言失败
  ·输入*#¥,断言失败
  在设计测试用例时,要遵循最小测试用例集原则,用尽量少的输入数据覆盖更大的输入数据集合,发现尽可能多的错误,这个能力是需要不断练习的。
  边界值分析
  在实际开发中,错误往往是发生在边界上,因此我们需要覆盖尽可能多的边界值。有效等价类的输入是0到100之间的整数,60分及格,因此我们可以增加下面几条测试用例用来覆盖边界值。
  ·输入-1,断言失败
  ·输入101,断言失败
  ·输入1,断言成功
  ·输入59,断言成功
  ·输入61,断言成功
  ·输入99,断言成功
  这其中有2条无效等价类,5条有效等价类,别忘了我们上面的最小测试用例集原则,添加边界值分析后的测试用例如下:
  ·输入1,输入59, 输入61, 输入99,断言正常
  ·输入-1,断言失败
  ·输入-10.5,断言失败
  ·输入101,断言失败
  ·输入150.5,断言失败
  ·输入50.5,断言失败
  ·输入*#¥,断言失败
  最终还是有7条测试用例,我将边界值1, 59,61, 99放到了一条测试用例,将原来“大于100的整数”输入105的这条测试用例,改为输入101,因为对于“大于100的整数”这条用例输入105和101是等价的。
  错误推测法
  错误推测法依赖过往的经验和对当前被测功能的理解程度来设计测试用例,也就是说这是一种比较依赖开发者直觉的方法。比如你曾为一个 PHP 服务端框架写过测试用例,知道在某些情况下会发生异常,这会对你当前正在做的 Node 服务端框架测试提供帮助。
  对于我们上面提到的学生成绩管理系统来说,如果用户不小心在输入成绩前加上了数字 0,是否能正常处理?因此我们可以再加两条测试用例。
  ·输入089,断言正常
  ·输入非0的任意一个字符加整数,断言失败
  如果系统能正常将‘089‘处理为‘89‘,在输入‘s89’时抛出错误,说明我们的方法容错率比较高,这样用户在使用起来也会更加方便。
  最终我们会得到8条测试用例:
  ·输入1,输入59, 输入61, 输入99,输入089,断言正常
  ·输入-1,断言失败
  ·输入-10.5,断言失败
  ·输入101,断言失败
  ·输入150.5,断言失败
  ·输入50.5,断言失败
  ·输入*#¥,断言失败
  ·输入s89,断言失败
  覆盖率报告
  在上面介绍 jest 插件时,我提到在开发测试代码时,要关掉 jest.config.js 中的 coverage,因为它会插入覆盖率收集代码导致无法 debug。但是在我们开发完一个模块的测试用例后就可以将 coverage 打开,看一下当前模块的测试覆盖率报告,它会告诉你这个模块的覆盖率是多少,更重要的是会告诉你有哪些代码是你没有测试到的,接下来你就可以针对这部分代码设计测试用例,如果你认为没有运行到的代码不需要设计测试用例,你也可以直接开始测试下一个模块。
  如果待测代码中有引入的第三方代码,并且这部分代码已经测试过,我们可以直接设置忽略这部分代码的覆盖率收集,具体可以在 jest.config.js 中设置 testPathIgnorePatterns 将第三方代码直接忽略掉。
  有些项目开发者会追求100%的测试覆盖率,这里就有一个投入产出比的问题,符合二八原则,达到80%的覆盖率只用20%的时间,追求剩下的20%的覆盖率用掉80%的时间,因此最好给项目定一个合理的测试覆盖率目标,比如在给eva.js指定覆盖率时我就定了90%的覆盖率。
  驱动代码、桩代码与 mock 代码
  驱动代码
  驱动代码指的是运行测试用例的代码,它包括调用前准备(setup)测试数据、调用(exercise)测试用例被测函数,验证结果(verify)三个步骤,这三个步骤一般都是由测试框架决定的,在本文中我们用的是 jest 框架,它主要有几个优点:
  支持多种项目,它支持使用了 babel, typescript, node, react, angular, vue 的项目,其中部分项目测试也用到了 jest。
  测试隔离,两个测试用例之间的状态不会影响,jest 默认是并行运行所有测试用例,这样能大大节省测试运行时间,可以让运行测试更频繁发生。
  零配置拆箱即用,对于 javaScript 项目可以做到不需要配置可以直接开始进行测试代码开发,如果是 typescript 项目则需要安装 ts-jest,如果是 babel 项目则会自动寻找 babel 配置,对于不同类型的前端项目 jest 都提供了详细的配置指南。下面是 typescript 项目中的 jest 配置,然后我们就可以用 TDD 的方式进行开发。
  module.exports = {
    preset: "ts-jest",
    verbose: true,
    transform: {
      "^.+\\.(ts|js)?$": "ts-jest",
    },
    watchPathIgnorePatterns: ["/node_modules/", "/.git/"],
    moduleFileExtensions: ["ts", "js", "json"],
    testMatch: [
      "<rootDir>/src/**/__tests__/**/*.spec.ts",
      "<rootDir>/src/**/*.spec.ts",
    ],
    testPathIgnorePatterns: ["/node_modules/"],
  };

  桩代码
  桩代码是一种补齐代码,通过桩代码直接返回结果,能让测试代码顺利执行,还可以通过控制桩代码的返回值来控制被测函数执行逻辑,在验证阶段验证桩代码的内部维护的状态。举个简单的例子,有一个订单系统,在库存不足时会发送邮件通知相关人员,发邮件是需要依赖网络的,我们不能真的把邮件发出去然后再验证,因此就需要一个桩代码来补齐发邮件的操作。
  import { Order, Warehouse, Message, MailService, TALISKER } from "./index";

  // 邮件服务桩代码
  class MailServiceStub implements MailService {
    private messages = new Array<Message>();
    public send(msg: Message) {
      this.messages.push(msg);
    }
    public numberSent(): number {
      return this.messages.length;
    }
  }

  describe("order", () => {
    let warehouse;
    beforeEach(() => {
      warehouse = new Warehouse();
    });

    it("test order send mail if unfilled", () => {
      const order = new Order(TALISKER, 51);
      const mailer = new MailServiceStub();
      order.setMailer(mailer); // 设置邮件服务桩代码,而不是真的将邮件发送出去。
      order.fill(warehouse);
      expect(order.isFilled()).toBeFalsy();
      expect(mailer.numberSent()).toBe(1);
    });
  });
  为了验证库存不足时会发一封邮件,我们需要在桩代码内部维护一些状态,通过断言这些状态判断邮件是否发送。由于桩代码需要开发者手动实现,上面的例子在 MailService 发生变化时,我们需要维护桩代码,否则依赖到这个桩代码的所有测试用例都会失败。
  mock 代码
  和桩代码不同,mock 代码不需要实现 MailService 抽象类,并且只能对调用行为进行验证,因此 mock 代码可以通过测试框架自动实现,我们对上面的例子进行修改:
  import { Order, Warehouse, TALISKER } from "./index";
  import MailService from "./mailService";
  jest.mock("./mailService"); // 开启对mailService模块的自动mock

  describe("order", () => {
    let warehouse;
    let mailer;

    beforeEach(() => {
      warehouse = new Warehouse();
      mailer = new MailService();
    });

    it("test order send mail if unfilled", () => {
      const order = new Order(TALISKER, 51);
      order.setMailer(mailer);
      order.fill(warehouse);
      expect(order.isFilled()).toBeFalsy();
      expect(mailer.send).toBeCalledTimes(1); // 断言调用行为,send方法被调用一次
    });
  });

  对比桩代码我们可以发现,我们不需要实现 MailServiceStub ,只在顶部 import 之后执行了 jest.mock('./mailService') 方法,jest 为我们自动实现了 mock 功能,在验证阶段我们可以通过验证 mailer.send 方法的执行次数,来断言库存不足时确实执行了邮件发送操作。如果想验证函数第一次调用时传入的第一个参数,我们可以再加一个断言。
  import { Order, Warehouse, TALISKER } from "./index";
  import MailService from "./mailService";
  jest.mock("./mailService");

  describe("order", () => {
    let warehouse;
    let mailer;

    beforeEach(() => {
      warehouse = new Warehouse();
      mailer = new MailService();
    });

    it("test order send mail if unfilled", () => {
      const order = new Order(TALISKER, 51);
      order.setMailer(mailer);
      order.fill(warehouse);
      expect(order.isFilled()).toBeFalsy();
      expect(mailer.send).toBeCalledTimes(1);
      expect(mailer.send.mock.calls[0][0]).toEqual({ text: '' }) // 第一次调用send方法时的第一个参数
    });
  });
  jest 提供了各种前端常用的多种 mock,timer mock 可以自动 mock 前端的 setTimeout, setInterval, clearTimeout, clearInterval 等方法,还有 es6 mock、手动 mock 等,在实际编写测试代码前,先掌握清楚 jest mock 能力,会让测试更好的进行。
  桩代码和 mock 代码的差异
  很多人会将桩代码和mock代码的概念混淆,首先我们需要明白为什么要使用桩代码和mock代码,在做单元测试时,我们应该专注于当前被测函数,如果被测函数依赖了其他函数/类,尚未实现或者尚未被测试,即便是依赖的函数/类已经实现并被测试过,我们也无法确定它就一定没有问题,我们就需要一种方式让该被测函数在与其他代码隔离的情况下顺利进行下去,因此就出现了桩代码和mock代码。
  不同点在于,桩代码是只会对特定参数进行响应,起到的是补齐作用。而mock代码可以通过验证被依赖函数/类的调用行为,断言被测函数的功能是正常的。另外一个不同是桩代码能够进行行为和状态验证,而mock代码只能进行调用行为验证。
  持续集成流水线
  在做完单元测试后,只有能够让测试运行在重要的位置上,才能发挥单元测试最大的价值,因此持续集成流水线是开源项目做测试时必不可少的一环。在 eva.js 的实践中,我们将运行测试加入到了 release 脚本中第一步来执行,在测试不通过之前是无法运行构建,更新版本、打tag、上传npm、push代码这些流程的,很大程度上保证了发布的可靠性。
  总结
  这篇文章是在做完 eva.js 单元测试后写下的,虽然之前也有做过其他开源项目的单元测试,但并没有梳理清楚其中的方法,在梳理的过程中发现自己对单元测试的一些知识掌握的并不充分,还需要进一步在研究和实践中,逐渐掌握单元测试的方法,希望本文能给想做单元测试的同学一些思考,欢迎交流。

  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系博为峰小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号