Skip to content

Latest commit

 

History

History
1104 lines (824 loc) · 48.2 KB

File metadata and controls

1104 lines (824 loc) · 48.2 KB

六、使用 Jest 和 Enzyme 构建探索 TDD 的应用

为了保持应用的可维护性,最好为项目设置测试。当一些开发人员讨厌编写测试,因此试图避免编写测试时,其他开发人员则喜欢通过实施测试驱动开发TDD策略,将测试作为其开发过程的核心。关于测试应用以及如何做到这一点,有很多意见。幸运的是,在使用 React 构建应用时,许多优秀的库可以帮助您进行测试。

在本章中,您将使用两个库对 React 应用进行单元测试。第一个是 Jest,它由 Facebook 自己维护,并附带 CreateReact 应用。另一个工具叫做 Enzyme,它比 Jest 有更多的功能,可以用来测试组件的整个生命周期。如果您想测试函数或组件在给定特定输入时是否按预期运行,那么它们非常适合测试大多数 React 应用

本章将介绍以下主题:

  • 用 Jest 进行单元测试
  • 呈现用于测试的组件
  • Enzyme 法检测

项目概述

在本章中,我们将创建一个 hotel review 应用,该应用使用 Jest 和 Ezyme 进行单元和集成测试。该应用已经预构建,并且使用了我们在前面章节中看到的相同模式。

构建时间为 2 小时。

开始

本章的应用以初始版本为基础,可在中找到 https://github.com/PacktPublishing/React-Projects/tree/ch6-initial 。本章的完整代码可在 GitHub 上找到:https://github.com/PacktPublishing/React-Projects/tree/ch6

首先从 GitHub 下载初始项目,然后移动到该项目的根目录中,在那里必须运行npm install命令。由于此项目基于 Create React App 构建,因此运行此命令将安装reactreact-domreact-scripts。此外,还将安装styled-componentsreact-router-dom,以便它们能够处理应用的样式和布线。完成安装过程后,您可以执行npm start命令运行应用,以便您可以在http://localhost:3000浏览器中访问项目。就像您在前面章节中构建的应用一样,此应用的功能类似于 PWA。

最初的应用由一个简单的标题和一个酒店列表组成。这些酒店有一个标题和元信息,比如缩略图。本页内容如下所示。如果您单击列表中的任何酒店,将打开一个新页面,其中包含该酒店的评论列表。单击此页面左上角的按钮,您可以返回到上一页,使用右上角的按钮,将打开一个包含表单的页面,您可以在其中添加审阅。如果添加新的审阅,此数据将存储在全局上下文中,并发送到模拟 API 服务器:

如果您查看项目的结构,您将看到它使用的结构与我们之前创建的项目相同。此应用的入口点是一个名为src/index.js的文件,它呈现一个名为App的组件。在这个App组件中,所有路由都在路由组件中声明和包装。此外,保存全局上下文和提供者的组件在这里声明。与之前创建的应用相比,此应用中不使用容器组件模式。相反,所有的数据提取都是由上下文组件完成的。使用钩子访问生命周期:

hotel-review
|-- node_modules
|-- public
    |-- assets
        |-- beachfront-hotel.jpg
        |-- forest-apartments.jpg
        |-- favicon.ico
        |-- index.html
        |-- manifest.json
|-- src
    |-- components
        |-- Button
            |-- Button.js
        |-- Detail
            |-- Detail.js
            |-- ReviewItem.js
        |-- Form
            |-- Form.js
            |-- FormItem.js
        |-- Header
            |-- Header.js
            |-- SubHeader.js
        |-- Hotels
            |-- Hotels.js
            |-- HotelItem.js
        |-- App.js
    |-- Context
        |-- GlobalContext.js
        |-- HotelsContextProvider.js
        |-- ReviewsContextProvider.js
    |-- api.js
    |-- index.js
    |-- serviceWorker.js
.gitignore
package.json

在前面的项目结构中,您可以看到public/assets目录中还有两个文件,它们是酒店的缩略图。要使它们在呈现的应用中可用,可以将它们放置在public目录中。此外,在src中还有一个名为api.js的文件,用于导出函数,以便将GETPOST请求发送到 API。

酒店审查申请

在本节中,我们将向 CreateReact 应用中创建的 HotelReview 应用添加单元和集成测试。此应用允许您将评论添加到酒店列表中,并从全局上下文控制此数据。Jest 和 Ezyme 将用于呈现没有 DOM 的 React 组件,并测试这些组件上的断言

用 Jest 进行单元测试

单元测试是应用的一个重要组成部分,因为您希望知道您的函数和组件的行为符合预期,即使在进行代码更改时也是如此。为此,您将使用 Jest,一个由 Facebook 创建的 JavaScript 应用开源测试包。使用 Jest,您可以测试断言,例如,如果函数的输出与您期望的值匹配

要开始使用 Jest,您不必安装任何东西;这是 CreateReact 应用的一部分。如果您查看package.json文件,您将看到已经存在用于运行测试的脚本。

让我们看看如果从终端执行以下命令会发生什么:

npm run test 

这将返回一条消息,称为No tests found related to files changed since last commit.,这意味着 Jest 正在监视模式下运行,并且仅对已更改的文件运行测试。按a键,您可以运行所有测试,即使您没有修改任何文件。如果按此键,将显示以下消息:

No tests found
 26 files checked.
 testMatch: /hotel-review/src/**/__tests__/**/*.{js,jsx,ts,tsx},/hotel-review/src/**/?(*.)(spec|test).{js,jsx,ts,tsx} - 0 matches
 testPathIgnorePatterns: /node_modules/ - 26 matches
