第 9 章 使用 Jest 测试 React 应用

第 9 章 使用 Jest 测试 React 应用

Flying
2018-11-09 / 0 评论 / 467 阅读 / 正在检测是否收录...

到目前为止,你已经创建了许多 React 组件。其中一些非常简单,但也有一些足够复杂。有了这些构建后,你可能已经获得了一定的信心,这让你相信无论用户界面有多复杂,你都可以使用 React 构建它,而不会有任何重大缺陷。这是一个很好的信心。毕竟,这就是我们投入时间学习 React 的原因。然而,许多自信的 React 开发人员都陷入了不编写单元测试的陷阱。

什么是单元测试?顾名思义,这是对应用单个单元的测试。应用中的单个单元通常是一个函数,这意味着编写单元测试意味着为函数编写测试。

为什么要编写单元测试?

你可能想知道为什么要编写单元测试。让我给你讲一个我亲身经历的故事。我发布了一个我最近建立的网站。几天后,使用该网站的同事给我发了一封电子邮件,其中包含两个网站一直拒绝的文件。我仔细检查了这些文件,这两个文件都符合 ID 匹配的要求。然而,文件仍然被拒绝,错误消息显示 ID 不匹配。你能猜出问题出在哪里吗?

我编写了一个函数,检查两个文件的 ID 是否匹配。该函数检查了 ID 的值和类型,因此如果值相同而类型不同,则不会返回匹配;事实证明,我同事的文件就遇到了这种形况。

重要的问题是,我如何才能防止这种情况发生?答案是对我的功能进行了许多单元测试。

创建测试套件、规范和预期

如何编写 JavaScript 函数测试?你需要一个测试框架,幸运的是,Facebook 已经为 JavaScript 构建了自己的单元测试框架 Jest。它的灵感来自 Jasmine —— 另一个著名的 JavaScript 测试框架。

熟悉 Jasmine 的人会发现 Jest 的测试方法非常相似。然而,我不会假设你有使用测试框架的经验,并首先讨论基础知识。

单元测试的基本思想是只测试应用中通常由一个函数实现的一部分功能。你单独测试它,这意味着该函数所依赖的应用的所有其他部分都不会被测试使用。相反,它们会被你的测试模仿。模仿 JavaScript 对象就是创建一个模拟真实对象行为的假对象。在单元测试中,假对象称为 mock,创建它的过程称为 mocking

Jest 在运行测试时自动模拟依赖项。它会自动查找要在存储库中执行的测试。让我们看看下面的示例。

首先,创建 ~/snapterest/source/utils/ 目录。然后,新建一个 TweetUtils.js 文件:

function getListOfTweetIds(tweets) {
  return Object.keys(tweets);
}

module.exports.getListOfTweetIds = getListOfTweetIds;

TweetUtils.js 文件是一个带有 getListOfTweetId() 工具函数供我们应用使用的模块。给定一个包含 tweets 的对象,getListOfTweetId() 返回一个推文 ID 数组。

现在让我们用 Jest 编写第一个单元测试。我们将测试 getListOfTweetId() 函数。

~/snapterest/source/utils/ 中新建 TweetUtils.test.js 文件:

import TweetUtils from '../TweetUtils';

jest.dontMock('../TweetUtils');

describe('Tweet utilities module', function () {
  it('returns an array of TweetIds', function () {
    const tweetsMock = {
      tweet1: {},
      tweet2: {},
      tweet3: {},
    };

    const expectedListOfTweetIds = ['tweet1', 'tweet2', 'tweet3'];
    const actualListOfTweetIds = TweetUtils.getListOfTweetIds(tweetsMock);

    expect(actualListOfTweetIds).toEqual(expectedListOfTweetIds);
  });
});

首先,我们需要引入 TweetUtils 模块:

import TweetUtils from './TweetUtils';

接下来,我们调用一个 Jest 全局函数 describe()。理解其背后的概念很重要。在 TweetUtils.test.js 文件中,我们不只是创建一个测试,而是创建一套测试。套件是一组测试,它们共同测试更大的功能单元。例如,一个套件可以有多个测试,测试更大模块的所有单独部分。在我们的示例中,我们有一个 TweetUtils 模块,它可能包含许多工具函数。在这种情况下,我们将为 TweetUtils 模块创建一个套件,然后为每个单独的工具函数(比如 getListOfTweetId() )创建测试。

