w3ctech

[Jest]单元测试初学者指南 - Part1 - 函数测试

原文: Unit Testing Beginner's Guide - Part 1 - Testing Functions

作者:jstweetster

示例代码

龙虎大战坐庄你 已经决定开始对代码进行单元测试,但是不知道从哪里开始或者围绕该代码的最佳实践是什么。

在这个系列中,龙虎大战坐庄我 计划在从基本原理开始,并使用您可能直到现在才知道的高级龙虎大战坐庄技术 ,在单元测试的领域中引导龙虎大战坐庄你 。

开始之前,龙虎大战坐庄你 需要安装如下依赖:

  • NodeJS - 龙虎大战坐庄你 也许已经安装啦
  • Jest - 这是龙虎大战坐庄龙虎大战坐庄我 们 将会使用的单元测试库

请注意下面事项:

  1. 确保龙虎大战坐庄你 已经安装了NodeJS:node -v。确保版本号 >= 6.x,如果不是请安装
  2. 创建名为unit-testing-functions目录
  3. 切换到该目录cd unit-testing-functions并且初始化该JavaScript项目:npm init--yes
  4. 现在在该目录龙虎大战坐庄你 应该有一个package.json文件
  5. 安装Jest:nom i jest --save-dev
  6. 龙虎大战坐庄你 现在可以验证Jest是否安装成功通过运行:./node_modules/.bin/jest -v

一切准备就绪,龙虎大战坐庄龙虎大战坐庄我 们 开始进入单元测试啦。

龙虎大战坐庄龙虎大战坐庄我 们 将从简单的函数开始,并且,随着龙虎大战坐庄龙虎大战坐庄我 们 进行一系列的单元测试,龙虎大战坐庄龙虎大战坐庄我 们 将会继续探索更复杂的数据结构和设置。

龙虎大战坐庄龙虎大战坐庄我 们 将会测试的代码

让龙虎大战坐庄龙虎大战坐庄我 们 开始定义一个简单的函数:

unit-testing-functions项目中创建一个sum.js文件。定义如下函数:

module.export = function sum (a, b) {
    return a + b;
}

这个函数龙虎大战坐庄龙虎大战坐庄我 们 将会进行测试。单元测试背后的想法是提供尽可能多的输入类型,以涵盖所有条件分支。

现在,没有任何条件分支,但龙虎大战坐庄龙虎大战坐庄我 们 应该改变龙虎大战坐庄龙虎大战坐庄我 们 对函数的输入,以确保它继续正确运行,即使代码在将来被更改。

理解测试文件

每一个龙虎大战坐庄你 写的代码文件都应该有一个对应的Spec文件,它通常在代码文件旁边。例如:touch sum.spec.js或者手动创建。

在spec文件中,龙虎大战坐庄龙虎大战坐庄我 们 将会写测试龙虎大战坐庄方法

Jest和其他测试框架将测试龙虎大战坐庄组织 到测试用例中,为了简单管理和记录,每个测试用例包含多个单独的测试。

让龙虎大战坐庄龙虎大战坐庄我 们 添加龙虎大战坐庄龙虎大战坐庄我 们 的第一个测试(在sum.spec.js中):

const sum = require('./sum.js');
describe('sum suite', function () {
  test('should add 2 positive numbers together and return the result', function () {
    expect(sum(1, 2)).toBe(3);
  });
});

如果这看起来令人生畏或不清楚,请不要担心,它会在少数情况下有意义。

那么,这里发生了什么?

const sum = require('./sum.js');

龙虎大战坐庄龙虎大战坐庄我 们 引入要测试的函数。龙虎大战坐庄龙虎大战坐庄我 们 使用 module.exports 从模块中暴露函数,并且使用 require 引入到要测试的文件中。这是因为Jest在能识别这些结构的NodeJS上运行龙虎大战坐庄龙虎大战坐庄我 们 的测试。

此代码不是在浏览器中运行,不使用像Webpack这样的模块打包器,但另一篇文章将会介绍。

接下来,龙虎大战坐庄龙虎大战坐庄我 们 定义了测试单元,它将包含所有有关 sum 函数的测试龙虎大战坐庄方法 。