Pattern: - 0 matches

此消息表示已调查了26文件,但未发现任何测试。它还声明它正在项目中名为__tests__的目录中查找 JavaScript 或 JSX 文件,以及具有spectest后缀的文件。忽略安装所有npm软件包的node_modules目录。从这条消息中,您可能已经注意到 Jest 会自动检测带有测试的文件

可以使用 Jest 创建这些测试,这将在本节的第一部分中演示

创建单元测试

由于 Jest 有多种方法可以检测哪个文件包含测试,所以让我们选择每个组件都有单独测试文件的结构。此测试文件将与保存组件的文件同名,并带有.test后缀。如果我们选择SubHeader组件,我们可以在src/components/Header目录中创建一个名为SubHeader.test.js的新文件。将以下代码添加到此文件:

describe('the <SubHeader /> component', () => {
  it('should render', () => {

  });
});

此处使用 Jest 的两个全局函数:

  • describe:用于定义一个相关测试块
  • it:用于定义测试

在测试的定义中,您可以添加假设,例如toEqualtoBe,分别检查值是否完全等于某个值,或者仅检查类型是否匹配。可以在it函数的回调中添加假设:

describe('the <SubHeader /> component', () => {
  it('should render', () => {
+   expect(1+2).toBe(3);
  });
});

如果您的终端中仍在运行测试脚本,您将看到 Jest 已检测到您的测试。测试成功,因为1+2确实是3。让我们继续并将假设更改为以下内容:

describe('the <SubHeader /> component', () => {
  it('should render', () => {
-    expect(1+2).toBe(3);
+    expect(1+2).toBe('3');
  });
});

现在,测试将失败,因为第二个假设不匹配。虽然1+2仍然等于3,但假设返回值为 pf3的字符串类型,而实际上返回的是数字类型。这有助于您在编写代码时确保应用不会更改其值的类型。

但是,这个假设没有实际用途,因为它没有测试您的组件。要测试组件,需要渲染它。渲染组件以便测试它们将在本节的下一部分中进行处理。

呈现用于测试的 React 组件

Jest 基于 Node.js,这意味着它不能使用 DOM 呈现组件并测试其功能。因此,您需要将 React core 包添加到项目中,这可以帮助您在不使用 DOM 的情况下呈现组件。让我们来看看这里:

  1. 从您的终端执行以下命令,该命令将在您的项目中安装react-test-renderer。它可以作为 devDependency 安装,因为您不需要在应用的构建版本上运行测试:
npm install react-test-renderer --save-dev
  1. 安装了react-test-renderer后,您现在可以将此包导入src/components/Header/SubHeader.test.js文件。此包返回一个名为ShallowRenderer的方法,该方法允许您渲染组件。使用浅层渲染时,仅在组件的第一级渲染组件,从而忽略所有可能的子组件。您还需要导入 React 和要测试的实际组件,因为这些组件由react-test-renderer使用:
+ import React from 'react';
+ import ShallowRenderer from 'react-test-renderer/shallow';
+ import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
 ....
  1. 在您的测试中,您现在可以使用ShallowRenderer呈现组件并获得该组件的输出。使用 JesttoMatchSnapshot假设,您可以测试组件的结构。ShallowRenderer将渲染组件,toMatchSnapshot将从此渲染创建快照,并在每次运行此测试时将其与实际组件进行比较:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
  it('should render', () => {
-   expect(1+2).toBe('3');
+    const renderer = new ShallowRenderer();
+    renderer.render(<SubHeader />);
+    const component = renderer.getRenderOutput();

+    expect(component).toMatchSnapshot();
  });
});
  1. src/components/Header目录中,Jest 现在创建了一个名为__snapshots__的新目录。该目录中有一个名为SubHeader.test.js.snap的文件,其中包含快照。如果打开此文件,您将看到SubHeader组件的渲染版本存储在此处:
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`the <SubHeader /> component should render 1`] = `
<ForwardRef>
  <ForwardRef />
</ForwardRef>
`;

使用styled-components创建的组件无法由react-test-renderer呈现,因为它们是由styled-components导出的。如果您查看SubHeader组件的代码,您将看到ForwardRef组件代表SubHeaderWrapperTitle。在本章的后面,我们将使用 Enzyme 进行测试,它可以更好地处理这个测试场景。

  1. react-test-renderer没有呈现任何实际值,因为没有向SubHeader组件传递任何道具。例如,您可以通过向SubHeader组件传递title道具来检查快照的工作方式。为此,创建一个新的测试场景,该场景应使用标题呈现SubHeader。此外,将renderer常数的创建移动到describe函数,以便所有测试场景都可以使用它:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
+  const renderer = new ShallowRenderer();

  it('should render', () => {
-   const renderer = new ShallowRenderer(); 
    renderer.render(<SubHeader />);
    const component = renderer.getRenderOutput();

    expect(component).toMatchSnapshot();
  });

+  it('should render with a dynamic title', () => {
+    renderer.render(<SubHeader title='Test Application' />);
+    const component = renderer.getRenderOutput();

+    expect(component).toMatchSnapshot();
+  }); });
  1. 下次运行测试时,将向src/components/Header/__snapshots__/SubHeader.test.js.snap文件中添加一个新快照。此快照具有为title属性呈现的值。如果更改测试文件中由SubHeader组件显示的title道具,则渲染的组件将不再与快照匹配。您可以通过在测试场景中更改title道具的值来尝试:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
  const renderer = new ShallowRenderer();

  ...

  it('should render with a dynamic title', () => {
-   renderer.render(<SubHeader title='Test Application' />);
+   renderer.render(<SubHeader title='Test Application Test' />);
    const component = renderer.getRenderOutput();

    expect(component).toMatchSnapshot();
  });
});