describe()` 函数定义一个套件,并接受以下两个参数:

  • 套件名称:描述此测试套件正在测试的内容的标题
  • 套件实现:实现此套件的函数。在我们的示例中,套件如下:
describe('TweetUtils', () => {
  // Test suite implementation goes here
});

如何创建单个测试呢?在 Jest 中,通过调用另一个 Jest 全局函数 test() 来创建单独的测试。与 describe() 一样,test() 函数接受两个参数:

  • 测试名称:描述此测试所测试内容的标题,例如:'getListOfTweetIds returns an array of tweet ids'
  • 测试实现:实现此测试的函数。在我们的示例中,测试如下:
test('getListOfTweetIds returns an array of tweet ids', () => {
  // Test implementation goes here...
});

让我们仔细看看测试的实现:

const tweetsMock = {
  tweet1: {},
  tweet2: {},
  tweet3: {},
};

const expectedListOfTweetIds = ['tweet1', 'tweet2', 'tweet3'];
const actualListOfTweetIds = TweetUtils.getListOfTweetIds(tweetsMock);

expect(actualListOfTweetIds).toEqual(expectedListOfTweetIds);

我们测试 TweetUtils 模块的 getListOfTweetId() 函数,在给定带有推文对象的对象时返回一个推文 ID 数组。

首先,我们将创建一个模拟真实 tweets 对象的 mock 对象:

const tweetsMock = {
  tweet1: {},
  tweet2: {},
  tweet3: {},
};

这个模拟对象的唯一要求是将推文 ID 作为对象键,值不重要。因此我们选择空对象。键名称也不重要,所以选择将它们命名为 tweet1tweet2 tweet3。这个模拟对象并不能完全模拟真实的推文对象,它的唯一用处是模拟其键是推文 ID 的事实。

下一步创建一个预期的推文 ID 列表:

const expectedListOfTweetIds = ['tweet1', 'tweet2', 'tweet3'];

我们知道需要什么推文 ID,因为我们已经用相同的 ID 模拟了推文对象。

下一步我们从模拟推文对象中提取实际的推文 ID。为此,我们使用 getListOfTweetId() 函数获取推文对象并返回一个推文 ID 数组:

const actualListOfTweetIds = TweetUtils.getListOfTweetIds(tweetsMock);

我们将 tweetsMock 对象传递给该函数,并将结果存储在 actualListOfTweetId 常量中。之所以命名为 actualListOfTweetId,是因为这个推文 ID 列表是由我们正在测试的 actualListOfTweetId() 函数生成的。

最后一步将向我们介绍一个新的重要概念:

expect(actualListOfTweetIds).toEqual(expectedListOfTweetIds);

让我们考虑一下测试的过程。我们需要获取我们正在测试的函数产生的实际值,即 getListOfTweetId(),并将其与我们预先知道的预期值相匹配。匹配结果将决定我们的测试是通过还是失败。

我们之所以能够猜测 getListOfTweetId() 将提前返回什么,是因为我们已经为它准备了输入,即我们的模拟对象:

const tweetsMock = {
  tweet1: {},
  tweet2: {},
  tweet3: {},
};

因此,我们可以通过调用 TweetUtils.getListOfTweetIds(tweetsMock)获得以下输出。

['tweet1', 'tweet2', 'tweet3'];

因为 getListOfTweetId() 内部可能会出错,所以我们不能保证这个结果;我们只能预估它。

这就是为什么我们需要创造一种预期。在 Jest 中,使用 expect() 函数构建预期值,该函数取实际值;例如,actualListOfTweetIds 对象:expect(actualListofTweetId)

然后,我们用匹配器函数将其链接起来,该函数将实际值与预期值进行比较,并告诉 Jest 是否满足预期:

expect(actualListOfTweetIds).toEqual(expectedListOfTweetIds);

在我们的示例中,使用 toEqual() 匹配函数来比较两个数组。你可以在 Jest 中找到所有内置匹配器函数的列表https://facebook.github.io/jest/docs/expect.html

这就是你写测试的方式。测试包含一个或多个预期值。每个预期都测试代码的状态。测试可以是通过的测试,也可以是失败的测试。只有当所有预期都得到满足时,测试才是通过的测试;否则,这是一个失败的测试。

做得不错!你已经用一个预期值的测试编写了第一个测试套件!你该如何运行它呢?

安装和运行 Jest

首先,让我们安装 Jest 命令行界面(Jest CLI)模块:

npm install --save-dev jest

这个命令将安装 Jest 模块,并将其作为开发依赖项添加到~/snapterest/package.json 文件中。

在第 2 章*为你的项目安装强大的工具中,我们安装并讨论了 Babel。我们使用 Babel 将新的 JavaScript 语法转换为旧的 JavaScript 语法,并将 JSX 语法编译为普通的 JavaScript 语法。在我们的测试中,我们将测试用 JSX 语法编写的 React 组件,但 Jest 并不理解 JSX 语法。我们需要告诉 Jest 使用 Babel 自动编译我们的测试。为此,我们需要安装 babel jest 模块:

npm install --save-dev babel-jest

现在我们需要配置 Babel。为此,在~/snapterest/目录中新建 .babelrc 文件:

{
  "presets": ["es2015", "react"]
}

接下来,让我们编辑 package.json 文件。我们将替换现有的 "scripts" 对象:

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

用以下对象替换前面的对象:

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

现在我们可以运行测试套件了。转到 ~/snapterest/ 目录,然后运行以下命令:

npm test

你应该在终端窗口中看到以下消息:

PASS source/utils/TweetUtils.test.js

此输出消息告诉你以下信息:

  • PASS: 你的测试通过
  • source/utils/TweetUtils.test.js: Jest 从该文件开始运行测试

这就是编写和测试一个小型单元测试所需的全部。接下来让我们创建另一个!

创建多个测试和预期

这次,我们将创建并测试集合工具模块。在 ~/snapterest/source/utils/ 目录中,新建 CollectionUtils.js 文件:

import TweetUtils from './TweetUtils';

function getNumberOfTweetsInCollection(collection) {
  const listOfCollectionTweetIds = TweetUtils.getListOfTweetIds(collection);
  return listOfCollectionTweetIds.length;
}

function isEmptyCollection(collection) {
  return getNumberOfTweetsInCollection(collection) === 0;
}

export default {
  getNumberOfTweetsInCollection,
  isEmptyCollection,
};

CollectionUtils 模块有两个功能:getNumberOfTweetsInCollection()isEmptyCollection()

首先,让我们看看 getNumberOfTweetsInCollection()

function getNumberOfTweetsInCollection(collection) {
  const listOfCollectionTweetIds = TweetUtils.getListOfTweetIds(collection);
  return listOfCollectionTweetIds.length;
}

如你所见,此函数从 TweetUtils 模块调用 getListOfTweetId() 函数,并将集合对象作为参数传递。getListOfTweetId() 返回的结果存储在 listOfCollectionTweetIds 常量中,由于它是一个数组,getNumberOfTweetsInCollection() 返回该数组的 length 属性。

现在,让我们看看 isEmptyCollection() 函数:

function isEmptyCollection(collection) {
  return getNumberOfTweetsInCollection(collection) === 0;
}

该函数重用了刚才讨论的 getNumberOfTweetsInCollection() 函数。它检查调用 getNumberOfTweetsInCollection() 返回的结果是否等于零。然后,它返回该检查的结果,该结果为 truefalse

请注意,我们从该模块导出两个函数:

export default {
  getNumberOfTweetsInCollection,
  isEmptyCollection,
};

我们刚刚创建了 CollectionUtils 模块,下一个任务是测试它。

~/snapterest/source/utils/ 目录中,新建 CollectionUtils.test.js 文件:

import CollectionUtils from '../CollectionUtils';

describe('CollectionUtils', () => {
  const collectionTweetsMock = {
    collectionTweet7: {},
    collectionTweet8: {},
    collectionTweet9: {},
  };

  test('getNumberOfTweetsInCollection returns a number of tweets in collection', () => {
    const actualNumberOfTweetsInCollection =
      CollectionUtils.getNumberOfTweetsInCollection(collectionTweetsMock);
    const expectedNumberOfTweetsInCollection = 3;

    expect(actualNumberOfTweetsInCollection).toBe(
      expectedNumberOfTweetsInCollection
    );
  });

  test('isEmptyCollection checks if collection is not empty', () => {
    const actualIsEmptyCollectionValue =
      CollectionUtils.isEmptyCollection(collectionTweetsMock);

    expect(actualIsEmptyCollectionValue).toBeDefined();
    expect(actualIsEmptyCollectionValue).toBe(false);
    expect(actualIsEmptyCollectionValue).not.toBe(true);
  });
});

首先我们定义测试套件:

describe('CollectionUtils', () => {
  const collectionTweetsMock = {
    collectionTweet7: {},
    collectionTweet8: {},
    collectionTweet9: {},
  };
  // Tests go here...
});

我们给正在测试的测试套件命模块名称 —— CollectionUtils。现在让我们来看看这个测试套件的实现。我们没有像之前的测试套件那样立即定义测试规范,而是创建了 collectionTweetsMock 对象。那么,我们可以这样做吗?显然测试套件实现函数只是另一个 JavaScript 函数,在定义测试规范之前,我们可以在这里做一些工作。

此测试套件将执行多个测试。我们的所有测试都将使用 collectionTweetsMock 对象,因此在规范范围之外定义它并在规范内重用它是有意义的。正如你可能已经猜到的,collectionTweetsMock 对象模仿了一个推文集合。

现在让我们实现各个测试规范。

我们的第一个规范测试 CollectionUtils 模块是否返回集合中的推文数量:

test('getNumberOfTweetsInCollection returns a number of tweets in collection', () => {
  const actualNumberOfTweetsInCollection =
    CollectionUtils.getNumberOfTweetsInCollection(collectionTweetsMock);
  const expectedNumberOfTweetsInCollection = 3;

  expect(actualNumberOfTweetsInCollection).toBe(
    expectedNumberOfTweetsInCollection
  );
});

我们首先获得模拟集合中实际的推文数量:

const actualNumberOfTweetsInCollection =
  CollectionUtils.getNumberOfTweetsInCollection(collectionTweetsMock);

为此我们调用 getNumberOfTweetsInCollection() 函数,并将 collectionTweetsMock 对象传递给它。然后,我们定义模拟集合中预期的推文数量:

const expectedNumberOfTweetsInCollection = 3;

最后,我们调用 expect() 全局函数来创建预期:

expect(actualNumberOfTweetsInCollection).toBe(
  expectedNumberOfTweetsInCollection
);

我们使用 toBe() 匹配器函数来匹配实际值和预期值。如果现在运行 npm test 命令,你将看到两个测试套件都通过了:

PASS source/utils/CollectionUtils.test.js
PASS source/utils/TweetUtils.test.js

请记住,要使测试套件通过测试,必须通过规范,规范要通过,必须满足所有预期。到目前为止,情况就是这样。

做一个小小的破坏实验怎么样?

打开 ~/snapterest/source/utils/CollectionUtils.js 文件,在 getNumberOfTweetsInCollection() 函数中,转到以下代码行:

return listOfCollectionTweetIds.length;

将其更改为:

return listOfCollectionTweetIds.length + 1;

这个小改动会在任何给定的集合中返回不正确的推文数量。现在再次运行 npm test。你应该看到 CollectionUtils.test.js 中的所有规范都失败了。这是我们感兴趣的:

FAIL source/utils/CollectionUtils.test.js
CollectionUtils › getNumberOfTweetsInCollection returns
a number of tweets in collection
expect(received).toBe(expected)
Expected value to be (using ===):
3
Received:
4
at Object. <anonymous> (source/utils/CollectionUtils.test.js:14:46)

我们以前从未见过失败的测试,所以让我们仔细看看它试图告诉我们什么。

首先,它给了我们一个坏消息,CollectionUtils.test.js 测试失败了:

FAIL source/utils/CollectionUtils.test.js>

然后,它以人性化的方式告诉我们哪个测试失败了:

CollectionUtils › getNumberOfTweetsInCollection returns
a number of tweets in collection

那么,意外的测试结果出了什么问题:

expect(received).toBe(expected)
Expected value to be (using ===):
3
Received:
4

最后,Jest 打印了一个堆栈跟踪,该跟踪为我们提供足够的技术细节,以便快速识别哪部分代码产生了意外结果:

at Object. (source/utils/CollectionUtils.test.js:14:46)

好吧,故意测试失败就这样了。让我们将 ~/snapterest/source/utils/CollectionUtils.js `文件恢复为:

return listOfCollectionTweetIds.length;

Jest 中的测试套件可以有许多规范,用于测试单个模块中的不同函数。我们的 CollectionUtils 模块有两个函数。现在让我们讨论第二个。

CollectionUtils.test.js 中的下一个规范是检查集合是否为空:

test('isEmptyCollection checks if collection is not empty', () => {
  const actualIsEmptyCollectionValue =
    CollectionUtils.isEmptyCollection(collectionTweetsMock);

  expect(actualIsEmptyCollectionValue).toBeDefined();
  expect(actualIsEmptyCollectionValue).toBe(false);
  expect(actualIsEmptyCollectionValue).not.toBe(true);
});

首先我们调用 isEmptyCollection() 函数并将collectionTweetsMock对象传递给它。我们将结果存储在 actualIsEmptyCollectionValue 常量中。请注意,我们是如何重用同一个 collectionTweetsMock 对象的,就像我们之前的规范一样。

接下来,我们创建了三个预期,而不是一个:

expect(actualIsEmptyCollectionValue).toBeDefined();
expect(actualIsEmptyCollectionValue).toBe(false);
expect(actualIsEmptyCollectionValue).not.toBe(true);

你可能已经猜到了我们将预期 actualIsEmptyCollectionValue 常量。

首先,我们预期定义了集合:

