Skip to content

Latest commit

 

History

History
981 lines (670 loc) · 41.4 KB

File metadata and controls

981 lines (670 loc) · 41.4 KB

十一、将 Jest 用于单元测试

构建一套健壮的单元测试,以捕获真正的 bug,并且在重构代码时不标记误报,这是我们作为软件开发人员所做的最困难的任务之一。Jest 是一个很好的测试工具,可以帮助我们应对这一挑战,我们将在本章中发现这一点

也许应用到单元测试中最简单的部分是纯功能,因为没有副作用需要处理。我们将回顾我们在第 7 章中使用表单构建的验证器函数,并针对它们执行一些单元测试,以了解如何对纯函数进行单元测试。

单元测试组件是我们在构建应用程序时执行的最常见的单元测试类型。我们将详细了解它,并利用一个库来帮助我们实现在重构代码时不会不必要地中断的测试。

我们将了解什么是快照测试,以及如何利用它更快地实现测试。快照可以用于测试纯函数和组件,因此它们是我们可以使用的非常有用的工具。

模仿是一个具有挑战性的话题,因为如果我们模仿太多,我们就不会真正测试我们的应用程序。但是,有一些依赖关系对 mock 是有意义的,比如 restapi。我们将重温我们在第 9 章中构建的应用程序与 Restful API交互,以便针对它实现一些单元测试并了解模拟。

在为我们的应用程序实现一套单元测试时,了解哪些位已经测试过,哪些位没有测试过是很有用的。我们将学习如何使用代码覆盖工具来帮助我们快速确定需要更多单元测试的应用程序区域。

本章将介绍以下主题:

  • 测试纯函数
  • 测试组件
  • 使用 Jest 快照测试
  • 模拟依赖项
  • 获取代码覆盖率

技术要求

我们在本章中使用以下技术:

In order to restore code from a previous chapter, the LearnReact17WithTypeScript repository at https://github.com/carlrip/LearnReact17WithTypeScript can be downloaded. The relevant folder can then be opened in Visual Studio Code and npm install entered in the terminal to do the restore. All the code snippets in this chapter can be found online at the following link: https://github.com/carlrip/LearnReact17WithTypeScript/tree/master/11-UnitTesting.

测试纯函数

在本节中,我们将通过在纯函数上实现单元测试来开始单元测试之旅。

A pure function has a consistent output value for a given set of parameter values. Pure functions only depend on the function arguments, and on nothing outside the function. These functions also don't change any of the argument values passed into them.

这些函数只依赖于它们的参数值这一事实使得它们可以直接进行单元测试。

我们将对我们在 React shop 中创建的Form组件中创建的required验证器函数进行单元测试。如果还没有,请在 Visual Studio 代码中打开此项目。

我们将使用 Jest 作为我们的单元测试框架,Jest 在单元测试 React 应用程序中非常流行。幸运的是,create-react-app工具在创建项目时为我们安装并配置了这个。因此,Jest 已准备好用于我们的 React shop 项目。

创建基本的纯函数测试

让我们在项目中创建第一个单元测试,以测试Form.tsx中的required功能:

  1. 首先在src文件夹中创建一个名为Form.test.tsx的文件。我们将使用此文件作为测试代码,在Form.tsx中测试代码

The test.tsx extension is important because Jest automatically looks for files with this extension when finding tests to execute. Note that if our tests don't contain any JSX, we could use a test.ts extension.

  1. 让我们导入要测试的函数,以及参数值所需的 TypeScript 类型:
import { required, IValues } from "./Form";
  1. 让我们开始使用 Jesttest函数创建测试:
test("When required is called with empty title, 'This must be populated' should be returned", () => {
  // TODO: implement the test
});

test功能包含两个参数:

  • 第一个参数是一条消息,告诉我们测试是否通过,这将显示在测试输出中
  • 第二个参数是一个 arrow 函数,它将包含我们的测试
  1. 我们将继续使用包含空title属性的values参数调用required函数:
test("When required called with title being an empty string, an error should be 'This must be populated'", () => {
  const values: IValues = {
 title: ""
 };
 const result = required("title", values);
  // TODO: check the result is correct
});
  1. 我们在这个测试中的下一个任务是检查required函数的结果是否符合我们的预期。我们可以使用 Jestexpect函数来执行此操作:
test("When required called with title being an empty string, an error should be 'This must be populated'", () => {
  const values: IValues = {
    title: ""
  };
  const result = required("title", values);
  expect(result).toBe("This must be populated");
});