Jest 将在终端中返回以下消息,其中指定与快照相比哪些行发生了更改。在这种情况下,显示的标题不再是Test Application,而是Test Application Test,与快照中的标题不匹配:

  the <SubHeader /> component  should render

 expect(value).toMatchSnapshot()

 Received value does not match stored snapshot "the <SubHeader /> component should render 1".

 - Snapshot
 + Received

 <ForwardRef>
 <ForwardRef>
 - Test Application
 + Test Application Title
 </ForwardRef>
 </ForwardRef>
...

通过按u键,您可以更新快照以处理此新的测试场景。这是一种测试组件结构并查看标题是否已呈现的简单方法。在前面的测试中,最初创建的快照仍然与第一次测试中呈现的组件匹配。另外,为第二个测试创建了另一个快照,其中在SubHeader组件中添加了一个title道具。

  1. 您可以对传递到SubHeader组件的其他道具执行相同的操作,如果您传递或不传递某些道具,则呈现不同。在title旁边,该组件将goBackopenForm作为道具,openForm道具的默认值为 false。

就像我们为title道具做的一样,我们也可以为另外两个道具创建测试场景。当goBack有值时,会创建一个按钮,将我们带回到上一页;当openForm有值时,会创建一个按钮,允许我们进入下一页,以便我们可以添加新评论。您还需要将这两个新的测试场景添加到src/components/Header/SubHeader.test.js文件中:

import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
  const renderer = new ShallowRenderer();

  ...

+  it('should render with a goback button', () => {
+   renderer.render(<SubHeader goBack={() => {}} />);
+    const component = renderer.getRenderOutput();
+
+    expect(component).toMatchSnapshot();
+  });

+  it('should render with a form button', () => {
+   renderer.render(<SubHeader openForm={() => {}} />);
+    const result = renderer.getRenderOutput();
+
+    expect(component).toMatchSnapshot();
+  });
});

您现在已经为SubHeader组件创建了另外两个快照,这导致总共四个快照。Jest 所做的另一件事是向您展示测试覆盖了多少行代码。测试覆盖率越高,就越有理由认为代码是稳定的。您可以通过执行带有--coverage标志的test脚本命令来检查代码的测试覆盖率,或者在终端中使用以下命令:

npm run test --coverage

此命令将运行您的测试并生成一个报告,其中包含每个文件中有关代码的所有测试覆盖率信息。添加SubHeader测试后,本报告如下:

 PASS src/components/Header/SubHeader.test.js
----------------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------------------------|----------|----------|----------|----------|-------------------|
All files | 5 | 6.74 | 4.26 | 5.21 | |
 src | 0 | 0 | 0 | 0 | |
 api.js | 0 | 0 | 0 | 0 |... 20,22,23,26,30 |
 index.js | 0 | 100 | 100 | 0 | 1,2,3,4,5,17 |
 serviceWorker.js | 0 | 0 | 0 | 0 |... 23,130,131,132 |
 src/components | 0 | 100 | 0 | 0 | |
 App.js | 0 | 100 | 0 | 0 |... ,8,10,22,26,27 |
 src/components/Button | 0 | 100 | 0 | 0 | |
 Button.js | 0 | 100 | 0 | 0 | 20 |
 src/components/Detail | 0 | 0 | 0 | 0 | |
 Detail.js | 0 | 0 | 0 | 0 |... 26,27,31,33,35 |
 ReviewItem.js | 0 | 100 | 0 | 0 |... 15,21,26,30,31 |
 src/components/Form | 0 | 0 | 0 | 0 | |
 Form.js | 0 | 0 | 0 | 0 |... 29,30,31,34,36 |
 FormInput.js | 0 | 0 | 0 | 0 |... 17,26,35,40,41 |
 src/components/Header | 100 | 100 | 100 | 100 | |
 Header.js | 100 | 100 | 100 | 100 | |
 SubHeader.js | 100 | 100 | 100 | 100 | |
...

Testing coverage only tells us something about the lines and the functions of your code that have been tested and not their actual implementation. Having a test coverage of 100% doesn't mean there aren't any bugs in your code as there will always be edge cases. Also, getting to a testing coverage of 100% means you may end up spending more time on writing tests than on actual code. Usually, a testing coverage above 80% is considered good practice.

如您所见,组件的测试覆盖率为 100%,这意味着测试中涵盖了所有行。但是,这种使用快照进行测试的方法将创建大量新文件和代码行。在本节的下一部分中,我们将研究测试组件的其他方法。

使用断言测试组件

从理论上讲,快照测试不一定是糟糕的做法;但是,随着时间的推移,您的文件可能会变得相当大。此外,由于您没有明确告诉 Jest 您要测试组件的哪一部分,因此可能需要定期更新代码

幸运的是,使用快照并不是我们测试组件是否呈现正确道具的唯一方法。相反,您还可以通过检查组件的值并做出断言来直接比较呈现的道具。使用断言进行测试的最大优点是,您可以进行大量测试,而无需深入挖掘正在测试的组件的逻辑。

例如,您可以看到正在渲染的子对象的外观。让我们来看看如何做到这一点:

  1. 首先,让我们为Button组件创建一个快照测试,以比较测试覆盖率的影响。创建一个名为src/components/Button/Button.test.js的新文件。在此文件中,您需要插入创建快照的测试:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import Button from './Button';

describe('the <Button /> component', () => {
  const renderer = new ShallowRenderer();

  it('should render', () => {
    const children = 'This is a button';
    renderer.render(<Button>{children</Button>);
    const result = renderer.getRenderOutput();

    expect(result).toMatchSnapshot();
  });
});
  1. 如果使用--coverage标志运行测试,将创建一个新的测试覆盖率报告:
npm run test --coverage

此报告生成以下报告,其中显示了Button组件的覆盖率,即 100%:

 PASS src/components/Header/SubHeader.test.js
 PASS src/components/Button/Button.test.js
  1 snapshot written.
 PASS src/components/Header/Header.test.js
----------------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------------------------|----------|----------|----------|----------|-------------------|
All files | 5.45 | 6.74 | 6.38 | 5.69 | |
 src | 0 | 0 | 0 | 0 | |
 api.js | 0 | 0 | 0 | 0 |... 20,22,23,26,30 |
 index.js | 0 | 100 | 100 | 0 | 1,2,3,4,5,17 |
 serviceWorker.js | 0 | 0 | 0 | 0 |... 23,130,131,132 |
 src/components | 0 | 100 | 0 | 0 | |
 App.js | 0 | 100 | 0 | 0 |... ,8,10,22,26,27 |
 src/components/Button | 100 | 100 | 100 | 100 | |
 Button.js | 100 | 100 | 100 | 100 | |

如果打开src/components/Button/__snapshots__/Button.test.js.snap文件中Button组件的快照,您将看到按钮中唯一呈现的东西(由ForwardRef表示)是children道具:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`the <Button /> component should render 1`] = `
<ForwardRef>
  This is a button
</ForwardRef>
`;
  1. 尽管测试覆盖率为 100%,但还有其他方法可以测试是否提供了正确的子级。为此,我们可以创建一个新的测试,该测试也使用ShallowRenderer并尝试使用子级呈现Button组件。本测试断言呈现的children道具等于Button呈现的实际children道具。您可以删除快照测试,因为您只想使用断言测试子项:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import Button from './Button';

describe('the <Button /> component', () => {
  const renderer = new ShallowRenderer();

-  it('should render', () => {
-    const children = 'This is a button';
-    renderer.render(<Button>{children}</Button>);
-    const result = renderer.getRenderOutput();

-    expect(result).toMatchSnapshot();
-  })

+  it('should render the correct children', () => {
+    const children = 'This is a button';
+    renderer.render(<Button>{children}</Button>);
+    const component = renderer.getRenderOutput();

+    expect(component.props.children).toEqual(children);
+  });
});
  1. 从终端再次运行npm run test --coverage检查此测试方法对测试覆盖率的影响:
 PASS src/components/Header/Header.test.js
 PASS src/components/Header/SubHeader.test.js
 PASS src/components/Button/Button.test.js
  1 snapshot obsolete.
  the <Button /> component should render 1
----------------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------------------------|----------|----------|----------|----------|-------------------|
All files | 5.45 | 6.74 | 6.38 | 5.69 | |
 src | 0 | 0 | 0 | 0 | |
 api.js | 0 | 0 | 0 | 0 |... 20,22,23,26,30 |
 index.js | 0 | 100 | 100 | 0 | 1,2,3,4,5,17 |
 serviceWorker.js | 0 | 0 | 0 | 0 |... 23,130,131,132 |
 src/components | 0 | 100 | 0 | 0 | |
 App.js | 0 | 100 | 0 | 0 |... ,8,10,22,26,27 |
 src/components/Button | 100 | 100 | 100 | 100 | |
 Button.js | 100 | 100 | 100 | 100 | |
...

在前面的报告中,您可以看到测试覆盖率仍然是 100%,这意味着此测试方法具有相同的结果。但这一次,你要特别测试孩子们是否等于这个值。好处是,您不必每次更改代码时都更新快照。

  1. 此外,还显示了一条注释为1 snapshot obsolete的消息。通过使用-u标志运行npm run test,Jest 将删除Button组件的快照:
npm run test -u

这为我们提供了以下输出,显示快照已被删除:

 PASS src/components/Button/Button.test.js
  snapshot file removed.

Snapshot Summary
  1 snapshot file removed from 1 test suite.

然而,Button组件不仅采用children道具,还采用onClick道具。如果您想测试点击按钮时是否触发此onClick道具,则需要以不同方式渲染组件。这可以通过使用react-test-renderer来实现,但 React 文档还指出,您可以使用 Enzyme 来实现。

在下一节中,我们将使用 Ezyme 的浅层渲染函数,它比ShallowRenderer有更多的选项。

用 Enzyme 检测反应

react-test-renderer中的ShallowRenderer允许我们呈现组件的结构,但没有向我们展示组件在某些场景中如何交互,例如触发onClick事件时。为了模拟这种情况,我们将使用一种更复杂的工具,称为 Enzyme。

Enzyme 浅涂

Ezyme 是一个开源 JavaScript 测试库,由 Airbnb 创建,可用于几乎所有 JavaScript 库或框架。使用 Ezyme,还可以浅层渲染组件以测试组件的第一级,以及渲染嵌套组件,并模拟集成测试的生命周期。Enzyme 库可以安装有npm,还需要一个适配器来模拟 React 特性。让我们开始:

  1. 要安装 Ezyme,您需要从终端运行以下命令,该命令将安装 Ezyme 和您正在使用的 React 版本的特定适配器:
npm install enzyme enzyme-adapter-react-16 --save-dev
  1. 安装 Ezyme 后,您需要创建一个安装文件,告诉 Ezyme 应该使用哪个适配器来运行测试。通常,您需要在package.json文件中指定哪个文件保存此配置,但是,当您使用 CreateReact 应用时,这已经为您完成了。自动用作测试库配置文件的文件名称为setupTests.js,应在src目录中创建。创建文件后,将以下代码粘贴到其中:
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