describe('sum suite', function () {
    // define here the individual tests
})

最后龙虎大战坐庄龙虎大战坐庄我 们 添加龙虎大战坐庄龙虎大战坐庄我 们 第一个测试(龙虎大战坐庄龙虎大战坐庄我 们 将会在上面的测试单元中添加龙虎大战坐庄更多 的测试):

test('should add 2 positive numbers together and return the result', function () {
  expect(sum(1, 2)).toBe(3);
});

下面的代码可能也不是很清楚

expect(sum(1, 2)).toBe(3)

这是任何单元测试的构建块,被称作为“断言(assertion)”。断言基本上是一种表达对事物应该如何表现的期望的方式。在龙虎大战坐庄龙虎大战坐庄我 们 的示例中,龙虎大战坐庄龙虎大战坐庄我 们 期望调用 sum(1, 2) 应该返回的结果是 3

toBe 被称作为“匹配器(matcher)”。在 Jest 里有很多的匹配器,每一个匹配器都有助于验证一个特定的方面:比如测试对象是否相等等。

那么,expect 从哪里来的呢?龙虎大战坐庄龙虎大战坐庄我 们 也没有从任何地方导入进来。

事实证明,Jest作为全局变量,提供了 describetestexpect 和一些其他函数,因此龙虎大战坐庄你 无需导入他们。龙虎大战坐庄你 可以到 官方文档 查看完整列表

该到龙虎大战坐庄龙虎大战坐庄我 们 运行龙虎大战坐庄龙虎大战坐庄我 们 的第一个单元测试啦

龙虎大战坐庄你 可以在龙虎大战坐庄龙虎大战坐庄我 们 的项目根目录中直接调用Jest来运行单元测试 ./node_modules/.bin/jest

更好,更跨平台的方式是定义一个NPM脚本来运行这些测试。例如,打开 package.json 文件并且编辑下面的部分:

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
 }

改为:

"scripts": {
    "test": "jest"
 }

运行 npm run test,龙虎大战坐庄你 将会看到成功的输出:

➜  unit-testing-functions npm run test

