如何使用 Jest 测试 React 组件

Jest是一个由Facebook维护的测试框架,在这篇文章中我们将学习一下如何使用Jest来测试我们的ReactJS组件。在学习它提供的使测试React apps更容易的新特性之前,我们先了解一下如何用Jest测试简单的JavaScript函数,Jest没有特别针对React,你可以用它测试任何JavaScript程序。然而,它提供了一些特性便于测试用户接口,这也是为什么它非常适合React。

示例应用程序

在测试之前,我们先得有一个应用程序来做测试才行! 秉承WEB开发的优良传统,我整了一个待办事项应用程序来做起步阶段的练手项目。在 GitHub 上你可以找到这个项目,我把它跟所有的测试代码放到了一起。如果想要先用用这个应用程序,好让你自己能具体了解到它可以做些什么,那么,你可以点击这里看到一个在线运行的示例版本。

程序是用 ES2015 写的, 编译时要用到预置了 ES2015 和 React 的 Webpack。关于构建的细节我不会讲太多,如果你想了解更多的话,这些资源都在 GitHub 上。关于如何在本地让项目运行起来,在 README 中有完整的说明。程序是使用 Webpack 构建的,所以如果你想要了解更多的东西的话, 我的建议是读一读《Webpack 入门指南》,它对这个工具有很好的介绍。

app/index.js 是这个应用程序的入口, 它只是将 Todos 组件渲染到了 HTML 里面:

render(
  <Todos />,
  document.getElementById('app')
);

Todos 组件是这个程序的重中之重,它包含了所有的状态(在这个程序里面是硬编码的——实际应用中要从一个API或者是其它类似的来源那里获取才好),还有一些代码是用来渲染两个子组件的: Todo, 在状态里面的每一个待办事项都会让它被渲染一遍,还有就是 AddTodo, 它会被渲染一次,提供给用户一个表单来添加新的待办事项。

因为 Todos 组件包含了所有的状态, 所以就需要 Todo 和 AddTodo 组件来通知它啥时候有改变发生。由此它会在某些数据发生改变时给这些组件下发一些函数来调用,而 Todos 也就能够有依据地对状态进行更新。

    最后,也就是现在,你会发现,所有的业务逻辑都包含在了app/state-functions.js中。

export function toggleDone(state, id) {...}

export function addTodo(state, todo) {...}

export function deleteTodo(state, id) {...}

    这些都是纯函数,它们可以接受状态和一些数据,并返回新的状态。如果你对纯函数不太熟悉的话,他们只是提供一些参考数据的函数,并没有其它什么副作用。要了解更多相关内容,你可以阅读我在A List Apart上面写的有关纯函数的文章以及SitePoint上有关纯函数和React的文章

如果你熟悉Redux,他们和Redux中称为减速器的东西非常类似。事实上,如果应用程序的规模变得很大,我会考虑转而使用Redux,因为这样可以得到一个更明确的、结构化的数据。但对于这种规模的应用程序,你通常会发现,本地组件状态并具有良好的抽象功能,这才是最重要的。

用还是不用 TDD 方法呢?

许多的文章都描述过有关于测试驱动开发(Test Driven Development)的利弊,它所倡导的就是开发者实现写好测试代码,然后再编写代码来满足这些测试的要求。这个创意的背后意义就是通过先把测试写好,让你不得不去深入思考你所要编写的API,如此就可以得到一个更好的设计。在我看来,这很多都是要因人而定的,而且也要看测试的是什么东西。我发现对于 React组件来说,自己更喜欢先把组件写好,然后再对功能最重要的部分添加一些测试。不过不过你发现先给组件写好测试代码适合于自己的工作流程,那么就应该这样做,其实没啥硬性规定的。只要对你和你的团队有利,怎样都好。

Jest 介绍