expect(actualIsEmptyCollectionValue).toBeDefined();

这意味着 isEmptyCollection() 函数必须返回 undefined 以外的值。

接下来,我们希望其值为 false

expect(actualIsEmptyCollectionValue).toBe(false);

我们之前使用 toEqual() 匹配器函数来比较数组。toEqual() 方法进行深度比较,这非常适合比较数组,但对于诸如 false 之类的原始值来说,这是一种过度比较。

最后,我们希望 actualIsEmptyCollectionValue 不为 true

expect(actualIsEmptyCollectionValue).not.toBe(true);

下一个比较与 .not 相反。它将预期值与 toBe(true)false 的相反值相匹配。

请注意,·toBe(false)not.toB(true) 产生相同的结果。

只有当满足所有三个预期时,这个规范才通过。

到目前为止,我们已经测试了集合工具模块,但如何使用 Jest 测试 React 组件?

我们将在下一节找到答案。

测试 React 组件

让我们暂时停止编写代码,谈谈测试用户界面意味着什么。我们到底在测试什么?我们正在测试这样一个事实,即我们的用户界面渲染出了预期的效果。换句话说,如果我们告诉 React 渲染一个按钮,我们希望它不会渲染更多或更少的按钮。

现在,我们如何检查情况是否属实?这样做的一种方式是编写 React 组件,捆绑我们的应用,在 web 浏览器中运行它,并亲眼看到它显示了我们希望它显示的内容。这是手动测试,我们至少做一次。但从长远来看,这是耗时且不可靠的。