> unit-testing-functions@1.0.0 test /Users/zouyuwei/code/web/_SELF/unit-testing-functions
> jest

 PASS  __tests__/sum.spec.js
  sum suite
    ✓ should add 2 positive numbers together and return the result (8ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.74s
Ran all test suites.

很好,龙虎大战坐庄你 的第一个测试通过了!

现在,快进几周或者几个月,假设龙虎大战坐庄你 的同事正在研究 sum 函数并且决定更改它的实现龙虎大战坐庄方法 如下所示:

module.exports = function sum(a, b) {
    return a - b;
}

请更改它(这仅仅只是为了演示)。现在龙虎大战坐庄你 的同事在提交这些改变之前运行单元测试。输出将会如下所示:

➜  unit-testing-functions npm run test

> unit-testing-functions@1.0.0 test /Users/zouyuwei/code/web/_SELF/unit-testing-functions
> jest

 FAIL  __tests__/sum.spec.js
  sum suite
    ✕ should add 2 positive numbers together and return the result (30ms)

  ● sum suite › should add 2 positive numbers together and return the result

    expect(received).toBe(expected) // Object.is equality

    Expected: 3
    Received: -1

      2 | describe('sum suite', function () {
      3 |   test('should add 2 positive numbers together and return the result', function () {
    > 4 |     expect(sum(1, 2)).toBe(3);
        |                       ^
      5 |   });
      6 | });

      at Object.toBe (__tests__/sum.spec.js:4:23)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        3.303s
Ran all test suites.

通过查看上面的输出信息,能够很容易的得出结论:在 __tests__/sum.spec.js 文件的第四行出错了,如跟踪堆栈所示 at Object.toBe (__tests__/sum.spec.js:4:23) 。因此可以得出结论 expect(sum(1,2)).toBe(3); 这一行出错了。通过检查控制台的输出,龙虎大战坐庄龙虎大战坐庄我 们 能够看到,期望的值是 ‘3’,而接收的值是 ‘-1’。

因此,单元测试既是防止回归的一种方式,也是一种活文档。

最后,请更改 a - ba + b

扩大单元测试覆盖率

龙虎大战坐庄龙虎大战坐庄我 们 进行了第一次测试,并且它涵盖了sum函数中的所有分支,有许多场景龙虎大战坐庄龙虎大战坐庄我 们 还没有测试过。

考虑一下测试中的功能,不仅要考虑今天的实现,还要考虑它如何随着时间的推移而发展。龙虎大战坐庄龙虎大战坐庄我 们 希望捕获函数停止工作的情况,即使有人修改了它的实现,并添加了额外的检查和分支。

因此,让龙虎大战坐庄龙虎大战坐庄我 们 通过创建额外的单元测试来扩展测试范围。在 sum.spec.js 文件中添加如下代码:

const sum = require('../sum.js');
describe('sum suite', function () {
  test('should add 2 positive numbers together and return the result', function () {
    expect(sum(1, 2)).toBe(3);
  });

  test('Should add 2 negative numbers together and return the result', function() {
    expect(sum(-1, -2)).toBe(-3);
  });

  test('Should add 1 positive and 1 negative numbers together and return the result', function() {
    expect(sum(-1, 2)).toBe(1);
  });

  test('Should add 1 positive and 0 together and return the result', function() {
    expect(sum(0, 2)).toBe(2);
  });

  test('Should add 1 negative and 0 together and return the result', function() {
    expect(sum(0, -2)).toBe(-2);
  });
});

除了最初的测试用例之外,龙虎大战坐庄龙虎大战坐庄我 们 刚添加了4个测试用例。注意龙虎大战坐庄龙虎大战坐庄我 们 如何改变函数的输入以及龙虎大战坐庄龙虎大战坐庄我 们 如何尝试击中边缘情况(例如,通过添加0)。

运行单元测试,龙虎大战坐庄你 将会看到:

➜  unit-testing-functions npm run test

> unit-testing-functions@1.0.0 test /Users/zouyuwei/code/web/_SELF/unit-testing-functions
> jest

 PASS  __tests__/sum.spec.js
  sum suite
    ✓ should add 2 positive numbers together and return the result (7ms)
    ✓ Should add 2 negative numbers together and return the result (1ms)
    ✓ Should add 1 positive and 1 negative numbers together and return the result (1ms)
    ✓ Should add 1 positive and 0 together and return the result (1ms)
    ✓ Should add 1 negative and 0 together and return the result

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        2.575s
Ran all test suites.

处理单元测试功能中的异常

虽然龙虎大战坐庄龙虎大战坐庄我 们 在扩展单元测试覆盖率方面做得很好,但测试可以为龙虎大战坐庄龙虎大战坐庄我 们 做龙虎大战坐庄更多 的事情。

如果龙虎大战坐庄龙虎大战坐庄我 们 认为龙虎大战坐庄龙虎大战坐庄我 们 还没有涉及的其他方案真的很好,龙虎大战坐庄你 能想出一些目前代码处理不当的龙虎大战坐庄方法 吗?

如何传递除数字以外的输入?

填写如下测试用例:

test('Should raise an error if one of the inputs is not a number', function() {
        expect(() => sum('0', -2)).toThrowError('Both arguments must be numbers');
 });

首先,龙虎大战坐庄龙虎大战坐庄我 们 用一个匿名函数龙虎大战坐庄包装 龙虎大战坐庄龙虎大战坐庄我 们 要测试的代码:() => sum('0', -2)

这是必需的,因为在测试一段代码时抛出的任何未捕获的异常都会触发测试失败。

在这个例子当中,当参数不是数据的时候,龙虎大战坐庄龙虎大战坐庄我 们 期望 sum 函数抛出异常,但是龙虎大战坐庄龙虎大战坐庄我 们 不希望这是被认为是测试失败的: 相反的,这是预期的行为,应该被认为是通过的测试用例。

因此,龙虎大战坐庄龙虎大战坐庄我 们 将其龙虎大战坐庄包装 在一个匿名函数中,并引入一个新的匹配器:toThrowError

运行测试,观察如下:

 FAIL  __tests__/sum.spec.js
  sum suite
    ✕ Should raise an error if one of the inputs is not a number (18ms)

  ● sum suite › Should raise an error if one of the inputs is not a number

    expect(function).toThrowError(string)

    Expected the function to throw an error matching:
      "Both arguments must be numbers"
    But it didn't throw anything.

      22 |
      23 |   test('Should raise an error if one of the inputs is not a number', function() {
    > 24 |     expect(() => sum("0", -2)).toThrowError('Both arguments must be numbers');
         |                                ^
      25 |   });
      26 | });

      at Object.toThrowError (__tests__/sum.spec.js:24:32)

