[译] Jest 入门教程:使用 Jest 运行 JS 单元测试

9,253 阅读5分钟

原文链接:Jest Tutorial for Beginners: Getting Started With Jest for JavaScript Testing (2019)

什么是测试?

用行话说,测试表示检查我们的代码是否满足一些期望。例如,一个名为 transformer 的函数在接收一个输入(input)后,返回一个预期输出(expected output)

测试分很多类型,但笼统总结的话测试主要有三个类型

  • 单元测试
  • 集成测试
  • UI 测试

本文介绍的 Jest 教程覆盖的是单元测试,但在文末,也给到很多其他类型测试的资源,方便大家学习。

Jest 是什么?

Jest 是用来创建、执行和构建测试用例的一个 JavaScript 测试库。你可以在任何项目中以 npm 包的形式,安装并使用它。Jest 是当前最受欢迎的测试执行器,并且是在创建 React App 时的默认选项。

我怎么知道要测试什么?

提到测试的时候,即使是最简单的一个代码块可能都让初学者不知所措。最常问的问题的是“我怎么知道要测试什么?”。如果你正在写一个 web 应用,那么你每个页面每个页面的测试用户交互的方式,就是一个很好的开端了。但 Web 应用也是由很多个函数和模块组成的代码单元,也是需要测试的。通常有两种情况:

  • 你接手的遗留代码没有写测试用例
  • 你必须从无到有的实现一个新功能

该怎么办呢?对于上面两种场景,你可以把测试视为代码的一部分来编写。我所说的这些代码,是用来检查给定的函数是否产生预期输出结果的。 一个典型的测试流程如下:

  1. 引入要测试的函数
  2. 给函数一个输入
  3. 定义预期输出
  4. 检查函数是否返回了预期的输出结果

就这么多。这样看测试也没那么可怕的嘛:输入 —— 预期输出 —— 验证结果。好了,现在就要开始介绍 Jest 了,它几乎可以准确地检测我们刚才说过的内容。

创建项目

每个 JavaScript 项目都需要一个 NPM 环境(确保系统中安装了 Node)。下面,我们创建一个新的文件夹,并且初始化项目。

mkdir getting-started-with-jest && cd $_
npm init -y

接下来,再安装 Jest:

npm i jest --save-dev

然后我们配置下 NPM 脚本,为了能够在命令行执行我们的测试用例。打开 package.json,将执行 Jest 的命令命名为“test”:

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

现在可以开始了!

规范和测试驱动开发

开发者都喜欢创意自由。但当涉及到的事情很严肃的时候,大多数时候就没有那么多的特权了。通常我们必须遵循规范,这是指,一个书面上的或者口头上的构建描述。

在本教程中,我们从项目经理那儿拿来的是一个相当简单的规范。一个非常重要的客户需要一个能够过滤出数组中我们所需对象的函数

对数组中每个对象,我们都要检查它的“url”属性,是否属性值跟我们给定的项目匹配。在最终的结果数组里,包含的都是我们匹配到的对象成员。想要成为一个精通测试的 JavaScript 开发人员,需要遵循 测试驱动开发(test-driven development) 模式,这种模式要求在开始编写代码之前,先编写失败的测试用例

默认 Jest 会在名为 tests 的项目文件夹中寻找测试文件。我们来创建一个新文件夹:

cd getting-started-with-jest
mkdir __tests__

接下来在 tests 文件夹中创建一个文件 filterByTerm.spec.js。你可能存在疑问,为什么文件名中包含一个“.spec”?这其实是从 Ruby 借鉴而来的一种约定,用于将文件标记为特定功能的规范。

现在开始测试!

测试结构 & 第一个失败测试

好了,现在常见第一个 Jest 测试用例。打开文件 filterByTerm.spec.js,创建一个测试块:

describe("Filter function", () => {
  // test stuff
});

我们的第一个朋友叫 describe,这个 Jest 方法用来包含一个或一个以上的相关测试。每次为一个功能开始编写新的测试套件的时候,都要包在 describe 里。这个方法接收两个参数:测试套件的描述以及包装实际测试用例的回调函数。