我们将要检查的变量传递到expect函数中。然后我们将一个toBe匹配器函数链接到该函数上,该函数检查expect函数的结果是否与提供给toBe函数的参数相同。

toBe is one of many Jest matcher functions we can use to check a variable value. The full list of functions can be found at https://jestjs.io/docs/en/expect.

  1. 现在我们的测试已经完成,我们可以通过在终端中键入以下内容来运行测试:
npm test

这将在监视模式下启动 Jest 测试运行程序,这意味着它将持续运行,在我们更改源文件时执行测试。

Jest 最终将找到我们的测试文件,执行我们的测试,并将结果输出到终端,如下所示:

  1. 让我们更改测试中的预期结果,以使测试失败:
expect(result).toBe("This must be populatedX");

当我们保存测试文件时,Jest 自动执行测试并将失败输出到终端,如下所示:

Jest 为我们提供了有关失败的宝贵信息。它告诉我们:

  • 哪个测试失败了
  • 与实际结果相比,预期结果是什么
  • 测试代码中发生故障的那一行

此信息有助于我们快速解决测试失败问题。

  1. 在继续之前,让我们更正测试代码:
expect(result).toBe("This must be populated");

当我们保存更改时,测试现在应该通过了。

了解 Jest 手表选项

Jest 执行我们的测试后,它为我们提供以下选项:

> Press f to run only failed tests.
> Press o to only run tests related to changed files.
> Press p to filter by a filename regex pattern.
> Press t to filter by a test name regex pattern.
> Press q to quit watch mode.
> Press Enter to trigger a test run.

这些选项允许我们指定应该执行哪些测试,随着测试数量的增加,这些选项非常有用。让我们探讨其中一些选项:

  1. 如果我们按下F,Jest 将只执行失败的测试。在我们的代码中,我们确认没有失败的测试:

  1. 让我们按F退出此选项,并返回所有可用选项。

  2. 现在,让我们按下P。这允许我们测试一个特定的文件或一组名称与正则表达式模式匹配的文件。当提示输入文件名模式时,让我们输入form

然后将执行我们在Form.test.tsx中的测试。

  1. 我们将保持文件名过滤器打开,然后按T。这将允许我们按测试名称添加额外的过滤器。让我们进入required

然后将执行我们对required函数的测试。

  1. 要清除过滤器,我们可以按C

If we receive an error—watch is not supported without git/hg, please use --watchAll, this will be because our project isn't in a Git repository. We can resolve the issue by entering the git init command in the Terminal.

我们现在可以很好地处理可用于执行测试的选项。

向单元测试结果添加结构

当我们实现更多的单元测试时,在单元测试结果中添加一些结构是非常有用的,这样我们就可以更容易地阅读它们。有一个名为describe的 Jest 函数,我们可以使用它将某些测试的结果分组在一起。如果将一个函数的所有测试组合在一起,可能会使读取测试结果更容易。

让我们这样做,并使用describe函数重构我们之前创建的单元测试:

describe("required", () => {
  test("When required called with title being an empty string, an error should be 'This must be populated'", () => {
    const values: IValues = {
      title: ""
    };
    const result = required("title", values);
    expect(result).toBe("This must be populated");
  });
});

描述函数包含两个参数:

  • 第一个参数是测试组的标题。我们已经使用了为此测试的函数名。
  • 第二个参数是一个 arrow 函数,它包含要执行的测试。我们在这里进行了最初的测试。

当我们保存测试文件时,测试将自动运行,我们改进的输出显示在终端中,测试结果在required标题下:

我们已经开始熟悉 Jest,已经实现并执行了单元测试。在下一节中,我们将继续讨论更复杂的单元测试组件主题。

测试组件

单元测试组件具有挑战性,因为组件具有依赖项,如浏览器的 DOM 和 React 库。在进行必要的检查之前,我们如何在测试代码中呈现组件?在编码用户交互(例如单击按钮)时,我们如何触发 DOM 事件

在本节中,我们将通过对我们在 React shop 中创建的ContactUs组件进行一些测试来回答这些问题。

创建基本组件测试

我们将首先创建一个单元测试,以验证在未填写字段的情况下提交“联系我们”表单是否会导致页面上显示错误:

  1. 我们将在ContactUs组件上实施单元测试。我们首先在src文件夹中创建一个名为ContactUs.test.tsx的文件。
  2. 我们将使用ReactDOM呈现ContactUs组件的测试实例。我们来导入ReactReactDOM
import React from "react";
import ReactDOM from "react-dom";
  1. 我们将模拟表单提交事件,因此让我们从 React 测试实用程序导入Simulate函数:
import { Simulate } from "react-dom/test-utils";
  1. 现在,让我们导入需要测试的组件:
import ContactUs from "./ContactUs";
  1. 我们还需要从Form.tsx导入提交结果界面:
import { ISubmitResult } from "./Form";
  1. 让我们开始使用 Jesttest函数创建测试,结果输出到ContactUs组:
describe("ContactUs", () => {
  test("When submit without filling in fields should display errors", () => {
    // TODO - implement the test
  });
});
  1. 测试实现中的第一项任务是在 DOM 中创建 React 组件:
test("When submit without filling in fields should display errors", () => {
  const handleSubmit = async (): Promise<ISubmitResult> => {
 return {
 success: true
 };
 };

 const container = document.createElement("div");
 ReactDOM.render(<ContactUs onSubmit={handleSubmit} />, container);

 // TODO - submit the form and check errors are shown

 ReactDOM.unmountComponentAtNode(container);
});

首先,我们创建一个容器div标记,然后将ContactUs组件呈现到这个容器中。我们还为返回成功的onSubmit道具创建了一个处理程序。测试中的最后一行通过删除在测试中创建的 DOM 元素进行清理。

  1. 接下来,我们需要获取表单的引用,然后提交它:
ReactDOM.render(<ContactUs onSubmit={handleSubmit} />, container);

const form = container.querySelector("form");
expect(form).not.toBeNull();
Simulate.submit(form!);

// TODO - check errors are shown

ReactDOM.unmountComponentAtNode(container);

以下是逐步说明:

  • 我们使用querySelector函数,传入form标记以获取对form标记的引用。
  • 然后,我们使用 Jestexpect函数和nottoBeNull函数链接在一起,检查表单是否为null
  • 使用 React 测试实用程序中的Simulate功能模拟submit事件。我们在form变量后面使用!来通知 TypeScript 编译器它不是null
  1. 我们的最终任务是检查是否显示验证错误:
Simulate.submit(form!);

const errorSpans = container.querySelectorAll(".form-error");
expect(errorSpans.length).toBe(2);

ReactDOM.unmountComponentAtNode(container);

让我们一步一步来看看:

  • 我们在容器 DOM 节点上使用querySelectorAll函数,传入 CSS 选择器以查找应该包含错误的span标记
  • 然后,我们使用 Jestexpect函数来验证是否显示了两个错误
  1. 当测试运行时,它应该成功通过,这给了我们两个通过的测试:

在本测试中,Jest 在假 DOM 中呈现组件。还使用标准 React 测试实用程序中的simulate函数模拟了表单submit事件。因此,为了便于交互式组件测试,有很多模拟正在进行。

还要注意,我们在测试代码中引用了内部实现细节。我们引用了一个form标记和一个form-errorCSS 类。如果我们以后将这个 CSS 类名更改为contactus-form-error会怎么样?我们的测试将失败,而我们的应用程序不一定存在问题。

这被称为一个假阳性,并且可能会使具有此类测试的代码库的更改非常耗时。

使用 react 测试库改进我们的测试

react 测试库是一组实用程序,帮助我们为 react 组件编写可维护的测试。它着重于帮助我们从测试代码中删除实现细节。

我们将使用此库删除测试代码中的 CSS 类引用,以及与 React 的事件系统的紧密耦合。

安装 react 测试库

我们先通过终端将react-testing-library作为开发依赖项安装:

npm install --save-dev react-testing-library

几秒钟后,这将添加到我们的项目中。

从测试中删除 CSS 类引用

我们将通过删除form-errorCSS 类上的依赖项对测试进行第一次改进。相反,我们将通过错误文本获得对错误的引用,这是用户在屏幕上看到的,而不是实现细节:

  1. 我们将从react-testing-library导入一个render函数,现在我们将使用它来呈现我们的组件。我们还将导入一个cleanup函数,我们将在测试结束时使用该函数从 DOM 中删除测试组件:
import { render, cleanup} from "react-testing-library";
  1. 我们可以使用刚刚导入的render函数来呈现我们的组件,而不是使用ReactDOM.render,如下所示:
test("When submit without filling in fields should display errors", () => {
  const handleSubmit = async (): Promise<ISubmitResult> => {
    return {
      success: true
    };
  };
  const { container, getAllByText } = render(
 <ContactUs onSubmit={handleSubmit} />
 );

  const form = container.querySelector("form");
  ...
});

我们在一个container变量中获取容器 DOM 节点,以及一个getallByText函数,我们将使用该函数获取对所显示错误的引用。

  1. 现在我们使用getAllByText函数获取页面上显示的错误:
Simulate.submit(form!);

const errorSpans = getAllByText("This must be populated");
expect(errorSpans.length).toBe(2);
  1. 我们要做的最后一个更改是在测试结束时使用我们刚刚导入的cleanup函数而不是ReactDOM.unmountComponentAtNode清理 DOM。我们还将在测试之外,在 Jest 的afterEach函数中执行此操作。我们完成的测试现在应该如下所示:
afterEach(cleanup);

describe("ContactUs", () => {
  test("When submit without filling in fields should display errors", () => {
    const handleSubmit = async (): Promise<ISubmitResult> => {
      return {
        success: true
      };
    };
    const { container, getAllByText } = render(
      <ContactUs onSubmit={handleSubmit} />
    );

    const form = container.querySelector("form");
    expect(form).not.toBeNull();
    Simulate.submit(form!);

    const errorSpans = getAllByText("This must be populated");
    expect(errorSpans.length).toBe(2);
  });
});

当测试运行时,它仍然可以正常执行,并且测试应该通过。

使用 fireEvent 进行用户交互

我们现在将切换到依赖于本机事件系统,而不是位于其上的 React 的事件系统。这使我们更接近于测试用户使用我们的应用程序时发生的情况,并增强了我们对测试的信心:

  1. 让我们首先从react-testing-library向导入语句添加fireEvent函数:
import { render, cleanup, fireEvent } from "react-testing-library";
  1. 我们将把getByText函数添加到render函数调用中的解构变量中:
const { getAllByText, getByText } = render(
  <ContactUs onSubmit={handleSubmit} />
);

我们还可以删除解构的container变量,因为不再需要它了。

  1. 然后,我们可以使用此函数获取对 Submit 按钮的引用。之后,我们可以使用我们导入的fireEvent功能点击按钮:
const { getAllByText, getByText } = render(
  <ContactUs onSubmit={handleSubmit} />
);

const submitButton = getByText("Submit");
fireEvent.click(submitButton);

const errorSpans = getAllByText("This must be populated");
expect(errorSpans.length).toBe(2);

先前引用form标记的代码现在已被删除。

当测试运行时,它仍然通过。

因此,我们的测试引用的是用户看到的项目,而不是实现细节,因此意外中断的可能性要小得多。

为有效表单提交创建第二个测试

现在我们已经了解了如何编写健壮测试的要点,让我们添加第二个测试,以检查表单填写错误时是否显示验证错误:

  1. 我们首先在ContactUs小组中创建一个新测试:
describe("ContactUs", () => {
  test("When submit without filling in fields should display errors", () => {
    ...
  });

  test("When submit after filling in fields should submit okay", () => {
 // TODO - render component, fill in fields, submit the form and check there are no errors
 });
});
  1. 我们将以与第一次测试相同的方式呈现组件,但分解的变量略有不同:
test("When submit after filling in fields should submit okay", () => {
  const handleSubmit = async (): Promise<ISubmitResult> => {
 return {
 success: true
 };
 };
 const { container, getByText, getByLabelText } = render(
 <ContactUs onSubmit={handleSubmit} />
 );
});

现在:

  • 我们需要container对象来检查是否显示任何错误
  • 我们将使用getByText功能定位提交按钮
  • 我们将使用getByLabelText函数获取对输入的引用
  1. 我们现在可以使用getByLabelText函数获取输入名称的引用。之后,我们进行一点检查以验证名称输入是否存在:
const { container, getByText, getByLabelText } = render(
  <ContactUs onSubmit={handleSubmit} />
);

const nameField: HTMLInputElement = getByLabelText(
 "Your name"
) as HTMLInputElement;
expect(nameField).not.toBeNull();
  1. 然后我们需要模拟用户填写这个输入。为此,我们调用本机change事件,传入所需的事件参数,其中包括我们的输入值:
const nameField: HTMLInputElement = getByLabelText(
  "Your name"
) as HTMLInputElement;
expect(nameField).not.toBeNull();
fireEvent.change(nameField, {
 target: { value: "Carl" }
});

我们模拟了用户将名称字段设置为Carl

We use a type assertion after the call to getByLabelText to inform the TypeScript compiler that the returned element is of type HTMLInputElement, so that we don't get a compilation error.

  1. 然后,我们可以按照相同的模式填写电子邮件字段:
const nameField: HTMLInputElement = getByLabelText(
  "Your name"
) as HTMLInputElement;
expect(nameField).not.toBeNull();
fireEvent.change(nameField, {
  target: { value: "Carl" }
});