我们如何使这个过程自动化?Jest 可以为我们做大部分工作,但 Jest 没有自己的眼睛,因此它需要为每个组件至少借用我们的眼睛一次。如果 Jest“看不到”渲染 React 组件的结果,那么它怎么能测试 React 组件呢?

在第 3 章创建你的第一个 React 元素中,我们讨论了 React 元素。它们是简单的 JavaScript 对象,描述了我们希望在屏幕上看到的内容。

例如,考虑以下 HTML 标签:

<h1>测试</h1>

这可以由以下纯 JavaScript 对象表示:

{
  type: 'h1',
  children: 'Testing'
}

组件渲染时表示输出的简单 JavaScript 对象,可以描述组件及其行为的某些预期。让我们来看看实际情况。

我们将测试的第一个 React 组件将是 Header 组件。~/snapterest/source/components/ 目录中新建 Header.test.js 文件:

import React from 'react';
import renderer from 'react-test-renderer';
import Header, { DEFAULT_HEADER_TEXT } from '../Header';

describe('Header', () => {
  test('renders default header text', () => {
    const component = renderer.create(<Header />);

    const tree = component.toJSON();
    const firstChild = tree.children[0];

    expect(firstChild).toBe(DEFAULT_HEADER_TEXT);
  });

  test('renders provided header text', () => {
    const headerText = 'Testing';

    const component = renderer.create(<Header text={headerText} />);

    const tree = component.toJSON();
    const firstChild = tree.children[0];

    expect(firstChild).toBe(headerText);
  });
});

现在,你可以分析测试文件结构。首先,我们定义测试套件,并将其命名为 Header。我们的测试套件有两个名为 renders default header textrenders provided header text 测试规范。正如他们的名字所暗示的那样,他们测试了 Header 组件可以渲染默认文本和提供的文本。让我们仔细看看这个测试套件。

首先,我们导入 React 模块:

import React from 'react';

然后,我们导入 react-test-renderer 模块:

import renderer from 'react-test-renderer';

React 渲染器将 React 组件渲染为纯 JavaScript 对象。它不需要 DOM,因此我们可以使用它在 web 浏览器之外渲染 React 组件。它与 Jest 配合得很好。让我们安装它:

npm install --save-dev react-test-renderer

接下来,为了测试 Header 组件,我们需要导入它:

import Header, { DEFAULT_HEADER_TEXT } from '../Header';

我们还从 Header 模块导入 DEFAULT_HEADER_TEXT。这样做是因为我们不想硬编码作为默认文本的实际字符串值。否则将增加维护该值的额外工作。相反,由于 Header 组件知道这个值是什么,所以我们将在测试中导入并重用它。

让我们来看看第一个名为 renders default header text 的测试。本测试中的第一个任务是将 Header 组件渲染分配给纯 JavaScript 对象。react-test-renderer 模块有一个 create 方法,该方法正是这样做的:

const component = renderer.create(<Header />);