Jest 第一次发布是在 2014 年,虽然它在最初获得了很多关注,但后面沉寂了一段时间没有进行积极的研发。不过在去年由 Facebook 发起的投资,对 Jest 进行改善,并且在最近发布了一个新的版本,带来了一些令人印象深刻的改变,重新引发了我们的关注。同最开始的开放源代码版本作比较,Jest与其的相似之处只有名称和logo,而其它所有的东西都已经改变和重写了。如果你想了解更多,可以读一读 Christoph Pojer 的评论,里面有他对项目当前状态的讨论。

如果你在使用其它框架对  Babel, React 和 JSX 进行测试时遇到过挫折,那么我绝对会推荐你尝试一下 Jest。如果你发现现有的测试设置太慢了,我也会强烈推荐Jest。它可以自动地让测试并行运行,这个在当你有一个很大的测试项目要跑的时候会很有价值。它附带有对 JSDom 的配置, 意味着你可以在浏览器里面编写测试,然后通过 Node 将它们跑起来,如此即可应对异步功能的测试,并且它还拥有一些高级的功能,比如内置的MOCK测试,SPY测试以及STUB测试。

安装和配置Jest

首先我们需要安装Jest。因为要同时使用Babel,所以我们还需安装另外几个模块,以使得Jest和Babel可以协同工作:

npm install --save-dev babel-jest babel-polyfill babel-preset-es2015 babel-preset-react jest

你还需要为Babel配置一个.babelrc文件,这样可以使用所需的任何预设及插件。示例项目已经包含了该文件,就像:

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

我们还没有安装任何的React测试工具,因为我们还不打算开始测试我们的部件,但是我们的状态函数除外。

Jest希望在__tests__文件夹中找到我们的测试,这已成为JavaScript社区的惯例,我们也打算坚持这样做。如果你不喜欢__tests__这个设置,Jest也支持寻找任意的.test.js和.spec.js文件。

因为我们要测试我们的状态函数,所以继续下一步,创建__tests__/state-functions.test.js。

然后我们将编写一个测试文件,不过现在要进行dummy测试,这样可以检查所有工作是否正常以及对Jest的配置情况。

describe('Addition', () => {
  it('knows that 2 and 2 make 4', () => {
    expect(2 + 2).toBe(4);
  });
});

现在,进入你的package.json。我们需要设置npm test,以便它运行Jest,只需通过设置test脚本来运行Jest就可以。

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

如果你现在在本地运行npm test,你可以看到测试运行正常,并通过。

PASS  __tests__/state-functions.test.js
  Addition
    ✓ knows that 2 and 2 make 4 (5ms)

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

如果你曾经使用过Jasmine,或者大多数测试框架,那么对于以上的测试代码你应该相当熟悉。Jest让我们根据需要进行usedescribeanditto nest测试。至于要使用多少个nest完全取决于你了;我喜欢所有的描述性字符可以通过,todescribeanditread几乎是一个完整的句子。

当要进行实际的声明时,你会希望在此之前通过expect()调用进行测试。在这种情况下,我们使用toBe。你可以在Jest文档找到所有可用的声明列表,toBe会使用===检查给定的值是否匹配测试中的值。在本教程中,我们会遇到几个Jest的声明。

测试业务逻辑

当看到Jest可以在模拟测试上运行时,接着就可以在真实案例上运行了!接下来测试的是第一个状态方法,toggleDone。toggleDone的参数是即将切换状态的todo的当前状态和它的ID。每个todo都有一个done属性,toggleDone方法应该将它由true改为false,或者相反的变更。

如果你在按本文步骤操作的话,请确保你已经克隆这个 仓库,并已拷贝到和你的___tests__文件夹同一个父文件夹下。同时也需要安装shortid包,(npm install shortid --save) 这个Todo app所依赖的包。

先从app/state-functions.js中引入待测试方法,然后建立测试结构。虽然Jest允许无限级的嵌套调用dsecribe和it,你还可以用test方法,因为它往往看起来更容易阅读。Jest中的test方法只是it的一个别名,但是有时可以让测试代码更易于阅读也不用很多的嵌套。

使用嵌套的describe和it方法,我可能写出如下案例:

import { toggleDone } from '../app/state-functions';

