九、用 Jest 测试 React 应用

现在,您已经创建了许多 React 组件。其中有些相当简单,但有些足够复杂。在构建了这两个界面之后,您可能已经获得了一定的信心,这使您相信,无论用户界面多么复杂,您都可以使用 React 来构建它,而不会出现任何重大缺陷。这是一个很好的自信。毕竟,这就是为什么我们要把时间花在学习上。然而,有一个陷阱,许多自信的 React 开发人员陷入了不编写单元测试的行为中。

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

为什么要编写单元测试?

您可能想知道为什么要编写单元测试。让我告诉你一个我个人经历的故事。我发布了一个我最近建立的网站。几天后,正在使用该网站的同事向我发送了一封电子邮件,其中包含两个文件,该网站一直拒绝接收。我仔细检查了这些文件,两个文件都满足了 ID 匹配的要求。但是,文件仍然被拒绝,错误消息说 ID 不匹配。你能猜出问题出在哪里吗?

我编写了一个函数来检查两个文件中的 ID 是否匹配。函数同时检查 ID 的值和类型,因此如果值相同而类型不同,它将不返回匹配;事实证明,我同事的文件就是这样。

重要的问题是,我如何防止这种情况发生?答案是对我的函数进行了大量的单元测试。

创建测试套件、规格和期望

如何编写 JavaScript 函数测试?你需要一个测试框架,幸运的是,Facebook 已经为 JavaScript 构建了自己的单元测试框架,名为Jest。它的灵感来自Jasmine——另一个著名的 JavaScript 测试框架。熟悉 Jasmine 的人会发现 Jest 的测试方法非常相似。但是,我不会对您之前的测试框架经验做任何假设,而是先讨论基本知识。

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

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

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

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

export default { getListOfTweetIds };

TweetUtils.js文件是一个具有getListOfTweetIds()实用功能的模块,供我们的应用使用。给定包含 tweets 的对象,getListOfTweetIds()返回 tweet id 数组。

现在,让我们用 Jest 编写我们的第一个单元测试。我们将测试我们的getListOfTweetIds()功能。

~/snapterest/source/utils/内创建TweetUtils.test.js文件:

import TweetUtils from './TweetUtils';

