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 之后,第一个 -- 之前,将命令直接传递给底层。以下这两个命令是等效的:
npm test -- --watch
jest --watch
我建议把 Jest 的运行留在另一个 tab 或者终端窗口中。
在测试 React 组件之前,我们对其中一个状态函数编写一个测试。在真实的应用程序中可能需要编写很多测试,但是为了本教程,我跳过了他们。现在,让我们编写一个测试,确保我们的 deleteTodo 函数工作。你可以尝试自己编写并与你自己的测试进行比较。
测试的第一部分改变不多,我们设置了初始状态运行我们的函数并完成维护状态。如果你离开 Jest 运行着的观察者模式,那么你的新测试将不能被检测到,也不会被运行,更不能像在 Jest 中那么快!在你测试,编写它们的时候,这种获得即时反馈的方式是很伟大的。
上面的测试证明了对一个测试的完美安排是这样的:
建立
在测试之下执行函数
反馈结果(结果断言)
以这种方式通过测试,你会发现他们更容易被跟踪和处理。
现在我很乐于测试我们的状态函数,让我们把目光转移到 React 组件吧。
测试React组件
出于其意义甚微,事实上我一般不推荐为React组件写太多的测试。你需要测试的东西,比如业务逻辑,本来就应该从组件中抽离出去,然后落实到独立的方法中去,比如我们上文测试的状态方法。也即是说,测试React的交互(比如,确保当用户点击一个按钮是指定的方法会以正确的参数被调用)只是偶尔才有用。我们将从测试React组件可以渲染正确的数据开始测试,然后测试交互。然后进行的是快照,Jest的一个可以使测试React组件输出变得方便的特性。
因此我们必须使用react-addons-test-utils,一个提供了测试React方法的库。还需要安装Enzyme,AirBnB写的一个可以使测试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将会告诉你下面的其中一种情况:
你意识到了错误,同时你可以修复组件使之再次匹配快速测试。
或者,你特意做出修改,然后通过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,但对它并不满意,我建议你再试一次,因为它现已做出非常大的改变:运行快,操作规范,错误消息提示奇特,更妙的是它还具备快照功能。