describe('toggleDone', () => {
  describe('when given an incomplete todo', () => {
    it('marks the todo as completed', () => {
    });
  });
});

而用test则可能是下面这个样子:

import { toggleDone } from '../app/state-functions';

test('toggleDone completes an incomplete todo', () => {
});

test方法的看起来更好,但是缩进可能会少一点。这主要与个人喜好相关,你可以选一个你更习惯的书写方式。

接着就可以写断言了。首先,在传入toggleDone之前先创建一个初始状态,同时传入需要转换状态的todo的ID。基于toggleDone方法返回的完成状态,我们这样写断言:

const startState = {
  todos: [{ id: 1, done: false, name: 'Buy Milk' }]
};

const finState = toggleDone(startState, 1);

expect(finState.todos).toEqual([
  { id: 1, done: true, name: 'Buy Milk' }
]);

注意这里是用toEqual作的断言。对于原始类型,如字符串和数字,应该使用toBe方法。toEqual是用于处理数字和对象的,它会递归的比较给定对象的所有字段或者元素以确认相等。

至此,我们便可以运行npm test,然后可以看到这个状态方法通过了测试:

PASS  __tests__/state-functions.test.js
  ✓ tooggleDone completes an incomplete todo (9ms)

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

变更后重新运行测试

更改测试文件,需要再次手动运行 npm test,这点令人沮丧。Jest 的最佳功能是在观察模式,它会观察文件的更改来运行测试。它依据哪些文件被更改,找出哪些子集需要测试运行。这令人难以置信的强大和可靠,用户可以把 Jest 运行在观察模式,然后将其放在一边,之后的一整天都可以只专注于修改代码了。

在观察模式下运行,可以运行 npm test -- --watch。在 npm test 之后,第一个 -- 之前,将命令直接传递给底层。以下这两个命令是等效的:

我建议把 Jest 的运行留在另一个 tab 或者终端窗口中。

在测试 React 组件之前,我们对其中一个状态函数编写一个测试。在真实的应用程序中可能需要编写很多测试,但是为了本教程,我跳过了他们。现在,让我们编写一个测试,确保我们的 deleteTodo 函数工作。你可以尝试自己编写并与你自己的测试进行比较。

显示测试

测试的第一部分改变不多,我们设置了初始状态运行我们的函数并完成维护状态。如果你离开 Jest 运行着的观察者模式,那么你的新测试将不能被检测到,也不会被运行,更不能像在 Jest 中那么快!在你测试,编写它们的时候,这种获得即时反馈的方式是很伟大的。

上面的测试证明了对一个测试的完美安排是这样的:

以这种方式通过测试,你会发现他们更容易被跟踪和处理。

现在我很乐于测试我们的状态函数,让我们把目光转移到 React 组件吧。

测试React组件

出于其意义甚微,事实上我一般不推荐为React组件写太多的测试。你需要测试的东西,比如业务逻辑,本来就应该从组件中抽离出去,然后落实到独立的方法中去,比如我们上文测试的状态方法。也即是说,测试React的交互(比如,确保当用户点击一个按钮是指定的方法会以正确的参数被调用)只是偶尔才有用。我们将从测试React组件可以渲染正确的数据开始测试,然后测试交互。然后进行的是快照,Jest的一个可以使测试React组件输出变得方便的特性。

因此我们必须使用react-addons-test-utils,一个提供了测试React方法的库。还需要安装EnzymeAirBnB写的一个可以使测试React组件变得简单的包装库。我们将在所有的测试中使用这个API。Enzyme是一个十分杰出的库,甚至React团队都在推荐用它测试React组件。

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

我们将测试Todo组件能否将它的todo文本渲染到一个段落。首先要创建__tests__/todo.test.js,然后导入我们的组件:

import Todo from '../app/todo';
import React from 'react';
import { mount } from 'enzyme';

test('Todo component renders the text of the todo', () => {
});