我们将 <Header /> 元素作为参数传递给 create() 函数,然后返回一个表示 Header 组件实例的 JavaScript 对象。它还不是组件的简单表示,因此我们的下一步是使用 toJSON 方法将该对象转换为组件的简单树表示:

const tree = component.toJSON();

现在,树也是一个 JavaScript 对象,但它也是我们的 Header 组件的简单表示,我们可以很容易地阅读和理解它:

{ type: 'h2', props: {}, children: [ 'Default header' ] }

我建议你同时打印组件和树对象,看看它们有啥不同:

console.log(component);
console.log(tree);

你将很快看到,组件对象是在 React 内部使用的,很难阅读和区分它代表什么。另一方面,树对象非常容易阅读,并且很清楚它代表什么。

正如你所看到的,到目前为止,我们测试 React 组件的方法是将 <Header /> 转化为 { type: 'h2', props: {}, children: [ 'Default header' ] }。现在我们有了一个表示组件的简单 JavaScript 对象,我们可以检查该对象是否具有预期值。如果确实如此,我们可以得出结论,我们的组件将按照预期在 web 浏览器中渲染。如果没有,那么我们可能引入了一个 bug。

当我们在没有任何属性的情况下渲染 Header 组件时,我们希望 <Header />渲染一个默认文本:Default Header。为了检查是否确实如此,我们需要从 Header 组件的树表示中访问 children 属性:

const firstChild = tree.children[0];

我们希望 Header 组件只有一个子元素,因此文本元素将是第一个子元素。

现在是时候写下我们的预期了:

expect(firstChild).toBe(DEFAULT_HEADER_TEXT);

我们希望 firstChild 具有与 DEFAULT_HEADER_TEXT 相同的值。toBe 匹配器在幕后使用===进行比较。

这是我们的第一个测试!

在第二个名为* renders provided header text 的测试中,我们将测试 Header 组件具有我们通过 text 属性提供的自定义文本:

test('renders provided header text', () => {
  const headerText = 'Testing';

  const component = renderer.create(<Header text={headerText} />);

  const tree = component.toJSON();
  const firstChild = tree.children[0];

  expect(firstChild).toBe(headerText);
});

现在你了解了测试 React 组件背后的核心思想:

  1. 将组件渲染为 JavaScript 对象表示。
  2. 在该对象上查找一些值,并检查该值是否符合你的预期。

正如你所看到的,当你的组件很简单时,这非常容易。但如果你需要测试由其他组件等组成的组件呢?想象一下表示该组件的 JavaScript 对象将是多么复杂。它有许多深度嵌套的属性。你可能会编写和维护大量代码来访问和比较深度嵌套的值。

因为编写单元测试变得过于昂贵,一些开发人员可能会选择完全放弃测试他们的组件。

幸运的是,我们有两种解决方案。

其中之一。还记得当直接遍历和转换 DOM 时工作量太大,所以创建 jQuery 库是为了简化这个过程吗?嗯,对于 React 组件,我们有 Enzyme —— 一个来自 AirBnB 的 JavaScript 测试工具库,它简化了遍历和处理渲染 React 组件生成输出的过程。

Enzyme 是 Jest 的一个独立库。让我们安装它:

npm install --save-dev enzyme jest-enzyme react-addons-test-utils

要将 Enzyme 与 Jest 一起使用,我们需要安装三个模块。记住,Jest 运行我们的测试,而 Enzyme 将帮助我们编写我们的预期。

现在,让我们使用 Enzyme 重写 Header 组件测试:

import React from 'react';
import { shallow } from 'enzyme';
import Header, { DEFAULT_HEADER_TEXT } from './Header';

describe('Header', () => {
  test('renders default header text', () => {
    const wrapper = shallow(<Header />);
    expect(wrapper.find('h2')).toHaveLength(1);
    expect(wrapper.contains(DEFAULT_HEADER_TEXT)).toBe(true);
  });

  test('renders provided header text', () => {
    const headerText = 'Testing';
    const wrapper = shallow(<Header text={headerText} />);
    expect(wrapper.find('h2')).toHaveLength(1);
    expect(wrapper.contains(headerText)).toBe(true);
  });
});

首先,我们从 Enzyme 模块导入 shallow 函数:

import { shallow } from 'enzyme';

然后,在测试中,我们调用 shallow 函数并将 Header 组件作为参数传递:

const wrapper = shallow(<Header />);

我们得到的是一个包装了渲染 Header 组件结果的对象。这个对象是由 Enzyme 的 ShallowWrapper 类创建的,它有一些非常有用的方法供我们使用。我们称之为包装器