const emailField = getByLabelText("Your email address") as HTMLInputElement;
expect(emailField).not.toBeNull();
fireEvent.change(emailField, {
 target: { value: "[email protected]" }
});

这里,我们模拟了用户将电子邮件字段设置为[email protected]

  1. 然后,我们可以通过单击 submit 按钮提交表单,方式与第一次测试相同:
fireEvent.change(emailField, {
  target: { value: "[email protected]" }
});

const submitButton = getByText("Submit");
fireEvent.click(submitButton); 
  1. 我们的最终任务是验证屏幕上没有显示错误。不幸的是,我们不能使用上一次测试中使用的getAllByText函数,因为这期望找到至少一个元素,而在我们的例子中,我们期望没有元素。因此,在我们执行此检查之前,我们将在错误周围添加一个包装div标签。让我们转到Form.tsx并执行以下操作:
{context.errors[name] && context.errors[name].length > 0 && (
 <div data-testid="formErrors">
    {context.errors[name].map(error => (
      <span key={error} className="form-error">
        {error}
      </span>
    ))}
  </div>
)}

我们已经给了div标记一个data-testid属性,我们将在测试中使用它

  1. 让我们回到我们的测试。我们现在可以使用data-testid属性定位错误周围的div标记。然后我们可以验证此div标记是否为null,因为不会显示任何错误:
fireEvent.click(submitButton); 

const errorsDiv = container.querySelector("[data-testid='formErrors']");
expect(errorsDiv).toBeNull();

当测试在我们的测试套件中运行时,我们会发现现在有三个通过测试。

不过,引用data-testid属性不是一个实现细节吗?用户看不到或不关心data-testid属性,这似乎与我们前面所说的相矛盾。

这是一种实现细节,但它专门用于我们的测试。因此,实现重构不太可能意外地破坏我们的测试

在下一节中,我们将添加另一个测试,这次使用 Jest 快照测试。

使用 Jest 快照测试

快照测试是 Jest 将呈现组件中的所有元素和属性与呈现组件的前一个快照进行比较的测试。如果没有差异,则测试通过。

我们将添加一个测试,通过使用 Jest 快照测试检查 DOM 节点来验证ContactUs组件呈现 OK:

  1. 我们将在ContactUs测试组中创建一个标题为Renders okay的测试,以与前面相同的方式呈现组件:
describe("ContactUs", () => {
  ...
  test("Renders okay", () => {
 const handleSubmit = async (): Promise<ISubmitResult> => {
 return {
 success: true
 };
 };
 const { container } = render(<ContactUs onSubmit={handleSubmit} />);

 // TODO - do the snapshot test
 });
});
  1. 现在,我们可以添加执行快照测试的行:
test("Renders okay", () => {
  const handleSubmit = async (): Promise<ISubmitResult> => {
    return {
      success: true
    };
  };
  const { container } = render(<ContactUs onSubmit={handleSubmit} />);

  expect(container).toMatchSnapshot();
});

进行快照测试非常简单。我们将要比较的 DOM 节点传递到 Jest 的expect函数中,然后在其后面链接toMatchSnapshot函数。

测试运行时,我们将确认快照已写入终端,如下所示:

  1. 如果我们查看src文件夹,我们将看到它现在包含一个__snapshots__文件夹。如果我们查看此文件夹,我们将看到一个名为ContactUs.test.tsx.snap的文件。打开该文件,我们将看到以下内容:
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ContactUs Renders okay 1`] = `
<div>
  <form
    class="form"
    novalidate=""
  >
    <div
      class="form-group"
    >
      <label
        for="name"
      >
        Your name
      </label>
      <input
        id="name"
        type="text"
        value=""
      />
    </div>
    ...
  </form>