安装了酵素后,您就不能再使用使用react-test-renderer的测试场景了。因此,您需要更改SubHeaderButton组件的测试。正如我们前面提到的,Ezyme 有一种方法允许我们对组件进行浅层渲染。让我们先在SubHeader组件中尝试一下:

  1. 您需要从 Enzyme 中导入shallow,而不是导入react-test-rendererShallowRender方法不应再添加到renderer常量中,因此您可以删除此行:
import React from 'react';
- import ShallowRenderer from 'react-test-renderer/shallow';
+ import { shallow } from 'enzyme';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
-  const renderer = new ShallowRenderer();
  it('should render', () => {
    ...
  1. 应更改每个测试场景,以便它使用来自 Ezyme 的浅层渲染功能。我们可以用shallow替换renderer.render。我们用来获取该渲染输出的函数也可以删除。来自 Ezyme 的shallow渲染将立即创建一个结果,可通过 Jest 进行测试:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
  it('should render', () => {
-    renderer.render(<SubHeader />);
-    const component = renderer.getRenderOutput();
+    const component = shallow(<SubHeader />);

    expect(component).toMatchSnapshot();
  });

  ...
  1. 就像我们在第一个测试场景中所做的那样,我们必须替换其他测试场景;否则,测试将无法运行。这是因为我们已经删除了react-test-renderer的设置:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader from './SubHeader';

describe('the <SubHeader /> component', () => {
  ...

  it('should render with a dynamic title', () => {
-    renderer.render(<SubHeader title='Test Application' />);
-    const component = renderer.getRenderOutput();
+    const component = shallow(<SubHeader title='Test Application' />);

    expect(component).toMatchSnapshot();
  });

  it('should render with a goback button', () => {
-    renderer.render(<SubHeader goBack={() => {}} />);
-    const component = renderer.getRenderOutput();
+    const component = shallow(<SubHeader goBack={() => {}} />);

    expect(component).toMatchSnapshot();
  });

  it('should render with a form button', () => {
-    renderer.render(<SubHeader openForm={() => {}} />);
-    const component = renderer.getRenderOutput();
+    const component = shallow(<SubHeader openForm={() => {}} />);

    expect(component).toMatchSnapshot();
  });
});
  1. 在终端中,您现在可以通过运行npm run test再次运行测试。由于测试在监视模式下运行,Button组件的测试可能也会开始运行。您可以通过按p键,然后在终端中键入SubHeader来指定应该运行哪些测试。现在,Jest 将只运行SubHeader组件的测试。

测试将失败,因为您的快照不再是由react-test-renderer创建的快照。Ezyme 的浅层渲染可以更好地理解styled-components的输出,不再将这些组件渲染为ForwardRef组件。相反,它返回一个名为styled.divstyled.h2的组件:

 FAIL src/components/Header/SubHeader.test.js
 the <SubHeader /> component
 Χ should render (27ms)
 Χ should render with a dynamic title (4ms)
 Χ should render with a goback button (4ms)
 Χ should render with a form button (4ms)

  the <SubHeader /> component  should render

 expect(value).toMatchSnapshot()

 Received value does not match stored snapshot "the <SubHeader /> component should render 1".

 - Snapshot
 + Received

 - <ForwardRef>
 - <ForwardRef />
 - </ForwardRef>
 + <styled.div>
 + <styled.h2 />
 + </styled.div>

u键,所有由react-test-renderer创建的快照将被来自 Ezyme 的新快照替换

对于Button组件也可以这样做,其中不使用快照进行测试。而是使用断言。在测试场景中,在src/components/Button/Button.test.js文件中,将ShallowRenderer替换为来自 Ezyme 的浅渲染。此外,由于 Enzyme 如何呈现成分,component.props.children的值不再存在。相反,您需要使用props方法来获取children道具,该方法在浅层渲染组件上可用:

import React from 'react';
- import ShallowRenderer from 'react-test-renderer/shallow';
+ import { shallow } from 'enzyme';
import Button from './Button';

describe('the <Button /> component', () => {
-  const renderer = new ShallowRenderer();

  it('should render the correct children', () => {
    const children = 'This is a button';
-   renderer.render(<Button>{children}</Button>);
-   const component = renderer.getRenderOutput();
+   const component = shallow(<Button>{children}</Button>)

-   expect(component.props.children).toEqual(children)
+   expect(component.props().children).toEqual(children)
  })
})

现在,当您运行测试时,所有测试都应该成功,并且测试覆盖率应该不会受到影响,因为您仍在测试组件上是否呈现了道具。但是,通过使用来自 Ezyme 的快照,您可以获得有关正在渲染的组件结构的更多信息。现在,您可以进行更多的测试,并了解如何处理onClick事件。

但是,快照并不是测试 React 组件的唯一方法,我们将在本节的下一部分中看到。

使用浅层呈现测试断言

除了react-test-renderer之外,Enzyme 可以处理浅层渲染组件上的onClick事件。为了测试这一点,您必须创建一个模拟版本的函数,该函数应该在单击组件后激发。在此之后,Jest 可以检查函数是否已执行。

您之前测试的Button组件不只是将children作为道具—它还具有onClick功能。让我们尝试一下,通过在文件中为Button组件创建一个新的测试场景,看看是否可以使用 Jest 和 Ezyme 对其进行测试:

import React from 'react';
import { shallow } from 'enzyme';
import Button from './Button';

describe('the <Button /> component', () => {
  ...

+  it('should handle the onClick event', () => {
+    const mockOnClick = jest.fn();
+    const component = shallow(<Button onClick={mockOnClick} />);

+    component.simulate('click');

+    expect(mockOnClick).toHaveBeenCalled();
+  });
});

在前面的测试场景中,使用 Jest 创建了一个模拟的onClick函数,该函数作为道具传递给浅层渲染的Button组件。然后,在该组件上调用带有 click 事件处理程序的simulate方法。模拟点击Button组件应该执行模拟的onClick功能,您可以通过检查此测试场景的测试结果来确认该功能。

SubHeader组件的测试也可以更新,因为它会呈现两个带有onClick事件的按钮。让我们开始:

  1. 首先,您需要对src/components/Header/SubHeader.jsSubHeader组件的文件进行一些更改,因为您需要导出使用styled-components创建的组件。通过这样做,它们可以在您的SubHeader测试场景中用于测试:
import React from 'react';
import styled from 'styled-components';
import Button from '../Button/Button';

const SubHeaderWrapper = styled.div`
  width: 100%;
  display: flex;
  justify-content: space-between;
  background: cornflowerBlue;
`;

- const Title = styled.h2`
+ export const Title = styled.h2`
  text-align: center;
  flex-basis: 60%;

  &:first-child {
    margin-left: 20%;
  }

  &:last-child {
    margin-right: 20%;
  }
`;

- const SubHeaderButton = styled(Button)`
+ export const SubHeaderButton = styled(Button)`
  margin: 10px 5%;
`;

...
  1. 一旦它们被导出,我们就可以将这些组件导入到我们的测试文件SubHeader
import React from 'react';
import { shallow } from 'enzyme';
- import SubHeader from './SubHeader';
+ import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
    ...
  1. 这使得我们可以从任何测试中找到这些组件。在这个场景中,title道具的渲染是通过快照进行测试的,但是您也可以直接测试SubHeader中的Title组件是否正在渲染title道具。要对此进行测试,请更改以下代码行:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
  it('should render with a dynamic title', () => {
+    const title = 'Test Application';
-    const component = shallow(<SubHeader title='Test Application' />);
+    const component = shallow(<SubHeader title={title} />);

-    expect(component).toMatchSnapshot();

+    expect(component.find(Title).text()).toEqual(title);
  });

  ...

此处为title道具创建了一个新常量,并将其传递给SubHeader组件。不再使用快照作为断言,而是创建一个新的快照,尝试查找Title组件并检查此组件中的文本是否等于title属性。

  1. title道具旁边,您还可以测试goBack(或openForm道具。如果存在此道具,则会呈现一个按钮,该按钮将goBack道具作为onClick事件。此按钮呈现为SubHeaderButton组件。这里,我们需要更改第二个测试场景,使其具有针对goBack道具的模拟函数,然后创建断言以检查呈现组件中是否存在SubHeaderButton
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
  ...

  it('should render with a goback button and handle the onClick event', () => {
+    const mockGoBack = jest.fn();
-    const component = shallow(<SubHeader goBack={() => {}} />);
+    const component = shallow(<SubHeader goBack={mockGoBack} />);

-    expect(component).toMatchSnapshot();

+    const goBackButton = component.find(SubHeaderButton);
+    expect(goBackButton.exists()).toBe(true);
  });
  ...
  1. 我们不仅要测试带有goBack道具的按钮是否正在被渲染,而且还要测试一旦我们点击按钮是否调用了此函数。就像我们对Button组件测试所做的那样,我们可以模拟一个点击事件,并检查模拟的goBack函数是否被调用:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
  ...

  it('should render with a goback button and handle the onClick event', () => {
    const mockGoBack = jest.fn();
    const component = shallow(<SubHeader goBack={mockGoBack} />);

    const goBackButton = component.find(SubHeaderButton);
    expect(goBackButton.exists()).toBe(true);

+    goBackButton.simulate('click');
+    expect(mockGoBack).toHaveBeenCalled();
  })
  ...
  1. 如果我们将测试快照的断言替换为两个测试按钮是否存在的断言,并且如果它触发模拟的openForm函数,那么openForm属性也可以这样做。我们可以扩展goBack按钮的测试场景,而不是将其添加到现有的测试场景中:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
  ...

-   it('should render with a goback button and handle the onClick event', () => {
+   it('should render with a buttons and handle the onClick events', () => {
    const mockGoBack = jest.fn();
+    const mockOpenForm = jest.fn();
-    //const component = shallow(<SubHeader goBack={mockGoBack} />);
+    const component = shallow(<SubHeader goBack={mockGoBack} openForm={mockOpenForm} />);

    ...
  });

-  it('should render with a form button', () => {
-    const component = shallow(<SubHeader openForm={() => {}} />);

-    expect(component).toMatchSnapshot();
-  });
});
  1. 现在为SubHeader呈现的组件应该有一个返回上一页的按钮和一个打开表单的按钮。然而,他们都使用SubHeaderButton组件进行渲染。返回按钮首先在组件树中呈现,因为它位于SubHeader的左侧。因此,我们需要指定哪个渲染SubHeaderButton是哪个按钮:
import React from 'react';
import { shallow } from 'enzyme';
import SubHeader, { Title, SubHeaderButton } from './SubHeader';

describe('the <SubHeader /> component', () => {
  ...

  it('should render with buttons and handle the onClick events', () => {
    const mockGoBack = jest.fn();
    const mockOpenForm = jest.fn();
    const component = shallow(<SubHeader goBack={mockGoBack} openForm={mockOpenForm} />);

-   const goBackButton = component.find(SubHeaderButton);
+   const goBackButton = component.find(SubHeaderButton).at(0);
    expect(goBackButton.exists()).toBe(true);

+   const openFormButton = component.find(SubHeaderButton).at(1);
+   expect(openFormButton.exists()).toBe(true)

    goBackButton.simulate('click');
    expect(mockGoBack).toHaveBeenCalled();

+    openFormButton.simulate('click');
+    expect(mockOpenForm).toHaveBeenCalled();
  });
  ...

在这些更改之后,所有使用快照的测试场景都将被删除,并替换为更具体的测试,一旦我们更改了任何代码,这些测试就不那么容易受到攻击。除了快照,如果我们改变任何使重构更容易的道具,这些测试将继续工作。

在本节中,我们创建了单元测试,用于测试代码的特定部分。然而,测试代码的不同部分是如何协同工作的可能很有趣。为此,我们将向项目中添加集成测试。

Enzyme 结合试验

我们创建的测试都使用浅层渲染来渲染组件,但是,使用 Ezyme,我们还可以选择装载组件。当使用它时,我们可以启用生命周期并测试比第一级更大的组件。当我们想要同时测试多个组件时,这称为集成测试。在我们的应用中,路由直接渲染的组件也渲染其他组件。一个很好的例子是Hotels组件,它呈现上下文返回的酒店列表。让我们开始:

  1. 和往常一样,开始点是在我们要测试的组件所在的目录中创建一个带有.test后缀的新文件。这里,我们需要在src/components/Hotels目录中创建Hotels.test.js文件。在这个文件中,我们需要从 Enzyme 中导入mount,导入我们想要测试的成分,并创建一个新的测试场景:
import React from 'react';
import { mount } from 'enzyme';
import Hotels from './Hotels';

describe('the <Hotels /> component', () => {

});

2.Hotels组件使用useContext钩子获取显示酒店所需的数据。但是,由于这是针对该特定组件的测试,因此需要模拟该数据。在模拟这些数据之前,我们需要为useContext钩子创建一个模拟函数。如果我们有多个使用此模拟的测试场景,我们还需要使用beforeEachafterEach方法为每个场景创建和重置此模拟功能:

import React from 'react';
import { mount } from 'enzyme';
import Hotels from './Hotels';

+ let useContextMock;

+ beforeEach(() => {
+  useContextMock = React.useContext = jest.fn();
+ });

+ afterEach(() => {
+  useContextMock.mockReset();
+ });

describe('the <Hotels /> component', () => {
    ...
  1. 我们现在可以使用 mockuseContextMock函数生成数据,这些数据将被Hotels组件用作上下文的 mock。将返回的数据也应该被模拟,这可以通过调用mockReturnValue函数来完成,该函数在模拟函数上可用。如果我们看一下Hotels组件的实际代码,就会发现它从上下文中获取了四个值:loadingerrorhotelsgetHotelsRequest。在我们将创建的第一个测试场景中,mockReturnValue应模拟并返回这些值,以检查上下文加载酒店数据时的行为:
import React from 'react';
import { mount } from 'enzyme';
import Hotels from './Hotels';

...

describe('the <Hotels /> component', () => {
  it('should handle the first mount', () => {
+    const mockContext = { 
+      loading: true,
+      error: '', 
+      hotels: [], 
+      getHotelsRequest: jest.fn(),
+    }
+    useContextMock.mockReturnValue(mockContext);
+    const wrapper = mount(<Hotels />);
+
+    expect(mockContext.getHotelsRequest).toHaveBeenCalled();
  });
});

第一个测试场景检查Hotels组件在首次装载时是否会从上下文调用getHotelsRequest函数。这意味着Hotels中使用的useEffect挂钩已经过测试。

  1. 由于数据仍在此处加载,我们还可以测试Alert组件是否正在从上下文中呈现loading值并显示加载消息。在这里,我们需要从src/components/Hotels/Hotels.js中的Hotels导出此组件:
...

- const Alert = styled.span`
+ export const Alert = styled.span`
  width: 100%;
  text-align: center;
`;

const Hotels = ({ match, history }) => {
    ...

现在,我们可以在测试文件中导入此组件并编写断言,以检查它是否显示上下文中的值:

import React from 'react';
import { mount } from 'enzyme';
- import Hotels from './Hotels';
+ import Hotels, { Alert } from './Hotels';

...

describe('the <Hotels /> component', () => {
  it('should handle the first mount', () => {
    const mockContext = { 
      loading: true,
      error: '',
      hotels: [], 
      getHotelsRequest: jest.fn(), 
    }
    useContextMock.mockReturnValue(mockContext);
    const wrapper = mount(<Hotels />);

    expect(mockContext.getHotelsRequest).toHaveBeenCalled();
+   expect(wrapper.find(Alert).text()).toBe('Loading...');
  });
  1. 安装Hotels组件并获取数据后,将更新上下文中的loadingerrorhotels的值。当loadingerror的值为false时,HotelItemsWrapper组件将由Hotels呈现。为了测试这一点,我们需要从Hotels导出HotelItemsWrapper
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { HotelsContext } from '../../Context/HotelsContextProvider';
import SubHeader from '../Header/SubHeader';
import HotelItem from './HotelItem';

- const HotelItemsWrapper = styled.div`
+ export const HotelItemsWrapper = styled.div`
  display: flex;
  justify-content: space-between;
  flex-direction: column;
  margin: 2% 5%;
`;

...

在测试文件中,现在可以导入此组件,这意味着我们可以添加新的测试场景来检查是否呈现此组件:

import React from 'react';
import { mount } from 'enzyme';
- import Hotels, { Alert } from './Hotels';
+ import Hotels, { Alert, HotelItemsWrapper } from './Hotels';

describe('the <Hotels /> component', () => {
  ...

+  it('should render the list of hotels', () => {
+    const mockContext = {
+      loading: false,
+      error: '',
+      hotels: [{
+        id: 123,
+        title: 'Test Hotel',
+        thumbnail: 'test.jpg',
+      }],
+      getHotelsRequest: jest.fn(),
+    }
+    useContextMock.mockReturnValue(mockContext);
+    const wrapper = mount(<Hotels />);

+    expect(wrapper.find(HotelItemsWrapper).exists()).toBe(true);
+  });
});

现在,当我们运行测试时,我们会得到一个错误,显示为Invariant failed: You should not use <Link> outside a <Router>,因为 Enzyme 不能呈现Link组件,当我们点击酒店时,该组件用于导航。因此,我们需要将Hotels组件包装在react-router的路由组件中:

import React from 'react';
import { mount } from 'enzyme';
+ import { BrowserRouter as Router } from 'react-router-dom';
import Hotels, { Alert, HotelItemsWrapper } from './Hotels';

...

describe('the <Hotels /> component', () => {
  ...

  it('should render the list of hotels', () => {
    const mockContext = {
      loading: false,
      alert: '',
      hotels: [{
        id: 123,
        title: 'Test Hotel',
        thumbnail: 'test.jpg',
      }],
      getHotelsRequest: jest.fn(),
    }
    useContextMock.mockReturnValue(mockContext);
-    const wrapper = mount(<Hotels />);
+    const wrapper = mount(<Router><Hotels /></Router>);

    expect(wrapper.find(HotelItemsWrapper).exists()).toBe(true);
  });
});

该测试现在将通过,因为 Enzyme 可以渲染组件,包括导航到酒店的Link

  1. HotelItemsWrapper组件中有一个map函数,该函数迭代上下文中的酒店数据。对于每次迭代,都会呈现一个HotelItem组件。在这些HotelItem组件中,数据将显示在Title组件中。我们可以测试这些组件中显示的数据是否等于模拟的上下文数据。显示酒店名称的组件应从src/components/Hotels/HotelItem.js导出:
- const Title = styled.h3`
+ export const Title = styled.h3`
  margin-left: 2%;
`

HotelItem组件一起,应将其导入Hotels的测试中。在测试场景中,我们现在可以检查<HotelItem组件的存在,并检查该组件是否有Title组件。此组件显示的值应等于hotels数组中第一行标题的模拟上下文值:

import React from 'react';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import Hotels, { Alert, HotelItemsWrapper } from './Hotels';
+ import HotelItem, { Title } from './HotelItem';

...

describe('the <Hotels /> component', () => {
  ...

  it('should render the list of hotels', () => {
    const mockContext = {
      loading: false,
      alert: '',
      hotels: [{
        id: 123,
        title: 'Test Hotel',
        thumbnail: 'test.jpg',
      }],
      getHotelsRequest: jest.fn(),
    }
    useContextMock.mockReturnValue(mockContext);
    const wrapper = mount(<Router><Hotels /></Router>);

    expect(wrapper.find(HotelItemsWrapper).exists()).toBe(true);

+   expect(wrapper.find(HotelItem).exists()).toBe(true);
+ expect(wrapper.find(HotelItem).at(0).find(Title).text()).toBe(mockContext.hotels[0].title);
  });
});

在使用--coverage标志再次运行测试之后,我们将能够看到编写此集成测试对我们的覆盖率有什么影响。由于集成测试不仅测试一个特定组件,而且同时测试多个组件,Hotels的测试覆盖率将更新。本测试还涵盖了HotelItem组件,运行npm run test --coverage后我们可以在覆盖报告中看到:

 PASS src/components/Button/Button.test.js
 PASS src/components/Header/SubHeader.test.js
 PASS src/components/Hotels/Hotels.test.js
----------------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------------------------|----------|----------|----------|----------|-------------------|
All files | 13.27 | 11.24 | 12.77 | 13.73 | |
 ...
 src/components/Hotels | 100 | 83.33 | 100 | 100 | |
 HotelItem.js | 100 | 100 | 100 | 100 | |
 Hotels.js | 100 | 83.33 | 100 | 100 | 33 |

Hotels的覆盖率接近 100%。HotelItems的测试覆盖率也达到了 100%。这意味着我们可以跳过为HotelItem编写单元测试,假设我们只在Hotels组件中使用此组件。

与单元测试相比,集成测试的唯一缺点是它们更难编写,因为它们通常包含更复杂的逻辑。此外,这些集成测试将比单元测试运行得慢,因为它们具有更多的逻辑,并将多个组件组合在一起。

总结

在本章中,我们介绍了使用 Jest 结合react-test-renderer或 Enzyme 进行 React 应用测试。这两个软件包对于希望向其应用添加测试脚本的每个开发人员来说都是一个很好的资源,而且它们也可以很好地与 React 配合使用。本章讨论了为应用进行测试的优点,希望您现在知道如何将测试脚本添加到任何项目中。此外,还显示了单元测试和集成测试之间的差异。

由于本章中测试的应用与前几章中的应用具有相同的结构,因此相同的测试原则可以应用于我们在本书中构建的任何应用

下一章将结合我们在本书中已经使用的许多模式和库,因为我们将使用 React、GraphQL 和 Apollo 创建一个全栈电子商务商店。

进一步阅读