我们有了这个 wrapper 对象就可以编写预期了。注意与 react-test-renderer 不同,我们使用 Enzyme 时不需要将 wrapper 对象转换为组件的最简表示。这是因为我们不打算直接遍历包装器对象,它不是一个容易阅读的简单对象;尝试打印该对象看看。相反,我们将使用 Enzyme 的 ShallowWrapper API 提供的方法。

让我们编写第一个预期:

expect(wrapper.find('h2')).toHaveLength(1);

如你所见,我们正在包装器对象上调用 find 方法。这就是 Enzyme 的力量。与直接遍历 React 组件输出对象并查找嵌套元素不同,我们只需调用 find 方法并告诉它我们要查找的内容。在本例中,我们告诉 Enzyme 在包装器对象中查找所有 h2 元素,因为它包装了 Header 组件的输出,所以我们希望包装器对象只有一个 h2 元素。我们使用 Jest 的 toHaveLength 匹配器来检查这一点。

这是我们的第二个预期:

expect(wrapper.contains(DEFAULT_HEADER_TEXT)).toBe(true);

正如你所猜测的,我们正在检查包装器对象是否包含 DEFAULT_HEADER_TEXT。此检查允许我们得出结论:当不提供任何自定义文本时,Header 组件将渲染默认文本。我们将使用 Enzyme 的 contains 方法,该方法允许我们方便地检查组件是否包含任何节点。在本例中,我们要检查文本节点。

Enzyme 的 API 为我们提供了更多的方法来方便地检查组件的输出。我建议你通过阅读官方文档来熟悉这些方法:http://airbnb.io/enzyme/docs/api/shallow.html

你可能想知道如何测试 React 组件的行为,这是我们接下来要讨论的。

~/snapterest/source/components/ 目录中新建 Button.test.js 文件:

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

describe('Button', () => {
  test('calls click handler function on click', () => {
    const handleClickMock = jest.fn();

    const wrapper = shallow(<Button handleClick={handleClickMock} />);

    wrapper.find('button').simulate('click');

    expect(handleClickMock.mock.calls.length).toBe(1);
  });
});

Button.test.js 文件将测试我们的 Button 组件,具体来说,当你单击它时,检查它是否触发单击事件处理程序函数。无需赘述,让我们关注 'calls click handler function on click' 规范的实现:

const handleClickMock = jest.fn();

const wrapper = shallow(<Button handleClick={handleClickMock} />);

wrapper.find('button').simulate('click');

expect(handleClickMock.mock.calls.length).toBe(1);

在本规范中,我们测试了 Button 组件调用通过 handleClick 属性提供的函数。以下是我们的测试策略:

  1. 生成模拟函数。
  2. 使用我们的模拟函数渲染 Button 组件。
  3. 在 Enzyme 创建的包装器对象中查找作为渲染 Button 组件结果的 button 元素。
  4. 模拟该 button 元素上的单击事件。
  5. 检查我们的模拟函数是否被调用了一次。

现在我们有了一个计划,让我们实施它。让我们先创建一个模拟函数:

const handleClickMock = jest.fn();

调用 fn() 函数返回新生成的 Jest 模拟函数;我们将其命名为 handleClickMock。

接下来,我们通过调用 Enzymed 的 shallow 函数:

const wrapper = shallow(<Button handleClick={handleClickMock} />);

我们将 handleClickMock 函数作为属性传递给 Button 组件。

然后,我们找到 button 元素,并给它模拟一个单击事件:

wrapper.find('button').simulate('click');

此时,我们的 button 元素将调用相应的 onClick 事件处理程序,在本例中,它是 handleClickMock 函数。这个模拟函数会记录它被调用一次的事实,或者至少这是我们希望的 Button 组件行为。让我们这样预期:

expect(handleClickMock.mock.calls.length).toBe(1);

我们该如何检查 handleClickMock 函数被调用了多少次呢? handleClickMock 函数有一个特殊的 mock 属性,我们可以检查该属性以了解 handleClickMock 被调用的次数:

expect(handleClickMock.mock.calls.length);

反过来,我们的 mock 对象有一个 calls 对象,它知道对 handleClickMock 函数的每个调用的所有信息。calls 对象是一个数组,在上述例子中,我们希望它的 length 属性值等于 1。

如你所见,使用 Enzyme 更容易编写预期值。我们的测试需要更少的工作来编写,并长期维护它们。这很好,因为现在我们有更多的动力来编写更多的测试。

但我们能让 Jest 编写测试更容易吗?

事实证明我们可以。

现在,我们将 React 组件渲染为对象表示,然后只使用 Jest 或借助 Enzyme 检查该对象。这种检查要求我们作为开发人员编写额外的代码来测试。我们如何避免这种情况?

我们可以将 React 组件渲染为一个可以轻松阅读理解的文本字符串,然后我们可以将该文本表示存储在代码库中。稍后,当我们再次运行测试时,我们可以简单地创建一个新的文本表示,并将其与我们存储的文本表示进行比较。如果它们不同,那么这可能意味着要么我们有意地更新了组件后也需要更新文本表示,要么我们在组件中引入了一个 bug,现在它生成了一个意外的文本表示。

这个想法在 Jest 中被称为快照测试。让我们使用快照测试重写 Header 组件的测试。替换 Header.test.js 文件中的现有代码,包含以下新代码:

import React from 'react';
import renderer from 'react-test-renderer';
import Header from './Header';

describe('Header', () => {
  test('renders default header text', () => {
    const component = renderer.create(<Header />);
    const tree = component.toJSON();
    expect(tree).toMatchSnapshot();
  });

  test('renders provided header text', () => {
    const headerText = 'Testing';
    const component = renderer.create(<Header text={headerText} />);
    const tree = component.toJSON();
    expect(tree).toMatchSnapshot();
  });
});

正如你所看到的,在这种情况下,我们没有使用 Enzyme,这对我们来说应该是有意义的,因为我们不想再检查任何东西了。

另一方面,我们再次使用 react 测试渲染器模块来渲染组件并将其转换为一个简单的 JavaScript 对象,我们将其命名为 tree

const component = renderer.create(<Header />);
const tree = component.toJSON();

执行快照测试的关键代码行是这一行:

expect(tree).toMatchSnapshot();

我们只是告诉 Jest,希望树对象与现有快照匹配。请稍等,我们还没有现有的快照。观察的得很仔细!那么在这种情况下会发生什么呢?Jest 找不到该测试的现有快照,而是将为该测试创建第一个快照。

让我们运行测试命令:

npm test

所有测试都应该通过,你应该看到以下输出:

Snapshot Summary
› 2 snapshots written in 1 test suite.

Jest 告诉我们,它为每个在 Header.test.js 中查找到的测试创建了一张快照。Jest 将这两张快照存储在哪里?如果检查 ~/snapterest/source/components/ 目录,你将在其中找到一个新文件夹:snapshots。在该文件夹中,你将找到 Header.test.js.snap。打开此文件并查看其内容:

// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header renders default header text 1`] = `
<h2
  style={
    Object {
    "display": "inline-block",
    "fontSize": "16px",
    "fontWeight": "300",
    "margin": "20px 10px",
    }
  }
>
  Default header
</h2> 
`;
exports[`Header renders provided header text 1`] = `
<h2
  style={
    Object {
    "display": "inline-block",
    "fontSize": "16px",
    "fontWeight": "300",
    "margin": "20px 10px",
    }
  }
>
  Testing
</h2>
`;

在这个文件中可以看到的是 Header 组件在使用 Jest 渲染时生成的输出的文本表示。我们很容易阅读这个文件并确认这是我们希望 Header 组件渲染的内容。现在我们的 Header 组件有自己的快照。将这些快照作为源代码的一部分进行处理和存储非常重要。

如果你有 Git 存储库,你应该将它们提交到 Git 存储中,并且你应该知道对它们所做的任何更改。

现在你已经看到了三种不同的 React 测试方法,你需要自己选择如何测试 React 组件。现在我建议你使用快照测试和 Enzyme。

太好了,我们已经编写了四个测试套件。现在是运行所有测试的时候了。转到 ~/snapterest/ 并运行以下命令:

npm test

所有测试套件都应通过:

source/utils/CollectionUtils.test.js
PASS source/utils/TweetUtils.test.js
Snapshot Summary
› 2 snapshots written in 1 test suite.
Test Suites: 4 passed, 4 total
Tests: 6 passed, 6 total
Snapshots: 2 added, 2 total
Time: 2.461s
Ran all test suites.

这些日志消息可以帮助你在晚上睡得好,去度假,不需要经常查看你的工作邮件。

做得不错!

总结

现在你知道如何创建 React 组件并对其进行单元测试。

在本章中,你从 Facebook 学习了 Jest 的基本要素,该框架可以与 React 一起使用。你了解了 Enzyme 库,并了解了它如何简化 React 组件的单元测试。我们讨论了测试套件、规格、预期和匹配器。我们创建了模拟和模拟点击事件。

下一章,你将学习 Flux 架构的基本内容,以及如何提高 React 应用的可维护性。

9

评论 (0)

取消