describe('TweetUtils', () => {
  test('getListOfTweetIds returns an array of tweet ids', () => {
    const tweetsMock = {
      tweet1: {},
      tweet2: {},
      tweet3: {}
    };
    const expectedListOfTweetIds = [
      'tweet1',
      'tweet2',
      'tweet3'
    ];
    const actualListOfTweetIds = TweetUtils.getListOfTweetIds(
      tweetsMock
    );

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

首先,我们需要TweetUtils模块:

import TweetUtils from './TweetUtils';

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

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);

当给定一个包含 tweet 对象的对象时,我们测试TweetUtils模块的方法getListOfTweetIds()是否返回 tweet id 数组。

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

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

此模拟对象的唯一要求是将 tweet ID 作为对象键。这些值并不重要,因此我们选择空对象。密钥名称也不重要,因此我们选择将它们命名为tweet1tweet2tweet3。这个模拟对象并不完全模拟真实的 tweet 对象,它的唯一目的是模拟它的键是 tweet id 这一事实。

下一步是创建预期的 tweet ID 列表:

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

我们知道 tweet ID 应该是什么,因为我们已经用相同的 ID 模拟了 tweets 对象。

下一步是从模拟 tweets 对象中提取实际的 tweet ID。为此,我们使用getListOfTweetIds()方法获取 tweets 对象并返回 tweet ID 数组:

const actualListOfTweetIds = TweetUtils.getListOfTweetIds(
  tweetsMock
);

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

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

expect(actualListOfTweetIds)
  .toEqual(expectedListOfTweetIds);

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

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

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

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

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

因为getListOfTweetIds()内部可能出现问题,我们无法保证这一结果;我们只能期待它。

这就是为什么我们需要创造一个期望。开玩笑的是,一个期望是使用expect()函数构建的,该函数取一个实际值;例如,actualListOfTweetIds对象:expect(actualListOfTweetIds)

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

expect(actualListOfTweetIds)
  .toEqual(expectedListOfTweetIds);

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

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

做得好,您已经编写了第一个测试套件,其中一个测试只有一个期望!你怎么能运行它?

安装运行 Jest

首先,我们来安装的Jest 命令行界面Jest CLI模块:

npm install --save-dev jest

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

第 2 章为您的项目安装强大的工具中,我们安装并讨论了巴贝尔。我们使用 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模块调用getListOfTweetIds()方法,并将collection对象作为参数传递。getListOfTweetIds()返回的结果存储在listOfCollectionTweetIds常量中,由于它是一个数组,getNumberOfTweetsInCollection()返回该数组的length属性。

现在,让我们来看看 Apple T0.方法:

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模块是否返回集合中的大量 tweet:

test('getNumberOfTweetsInCollection returns a numberof 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;

这个微小的更新将在任何给定的集合中返回错误数量的 tweet。现在再跑一次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.<anonymous> (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.toBe(true)产生相同的结果。

只有当所有三个期望都得到满足时,该规范才能通过。

到目前为止,我们已经测试了实用程序模块,但是如何使用 Jest 测试 React 组件呢?

我们下一步会知道的。

测试 React 组件

让我们从编写代码后退一步,讨论一下测试用户界面意味着什么。我们到底在测试什么?我们正在测试我们的用户界面是否按预期呈现。换句话说,如果我们告诉 React 渲染一个按钮,我们希望它渲染一个按钮,而不是更多,而不是更少。

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

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

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

例如,考虑这个 HTML 标记:

<h1>Testing</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 组件。它非常适合开玩笑。让我们安装它:

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

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

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

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

让我们来看看我们的第一个测试名为本测试的第一个任务是将Header组件呈现给普通 JavaScript 对象。react-test-renderer模块有的create方法,该方法正是这样做的:

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

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

const tree = component.toJSON();

现在,tree也是一个 JavaScript 对象,但它也是我们Header组件的一个简单表示,我们可以轻松阅读和理解:

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

我建议您同时记录componenttree对象,看看它们有多不同:

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

你很快就会发现component对象是 React 内部使用的,很难阅读和说出它代表什么。另一方面,tree对象非常容易阅读,并且很清楚它代表什么。

如您所见,到目前为止,我们测试 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);

这里我们期望firstChildDEFAULT_HEADER_TEXT具有相同的值。在幕后,toBematcher 使用===进行比较。

这是我们的第一次测试!

在我们名为'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 组件,我们有——来自 AirBnB 的 JavaScript 测试实用程序库,它简化了遍历和操作呈现 React 组件产生的输出的过程。

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

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

要将酶与 Jest 结合使用,我们需要安装三个模块。记住,Jest 运行我们的测试,而酶将帮助我们编写我们的期望。

现在,让我们用酶重写我们的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组件的结果。这个对象是由 Ezyme 的ShallowWrapper类创建的,有一些非常有用的方法供我们使用。我们称之为wrapper

现在我们有了这个wrapper对象,我们准备好写下我们的期望。请注意,与react-test-renderer不同,使用酶时,我们不需要将wrapper对象转换为成分的简化表示。这是因为我们不会直接遍历我们的wrapper对象,它不是一个简单的对象,我们很容易阅读;尝试记录该对象并亲自查看它。相反,我们将使用酶的ShallowWrapperAPI 提供的方法。

让我们写下我们的第一个期望:

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

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

这是我们的第二个期望:

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

您可以猜到,我们正在检查包装器对象是否包含DEFAULT_HEADER_TEXT。此检查允许我们得出结论,当我们不提供任何自定义文本时,Header组件呈现默认文本。我们使用酶的contains方法,可以方便地检查我们的成分是否含有任何节点。在本例中,我们正在检查文本节点。

酶的 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组件,特别是当您单击它时,检查它是否触发 click 事件处理程序函数。不用多说,让我们关注一下'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. 在 Ezyme 创建的包装器对象中查找作为呈现我们的Button组件的结果的button元素。
  4. button元素上模拟一个点击事件。
  5. 检查是否只调用了一次模拟函数。

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

const handleClickMock = jest.fn();

jest.fn()函数调用返回新生成的 Jest mock 函数;我们把它命名为handleClickMock

接下来,我们通过调用酶的shallow函数得到Button组件的输出:

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被调用了多少次:

handleClickMock.mock.calls.length

反过来,我们的mock对象有一个calls对象,它知道对handleClickMock函数的每个调用的一切。calls对象是一个数组,在本例中,我们希望其length属性等于 1。

正如您所看到的,使用酶更容易编写期望值。我们的测试首先需要更少的工作来编写它们并长期维护它们。这很好,因为现在我们有更多的动力来编写更多的测试。

但是我们能让开玩笑的测试更容易吗?

事实证明我们可以。

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

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

这个想法被戏称为快照测试。让我们使用快照测试重写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();
  });
});

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

另一方面,我们再次使用react-test-renderer模块将我们的组件呈现并转换为一个简单的 JavaScript 对象,我们将其命名为tree

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

const tree = component.toJSON();

执行快照测试的关键代码行如下:

expect(tree).toMatchSnapshot();

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

让我们运行 test 命令:

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 组件。现在我建议您使用快照测试和酶。

很好,我们已经编写了四个测试套件。现在是运行所有测试的时候了。

导航到~/snapterest/并运行此命令:

npm test

您的所有测试套件应PASS

PASS  source/components/Button.test.js 
PASS  source/components/Header.test.js 
PASS  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 配合良好。您已经了解了酶库,并了解了它如何简化 React 组件的单元测试。我们讨论了测试套件、规格、期望和匹配器。我们创建了模拟和模拟点击事件。

在下一章中,您将学习 Flux 体系结构的要点以及如何提高 React 应用的可维护性。