接下来就要介绍另一个函数 test 了,这里用来定义实际的测试代码块:

describe("Filter function", () => {
  test("it should filter by a search term (link)", () => {
    // actual test
  });
});

现在可以准备写测试了。记住,测试是关于输入函数预期结果的事情。首先,我们定义一个简单的输入——一个包含对象成员的数组:

describe("Filter function", () => {
  test("it should filter by a search term (link)", () => {
    const input = [
      { id: 1, url: "https://www.url1.dev" },
      { id: 2, url: "https://www.url2.dev" },
      { id: 3, url: "https://www.link3.dev" }
    ];
  });
});

再来定义我们的预期结果。根据规范,被测试函数应该去掉 url 属性与给定搜索项不匹配的对象。比如,我们的搜索项是“link”,期望的结果是一个仅包含一个对象成员的数组:

describe("Filter function", () => {
  test("it should filter by a search term (link)", () => {
    const input = [
      { id: 1, url: "https://www.url1.dev" },
      { id: 2, url: "https://www.url2.dev" },
      { id: 3, url: "https://www.link3.dev" }
    ];

    const output = [{ id: 3, url: "https://www.link3.dev" }];
  });
});

现在可以写实际测试代码了。我们要用到 Jest 的 expect 函数和 匹配器(matcher) 来检查我们假想的(当前是)函数调用时是否返回预期结果。下面给出了代码:

expect(filterByTerm(input, "link")).toEqual(output);

或者将代码分解,调用函数的部分单拎出来:

filterByTerm(input, "link");

在 Jest 测试中,我们把测试函数包装在 expect 里面,并且搭配 匹配器 (用来检查输入的 Jest 函数)一起使用,来完成测试。下面列出了完整测试代码:

describe("Filter function", () => {
  test("it should filter by a search term (link)", () => {
    const input = [
      { id: 1, url: "https://www.url1.dev" },
      { id: 2, url: "https://www.url2.dev" },
      { id: 3, url: "https://www.link3.dev" }
    ];

    const output = [{ id: 3, url: "https://www.link3.dev" }];

    expect(filterByTerm(input, "link")).toEqual(output);

  });
});

(更多有关 Jest 匹配器的信息检查 这里的文档

现在,运行一波测试:

npm test

你会看到测试失败了:

 FAIL  __tests__/filterByTerm.spec.js
  Filter function
    ✕ it should filter by a search term (2ms)

  ● Filter function › it should filter by a search term (link)

    ReferenceError: filterByTerm is not defined

       9 |     const output = [{ id: 3, url: "https://www.link3.dev" }];
      10 | 
    > 11 |     expect(filterByTerm(input, "link")).toEqual(output);
         |     ^
      12 |   });
      13 | });
      14 |

“ReferenceError: filterByTerm is not defined”. 意思很明显啦,没有定义 filterByTerm,接下来我们来修复它。

修复测试(并再一次测试失败)

我们还没实现 filterByTerm 呢。为了方便,我们把这个函数的定义跟咱们的测试用例放在一起。当然,在实际新项目中,测试用例和要测试的函数往往是为了不同文件中的,测试函数的时候是需要从别的文件引入的

filterByTerm 函数内部,需要借助原生的数组方法 filter 实现,来过滤出我们需要的成员:

function filterByTerm(inputArr, searchTerm) {
  return inputArr.filter(function(arrayElement) {
    return arrayElement.url.match(searchTerm);
  });
}

说明下函数的工作原理:我们检查输入数组里的每个对象成员的“url”属性值,是否与 match 方法里的正则表达式匹配。下面是完整代码:

function filterByTerm(inputArr, searchTerm) {
  return inputArr.filter(function(arrayElement) {
    return arrayElement.url.match(searchTerm);
  });
}

describe("Filter function", () => {
  test("it should filter by a search term (link)", () => {
    const input = [
      { id: 1, url: "https://www.url1.dev" },
      { id: 2, url: "https://www.url2.dev" },
      { id: 3, url: "https://www.link3.dev" }
    ];

    const output = [{ id: 3, url: "https://www.link3.dev" }];

    expect(filterByTerm(input, "link")).toEqual(output);
  });
});

现在再次执行测试:

npm test

看到没,通过了!

 PASS  __tests__/filterByTerm.spec.js
  Filter function
    ✓ it should filter by a search term (link) (4ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.836s, estimated 1s

很棒。但是完了吗?还没。怎样让函数再次调用失败呢?接下里,我们用大写的搜索项调用下函数:

function filterByTerm(inputArr, searchTerm) {
  return inputArr.filter(function(arrayElement) {
    return arrayElement.url.match(searchTerm);
  });
}

describe("Filter function", () => {
  test("it should filter by a search term (link)", () => {
    const input = [
      { id: 1, url: "https://www.url1.dev" },
      { id: 2, url: "https://www.url2.dev" },
      { id: 3, url: "https://www.link3.dev" }
    ];

    const output = [{ id: 3, url: "https://www.link3.dev" }];

    expect(filterByTerm(input, "link")).toEqual(output);

    expect(filterByTerm(input, "LINK")).toEqual(output); // New test

  });
});

执行测试……嗯,失败了。来吧,我们再来修复下。

修复测试:兼容大写搜索项

filterByTerm 应该也要把大写搜索项考虑进去。也就是说,即使是搜索内容是大写的,也要以忽略大小写的形式返回对应的匹配对象。

filterByTerm(inputArr, "link");
filterByTerm(inputArr, "LINK");

为了测试这种状况,我们需要引入一个新的测试:

expect(filterByTerm(input, "LINK")).toEqual(output); // New test

为了通过测试,我们需要稍微调整下 match 方法的正则表达式:

//
    return arrayElement.url.match(searchTerm);
//

相较于直接使用 searchTerm,我们可以构建一个不区分大小写的正则表达式。也就是说,一个与字符串大小写无关的表达式。下面是修复后的代码:

function filterByTerm(inputArr, searchTerm) {
  const regex = new RegExp(searchTerm, "i");
  return inputArr.filter(function(arrayElement) {
    return arrayElement.url.match(regex);
  });
}

下面是完整测试代码:

describe("Filter function", () => {
  test("it should filter by a search term (link)", () => {
    const input = [
      { id: 1, url: "https://www.url1.dev" },
      { id: 2, url: "https://www.url2.dev" },
      { id: 3, url: "https://www.link3.dev" }
    ];

    const output = [{ id: 3, url: "https://www.link3.dev" }];

    expect(filterByTerm(input, "link")).toEqual(output);

    expect(filterByTerm(input, "LINK")).toEqual(output);
  });
});

function filterByTerm(inputArr, searchTerm) {
  const regex = new RegExp(searchTerm, "i");
  return inputArr.filter(function(arrayElement) {
    return arrayElement.url.match(regex);
  });
}

在此执行,会看到通过了。厉害!作为联系,你可以写一个新的测试来检查下列条件:

  1. 测试如果搜索项为“uRI” 的情况
  2. 测试空搜索项。函数如何去处理它呢?

你如何去构建这些测试呢?

下一节,我们要来看下另一个重要的测试话题:代码覆盖率

代码覆盖率

什么是代码覆盖率?在谈论这个之前,我们先对代码做下调整。在项目根目录下创建一个名为 src 的文件夹,再在里面创建一个名为 filterByTerm.js 的文件。我们在这里 export 出这个函数:

mkdir src && cd _$
touch filterByTerm.js

下面是文件 filterByTerm.js 的内容:

function filterByTerm(inputArr, searchTerm) {
  if (!searchTerm) throw Error("searchTerm cannot be empty");
  const regex = new RegExp(searchTerm, "i");
  return inputArr.filter(function(arrayElement) {
    return arrayElement.url.match(regex);
  });
}

module.exports = filterByTerm;

现在假设我是你们公司新来的同事。我对测试一无所知,在不清楚我们开发环境的情况下,我在这个函数里加了一个 if 语句

function filterByTerm(inputArr, searchTerm) {
  if (!searchTerm) throw Error("searchTerm cannot be empty");
  if (!inputArr.length) throw Error("inputArr cannot be empty"); // new line
  const regex = new RegExp(searchTerm, "i");
  return inputArr.filter(function(arrayElement) {
    return arrayElement.url.match(regex);
  });
}

module.exports = filterByTerm;d

我们在 filterByTerm 里加了一行新代码,但没有被测试。除非我告诉你“这里有个新语句需要测试”,你是不会知道要测试什么的。几乎不可能知道我们的代码会走的所有路径,因此需要一种工具来帮助我们发现这些盲点

这种工具称为代码覆盖率,它是我们工具箱里的一个强大工具。Jest 内置了代码覆盖率工具,你可以使用两种方式激活它:

  1. 在命令行中通过 “–coverage” flag 指定
  2. 在 package.json 中手动配置

再执行覆盖率测试之前,确保在 tests/filterByTerm.spec.js引入了 filterByTerm 函数。

const filterByTerm = require("../src/filterByTerm");
// ...

保存文件,执行覆盖率测试:

npm test -- --coverage

得到如下的结果:

 PASS  __tests__/filterByTerm.spec.js
  Filter function
    ✓ it should filter by a search term (link) (3ms)
    ✓ it should filter by a search term (uRl) (1ms)
    ✓ it should throw when searchTerm is empty string (2ms)

-----------------|----------|----------|----------|----------|-------------------|
File             |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
-----------------|----------|----------|----------|----------|-------------------|
All files        |     87.5 |       75 |      100 |      100 |                   |
 filterByTerm.js |     87.5 |       75 |      100 |      100 |                 3 |
-----------------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total

这是对我们函数测试覆盖率的一个很好的总结。我们看见 第 3 行没有覆盖。现在来测试我新添加的 if 语句,来达到 100% 的代码覆盖率。

如果想要在每次测试的时候,都要做代码覆盖率检查,可以在 package.json 对 jest 做出如下配置:

"scripts": {
  "test": "jest"
},
"jest": {
  "collectCoverage": true
},

或者以 flag 的形式在命令中带入:

"scripts": {
  "test": "jest"
},
"jest": {
  "collectCoverage": true,
  "coverageReporters": ["html"]
},

现在,每次运行 npm test 时,您都可以在项目中看见一个名为 coverage 的文件夹:getting-started-with-jest/coverage/。在此目录中,你会看见一堆文件,而 /coverage/index.html 就是这些文件的索引页,是对代码覆盖率的总结说明。

点击文件名,你可以看见准确的未被测试到的代码行:

很简洁吧。有了代码覆盖率工具,你就能知道该测试哪里的代码了。

如何测试 React?

React 是一个非常流行的、用于创建动态用户界面的 JavaScript 库。Jest 在测试 React 应用程序时运行顺畅(Jest 和 React 都出自 Facebook 的工程师)。Jest 也是创建 React 程序时的默认测试运行器。

如果你要学习如何测试 React 组件的话,可以查看 《Testing React Components: The Mostly Definitive Guide》 这篇教程。这篇教程覆盖了 单元测试组件、类组件、带 Hooks 的功能组件以及新的 Act API。

总结(接下来该去哪儿)

测试是一个大且迷人的话题。现在有许多种类型的测试和许多可以选择的测试库。在本篇 Jest 教程里,我们学习了如何配置 Jest 覆盖率报告,如何组织和编写一个简单的单元测试,以及如何测试 JavaScript 代码。

如果要学习关于 UI 测试 方面的东西,我强烈建议你查看这篇教程 《Tutorial: JavaScript End to End Testing with Cypress》

同是我也建议你阅读由 Harry Percival 写的 Test-Driven Development with Python 这本书,虽然与 JavaScript 不直接相关。教程里包含了所有的测试提示和技巧,并深入介绍了所有不同类型的测试。

如果您已经准备好,想要学习自动化测试和持续集成,建议阅读 《Automated Testing and Continuous Integration in JavaScript》 这篇教程。

你可以在 Github 上找到本教程的代码:getting-started-with-jest,这里包括里文中布置的练习代码。

感谢阅读!

(完)