这里也从Enzyme中导入了mount。mount方法是用于渲染组件,其输出则用于检查和断言。虽然是在Node中测试,我们却可以在写测试时引入DOM元素。因为Jest配置了jsdom,一个在Node中实现了DOM的库。这是一个很棒的库,通过它我们就可以写基于DOM的测试,且在测试的时候不用每次都打开浏览器。

可以用mount方法创建Todo:

const todo = { id: 1, done: false, name: 'Buy Milk' };
const wrapper = mount(
  <Todo todo={todo} />
);

然后调用wrapper.find方法,传入一个CSS选择器,用于查找需要包含Todo内容的段落。这个API可能会让你想起jQuery,因为它是故意这么设计的。在渲染的输出中查找匹配的元素用时使用这个API看起来十分直观:

const p = wrapper.find('.toggle-todo');

最后,断言它包含的文本是Buy Milk:

expect(p.text()).toBe('Buy Milk');

完整的测试代码将会是下面这个样子:

import Todo from '../app/todo';
import React from 'react';
import { mount } from 'enzyme';

test('TodoComponent renders the text inside it', () => {
  const todo = { id: 1, done: false, name: 'Buy Milk' };
  const wrapper = mount(
    <Todo todo={todo} />
  );
  const p = wrapper.find('.toggle-todo');
  expect(p.text()).toBe('Buy Milk');
});

 

哇哦!可能你觉得为了测试‘Buy Milk’可以显示到正确的位置的确需要很多的工作,呃……确实如此。但是先不必着急,下一节将会看到用Jest的快照功能会使这个测试变简单很多。

此时请先看一下,如何用Jest的监听功能来断言方法是否以指定参数调用。对于本例来说这个测试是很有用的,因为我们的Todo组件包含了两个函数类型的属性,它们是在用户点击按钮或者触发交互时被调用的。

下面的测试会断言当点击todo按钮的时候,会调用给定组件的doneChange方法。

test('Todo calls doneChange when todo is clicked', () => {
});

现在需要有个可以跟踪方法调用以及调用时的参数的函数。然后就可以检查当用户点击todo时,doneChange会被以正确的参数调用。幸好,Jest提供了开箱即用的监听功能。监听方法是无需关注其实现,只需考虑其调用时机及方式的方法。可以想象为你在监视那个方法。可以调用Jest.fn()创建:

const doneChange = jest.fn();

返回的方法可以用于监听以检查其是否被正确调用。一开始要渲染一个有正确属性的Todo:

const todo = { id: 1, done: false, name: 'Buy Milk' };
const doneChange = jest.fn();
const wrapper = mount(
  <Todo todo={todo} doneChange={doneChange} />
);

接着,再次查找那个段落,如同上一个测试:

const p = TestUtils.findRenderedDOMComponentWithClass(rendered, 'toggle-todo');

然后可以调用它的simulate方法,模拟用户事件,传入参数click:

p.simulate('click');

最后只需断言监视方法可以被正确调用。本例中,期望调用时传入todo的ID也就是1。可以用expect(doneChange).toBeCalledWith(1)来断言,此时也完成了我们的测试!

test('TodoComponent calls doneChange when todo is clicked', () => {
  const todo = { id: 1, done: false, name: 'Buy Milk' };
  const doneChange = jest.fn();
  const wrapper = mount(
    <Todo todo={todo} doneChange={doneChange} />
  );

  const p = wrapper.find('.toggle-todo');
  p.simulate('click');
  expect(doneChange).toBeCalledWith(1);
});

使用组件的快速测试更好

我上面提及的看起来测试React组件需要大量工作,特别是一些比较通用功能(比如渲染文本)。Jest允许你快速测试,而不是在React组件里使用大量断言。这些对于交互没有多大用处(但是我依然选择使用我上面提及的),但是对于测试组件的输出是否正确,它使用起来非常简单。

当你运行一个快速测试,Jest在测试时渲染React组件,并且益JSON格式文件来存储结果。每次运行测试,Jest将会快速检测React组件是否依然渲染相同输出。然后,当你改变一个组件的行为,Jest将会告诉你下面的其中一种情况:

这种测试方式意味着:

你也不用对你所有组件进行快速测试,事实上,我不推荐这种方式。你可以选择一些需要确认的组件进行功能测试。快速测试你所有组件将会使得你的测试周期变长,效率更低。请谨记,React是一个非常好用的测试框架,所以我们大可以对它的测试结果放心。请确认你没有结束测试框架,而不是你的代码。



进行快照测试之前,还需要另一个Node包。react-test-renderer是一个将React组件渲染成一个纯JavaScript对象的包。也意味着其结果可以被存储成文件,这也正是Jest用来跟踪快照的方法。

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

下面开始用快照方式重写第一个Todo组件测试。先将TodoComponent中点击todo调用doneChange的测试注释掉。

首先要做的是引入react-test-render,并删除mount的引用。它们不能同时使用,只能使用其中之一。所以我们要先注释掉那个测试。

import renderer from 'react-test-renderer';

然后用刚才导入的renderer渲染组件,并断言它与快照相符:

describe('Todo component renders the todo correctly', () => {
  it('renders correctly', () => {
    const todo = { id: 1, done: false, name: 'Buy Milk' };
    const rendered = renderer.create(
      <Todo todo={todo} />
    );
    expect(rendered.toJSON()).toMatchSnapshot();
  });
});

第一次运行这段代码,Jest可以发现没有这个组件的快照,然后会创建一个。看一下__tests__/__snapshots__/todo.test.js.snap:

exports[`Todo component renders the todo correctly renders correctly 1`] = `
<div
  className="todo  todo-1">
  <p
    className="toggle-todo"
    onClick={[Function]}>
    Buy Milk
  </p>
  <a
    className="delete-todo"
    href="#"
    onClick={[Function]}>
    Delete
  </a>
</div>
`;

可以看到,Jest将输出保存了起来,下一次运行这个测试的时候,它会检查输出是否与快照相同。为了举例,先将组件破坏,将渲染了todo文本的段落移除,也就是将这段代码从TodoComponent中删除:

<p className="toggle-todo" onClick={() => this.toggleDone() }>{ todo.name }</p>

现在看看Jest的输出:

FAIL  __tests__/todo.test.js
 ● Todo component renders the todo correctly › renders correctly

   expect(value).toMatchSnapshot()

   Received value does not match stored snapshot 1.

   - Snapshot
   + Received

     <div
       className="todo  todo-1">
   -   <p
   -     className="toggle-todo"
   -     onClick={[Function]}>
   -     Buy Milk
   -   </p>
       <a
         className="delete-todo"
         href="#"
         onClick={[Function]}>
         Delete
       </a>
     </div>

     at Object.<anonymous> (__tests__/todo.test.js:21:31)
     at process._tickCallback (internal/process/next_tick.js:103:7)

Jest发现快照与新组件并不匹配,在结果中就体现出来了。如果这次变更是正常的,可以在运行jest时加上-u参数,这将会更新快照。但是本例中,先重置变更,Jest会再次变得高兴。

下一步,想想该如何使用快照测试交互。同一个测试可以拥有多个快照,所以可以测试交互后的输出与预期是否相符。

其实,并不能用Jest快照测试我们Todo组件的交互,因为他们不控制本身的状态,而是调用其callback属性上的回调函数。本例中已经将快照测试移到一个新文件todo.snapshot.test.js,将原先的toggling测试保留在 todo.test.js。将快照测试单独放到一个文件是有用的,也意味着消除了react-test-renderer 和 react-addons-test-utils之间的冲突。

注意,在GitHub 上可以找到这个教程中全部的代码,也可以切到你本地运行。

结论

Facebook发布Jest已经有很长一段时间,但在最近几个月它才被挖出来并充分被使用。它很快得到JavaScript开发人员的青睐,并将会变得更好。如果过去试用过Jest,但对它并不满意,我建议你再试一次,因为它现已做出非常大的改变:运行快,操作规范,错误消息提示奇特,更妙的是它还具备快照功能。