此时要抵制修改测试代码的诱惑。

这个测试很清楚的说明了该函数实现上的问题:

  • 它期望函数返回 to throw an error matching:“Both arguments must be numbers 。实际上它没有抛出任何异常。
  • 要查看它正在讨论的函数以及用于调用它的参数,请遵循堆栈跟踪: at Object.toThrowError (__tests__/sum.spec.js:24:32) 。在指定的行和列上,您可以看到对应的断言: expect(() => sum("0", -2)).toThrowError('Both arguments must be numbers');

所以龙虎大战坐庄龙虎大战坐庄我 们 的单元测试刚刚发现了一个bug,该是修复的时候了!

更改 sum.js 的代码,以考虑错误的输入类型,并在这种情况下抛出适当的异常:

module.exports = function sum (a, b) {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('Both arguments must be numbers');
  }
  return a + b;
}

再次运行测试脚本,观察所有的测试都通过了。好样的!

请注意:龙虎大战坐庄龙虎大战坐庄我 们 首先添加了一个单元测试,然后再进入并添加代码,这表明 sum 函数在某些条件下无法正常运行。

龙虎大战坐庄龙虎大战坐庄我 们 看到了测试FAILING,龙虎大战坐庄龙虎大战坐庄我 们 添加了修复bug的代码并观看了测试PASSING。

在开发新代码/修复现有代码时,您应始终遵循此过程。

提高生产力

到目前为止,您可能已经注意到,每次添加代码或更新单元测试时,龙虎大战坐庄龙虎大战坐庄我 们 都必须不断重新运行单元测试。

这很快就会变得烦人并妨碍实际的开发工作流程。幸运的是,大多数测试运行程序允许设置文件监视模式,当磁盘上的文件发生更改时,它会重新运行单元测试。

修改 package.json 文件,将 “script” 部分改为:

"script": {
    "test": "jest --watch"
}

运行单元测试: npm run test

观察现在测试运行器没有退出而是等待命令。

改变 sum.js 或者 sum.spec.js 文件,会看到测试正在重新运行。

单元测试函数 - 最好实践总结

  • 在项目中龙虎大战坐庄本地 安装测试依赖项,而不是全局安装(例如龙虎大战坐庄龙虎大战坐庄我 们 安装 Jest 到 ./node_modules 而不是全局的)。这允许龙虎大战坐庄龙虎大战坐庄我 们 在同时处理多个项目,并为每个项目分别升级。此外,它还可以轻松地与他人共享龙虎大战坐庄龙虎大战坐庄我 们 的项目设置。
  • 定义一个用于运行单元测试的NPM脚本,龙虎大战坐庄你 不必再记住确切的测试命令。它还抽象出用于运行测试的实际单元测试运行器。
  • 每个代码文件应该有一个相应的 .spec 文件,通常位于代码文件的旁边(译者觉得:或者是放到统一的测试文件夹中)。这使得某人能够快速浏览与组件相关的测试并了解其工作原理。
  • test 语句的文本说明非常重要:确保它们非常清晰,可读并且能够确定哪些条件下的预期行为。描述文本通常应该遵循如下模板:“[在什么情况下] 应该 [期望什么] ” (Should [what's to be expected] when [under which circumstances])。
  • 单元测试应该只运行一种行为。同一个单元测试不要涵盖多个场景。而是创建场景自己的 test 单元,并且清晰的命名、描述和运用该场景。
  • 在添加实现/修复某些行为的代码之前,始终先编写一个初始的FAILING测试。

这就是龙虎大战坐庄龙虎大战坐庄我 们 对单元测试的介绍。

w3ctech微信

扫码关注w3ctech微信龙虎大战坐庄公众号

共收到0条回复