</div>
`;

在这段代码中,部分内容被剥离出来,但我们得到了要点:我们有每个 DOM 节点的副本,包括它们的属性,这些属性来自传递给toMatchSnapshot函数的container元素。

不过,此测试与我们的实现紧密耦合。因此,对 DOM 结构或属性的任何更改都将破坏我们的测试。

  1. 例如,让我们在Form.tsx中的Form组件中添加一个div标记:
<form ...>
  <div>{this.props.children}</div>
  ...
</form>

当测试运行时,我们将看到测试已中断的确认。Jest 很好地向我们展示了终端的不同之处:

  1. 我们很高兴这是一个有效的更改,所以我们可以按U让 Jest 更新快照:

那么,快照测试是好事还是坏事?它们是不稳定的,因为它们与组件的实现紧密耦合。然而,它们非常容易创建,当它们出现故障时,Jest 在突出问题区域并允许我们高效地更正测试快照方面做得非常好。它们非常值得一试,看看您的团队是否从中获得了价值。

在本章中,我们已经学到了很多关于单元测试 React 和 TypeScript 应用程序的知识。接下来,我们将学习如何模拟依赖项。

模拟依赖项

模拟组件的依赖关系可以使组件更易于测试。然而,如果我们模拟了太多的东西,那么测试是否真的验证了该组件在我们真正的应用程序中工作?

在编写单元测试时,确定要模拟的内容是最困难的任务之一。不过,有些东西很值得模仿,比如 RESTAPI。RESTAPI 在前端和后端之间是一个非常固定的契约。模拟 RESTAPI 还可以让我们的测试运行得又好又快。

在本节中,我们最终将学习如何模拟使用axios进行的 RESTAPI 调用。不过,首先,我们将了解 Jest 的函数模拟特性。

在 Jest 中使用模拟函数

我们将对该测试进行另一项改进,该测试验证了在未填写字段的情况下提交 Contact Us 表单会导致错误显示在页面上。我们将添加一个附加检查,以确保未执行提交处理程序:

  1. 让我们回到我们编写的第一个组件测试:ContactUs.test.tsx。我们手动创建了一个handleSubmit函数,我们在ContactUs组件的实例中引用了该函数。让我们将其更改为 Jest 模拟函数:
const handleSubmit = jest.fn();

我们的测试将正确运行,就像以前一样,但是这次 Jest 为我们模拟了这个函数。

  1. 现在 Jest 正在模拟提交处理程序,我们可以在测试结束时检查它是否被作为附加检查调用。我们使用nottoBeCalledJest matcher 函数来实现这一点:
const errorSpans = container.querySelectorAll(".form-error");
expect(errorSpans.length).toBe(2);

expect(handleSubmit).not.toBeCalled();

这真的很好,因为我们不仅简化了提交处理程序函数,而且还很容易地添加了一个检查来验证它是否未被调用。

让我们继续进行我们实施的第二个测试,该测试验证了已提交有效的联系我们表单,可以:

  1. 我们将再次更改handleSubmit变量以引用 Jest 模拟函数:
const handleSubmit = jest.fn();
  1. 让我们验证是否调用了提交处理程序。我们使用toBeCalledTimesJest 函数传递我们期望调用该函数的次数,在本例中为1
const errorsDiv = container.querySelector("[data-testid='formErrors']");
expect(errorsDiv).toBeNull();

expect(handleSubmit).toBeCalledTimes(1);

当测试执行时,它仍然应该通过

  1. 我们还可以做一个有用的检查。我们知道正在调用 submit 处理程序,但它的参数是否正确?我们可以使用toBeCalledWithJest 函数来检查:
expect(handleSubmit).toBeCalledTimes(1);
expect(handleSubmit).toBeCalledWith({
 name: "Carl",
 email: "[email protected]",
 reason: "Support",
 notes: ""
});

同样,当测试执行时,它仍然应该通过

因此,通过让 Jest 模拟我们的提交处理程序,我们很快在测试中添加了一些有价值的附加检查。

使用 Axios 模拟适配器模拟 Axios

我们将转到我们在第 9 章中创建的项目*与 Restful API 交互。*我们将添加一个测试,验证帖子是否正确呈现在页面上。我们将模拟 JSONPlaceholder REST API,这样我们就可以控制返回的数据,这样我们的测试就能很好地快速执行:

  1. 首先,我们需要安装axios-mock-adapter包作为开发依赖项:
npm install axios-mock-adapter --save-dev
  1. 我们还将安装react-testing-library
npm install react-testing-library --save-dev
  1. 该项目已经有一个测试文件App.test.tsx,其中包括对App组件的基本测试。我们将删除测试,但保留导入,因为我们需要这些。
  2. 此外,我们将从 react 测试库axiosMockAdapter类中导入一些函数,我们将使用这些函数模拟 REST API 调用:
import { render, cleanup, waitForElement } from "react-testing-library";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
  1. 让我们添加在每次测试后执行的常规清理行:
afterEach(cleanup);
  1. 我们将使用适当的描述创建测试,并将其置于App组下:
describe("App", () => {
  test("When page loads, posts are rendered", async () => {

    // TODO - render the app component with a mock API and check that the posts in the rendered list are as expected
      });
});

注意,arrow函数用async关键字标记。这是因为我们最终将在测试中进行异步调用。

  1. 我们测试的第一项工作是使用MockAdapter类模拟 REST API 调用:
test("When page loads, posts are rendered", async () => {
    const mock = new MockAdapter(axios);
 mock.onGet("https://jsonplaceholder.typicode.com/posts").reply(200, [
 {
 userId: 1,
 id: 1,
 title: "title test 1",
 body: "body test 1"
 },
 {
 userId: 1,
 id: 2,
 title: "title test 2",
 body: "body test 2"
 }
 ]);
});

当调用获取帖子的 URL 时,我们使用onGet方法定义我们想要的响应 HTTP 状态代码和主体。因此,对 RESTAPI 的调用应该返回两个包含测试数据的 POST。

  1. 我们需要检查帖子是否正确呈现。为此,我们将在App.tsx中的无序帖子列表中添加一个data-testid属性。我们也只会在有数据时渲染:
{this.state.posts.length > 0 && (
  <ul className="posts" data-testid="posts">
    ...
  </ul>
)}
  1. 回到我们的测试,我们现在可以呈现组件并分解getByTestId函数:
mock.onGet("https://jsonplaceholder.typicode.com/posts").reply(...);
const { getByTestId } = render(<App />);
  1. 我们需要检查渲染的帖子是否正确,但这很棘手,因为这些帖子是异步渲染的。在进行检查之前,我们需要等待 posts 列表添加到 DOM 中。我们可以使用 react 测试库中的waitForElement函数来完成此操作:
const { getByTestId } = render(<App />);
const postsList: any = await waitForElement(() => getByTestId("posts"));

waitForElement函数接受一个 arrow 函数作为参数,它反过来返回我们正在等待的元素。我们使用getByTestId函数获取 posts 列表,该列表使用其data-testid属性找到它。

  1. 然后,我们可以使用快照测试来检查 posts 列表中的内容是否正确:
const postsList: any = await waitForElement(() => getByTestId("posts"));
expect(postsList).toMatchSnapshot();
  1. 在我们的测试能够成功执行之前,我们需要在tsconfig.json中进行更改,以便 TypeScript 编译器知道我们正在使用asyncawait
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "es2015"],
    ...
  },
  "include": ["src"]
}

执行测试时,将创建快照。如果我们检查快照,它将包含两个列表项,其中包含我们告诉 RESTAPI 返回的数据。

我们已经了解了 Jest 和 react 测试库中的一些重要特性,这些特性帮助我们在纯函数和 react 组件上编写可维护的测试。

我们怎样才能知道单元测试覆盖了我们应用程序的哪些部分,更重要的是,哪些部分没有被覆盖?我们将在下一节中找到答案。

获取代码覆盖率

代码覆盖率是指单元测试覆盖了多少应用程序代码。当我们编写单元测试时,我们会对哪些代码被覆盖和哪些代码没有被覆盖有一个大致的概念,但随着应用程序的发展和时间的推移,我们将无法了解这一点

Jest 附带了一个很好的代码覆盖工具,所以我们不必将所覆盖的内容保存在头脑中。在本节中,我们将使用它来发现我们在上一节中工作的项目中的代码覆盖率,我们在其中模拟了axios

  1. 我们的第一个任务是添加一个npm脚本,该脚本将在覆盖率跟踪工具打开的情况下运行测试。让我们添加一个名为test-coverage的新脚本,在执行react-scripts时包含--coverage选项:
"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "test-coverage": "react-scripts test --coverage",
  "eject": "react-scripts eject"
},
  1. 然后,我们可以在终端中运行此命令:
npm run test-coverage

几秒钟后,Jest 将在终端中的每个文件上呈现一些漂亮的高级覆盖率统计信息:

  1. 如果我们查看我们的项目文件结构,我们会看到一个coverage文件夹被添加了一个lcov-report文件夹。在lcov-report文件夹中有一个index.html文件,其中包含每个文件覆盖范围的更详细信息。让我们打开这个,看看:

我们看到的信息与终端中显示的信息相同

这四列统计数字意味着什么?

  • Statements列显示代码中执行了多少条语句

  • Branches列显示代码中条件语句中执行了多少分支

  • Function列显示代码中调用了多少函数

  • Line列显示代码中执行了多少行。一般来说,这将与Statements图相同。但是,如果在一行上放置多个语句,则可能会有所不同。例如,以下内容计为一行,但有两条语句:

let name = "Carl"; console.log(name);
  1. 我们可以钻取每个文件,找出哪些特定的代码位没有被覆盖。点击App.tsx链接:

代码行左侧带有绿色背景的1x表示这些行已经被我们的测试执行过一次。以红色突出显示的代码是我们的测试未涵盖的代码。

因此,获取覆盖率统计数据并确定我们可能想要实现的其他测试非常容易。这是一个非常值得使用的东西,让我们相信我们的应用程序经过了良好的测试。

总结

在本章中,我们学习了如何使用 Jest 测试用 TypeScript 编写的纯函数。我们只需使用要测试的参数执行函数,并使用 Jest 的expect函数与 Jest 的匹配器函数(如toBe链接)来验证结果。

我们研究了如何与 Jest 的测试运行程序交互,以及如何应用过滤器,以便只执行我们关注的测试。我们了解到,测试 React 和 TypeScript 组件比测试纯函数更复杂,但 Jest 和 React 测试库为我们提供了大量帮助。

我们还学习了如何使用render函数呈现组件,以及如何使用 react 测试库中的getByTextgetLabelByText等各种函数与元素交互和检查元素。

我们了解到,我们也可以使用 react 测试库中的waitForElement函数轻松测试异步交互。我们现在了解了在测试中不引用实现细节的好处,这将帮助我们构建更健壮的测试。

我们还讨论了 Jest 聪明的快照测试工具。我们研究了这些测试是如何定期中断的,以及为什么它们非常容易创建和更改。

模拟和监视函数的能力是我们现在知道的另一个非常有趣的特性。检查是否使用正确的参数调用了组件事件处理程序的函数确实可以为测试增加价值。

我们讨论了可用于模拟axiosREST API 请求的axios-mock-adapter库。这使我们能够轻松地测试与 RESTful API 交互的容器组件。

现在,我们知道如何快速确定需要实施的其他测试,从而让我们相信我们的应用程序经过了良好的测试。我们使用react-scripts--coverage选项创建了一个npm脚本命令来实现这一点。

总的来说,我们现在有了知识和工具,可以通过 Jest 为我们的应用程序稳健地创建单元测试。

Jasmine 和 Mocha 是两种流行的替代测试框架。Jest 的最大优点是它由create-react-app配置来计算方框。如果我们想使用茉莉花和摩卡,就必须手动配置它们。不过,如果您的团队已经有使用这两种工具的经验,而不是学习其他测试框架,那么 Jasmine 和 Mocha 是值得考虑的。

Ezyme 是另一个与 Jest 一起用于测试 React 应用程序的流行库。它支持浅层渲染,这是一种仅渲染组件中的顶级元素而不渲染子组件的方法。这是非常值得探索的,但请记住,我们嘲笑的越多,我们得到的真相就越远,我们对我们的应用程序经过良好测试的信心就越低。

问题

  1. 假设我们正在实现一个 Jest 测试,我们有一个名为result的变量,我们要检查它不是null。我们如何使用 Jest matcher 函数实现这一点?

  2. 假设我们有一个名为person的变量,类型为IPerson

interface IPerson {
  id: number;
  name: string;
}

我们要检查person变量是否为{ id: 1, name: "bob" }。我们如何使用 Jest matcher 函数实现这一点?

  1. 是否可以使用 Jest 快照测试执行最后一个问题中的检查?如果是,怎么做?
  2. 我们已经实现了一个名为CheckList的组件,它从列表中的数组中呈现文本。每个列表项都有一个复选框,以便用户可以选择列表项。该组件有一个名为onItemSelect的函数 prop,当用户选中复选框选择一个项目时调用该函数 prop。我们正在进行一项测试,以验证onItemSelect道具是否有效。以下代码行在测试中呈现组件:
const { container } = render(<SimpleList data={["Apple", "Banana", "Strawberry"]} onItemSelect={handleListItemSelect} />);

我们如何为handleListItemSelect使用 Jest 模拟函数并检查它是否被调用?

  1. 在上一个问题SimpleList的实现中,onItemSelect函数引入了一个名为item的参数,该参数是用户选择的string值。在我们的测试中,假设我们已经模拟了用户选择Banana。如何检查调用了onItemSelect函数,项目参数为Banana

  2. 在最后两个问题的SimpleList实现中,文本使用一个标签显示,该标签使用for属性绑定到复选框。我们如何使用 react 测试库中的函数首先定位Banana复选框,然后再进行检查?

  3. 在本章中,我们发现呈现来自 JSONPlaceholder REST API 的帖子的代码覆盖率很低。当我们从 RESTAPI 获取帖子时,componentDidMount函数中处理 HTTP 错误代码是其中一个未涉及的领域。创建一个测试以覆盖此代码区域。

进一步阅读

以下资源可用于查找有关单元测试 React 和 TypeScript 应用程序的更多信息: