「 翻译」使用 Jest 和 Enzyme 在 React 中进行测试驱动开发

原文链接

前言:你是如何开发的?

本文涵盖了一系列 Enzyme 与 Jest 为 React 提供的测试工具,包括「快照测试」「mock 函数」「模拟事件」,以及如何测试组件的「功能」、「props」以及「state」。我们也会探讨其他方面的测试,包括 React 项目中的「抽象能力」和「逻辑隔离」。最后会附上一个测试清单,关于如何设计你自己的测试。

EnzymeJest 是确保代码处于高稳定性的关键工具,不论你是选用「测试驱动开发」还是「行为驱动开发」策略。在讨论工具前,让我们先来以抽象的方式回顾一下这两种开发模式的差异。

行为驱动开发(Behavioural Driven Development)

你需要花多少时间来测试你的 app 呢?是「在完成一个里程碑后」还是「在开发组件间隔」就测试呢?前者更关注 app 的行为,优先考虑业务逻辑完成而不是组件稳定性。你的测试代码会在业务逻辑开发完成后立刻编写,此类测试的结果将确定重构代码或修复任何漏洞的最佳途径。

这种方式的本质是在写单元测试前对组件进行集成测试,或者使用像 Jest 这样的复杂框架进行任何的正式测试。这确实产生了更快的转变,并且被广泛使用。为什么会这样?这可能是因为商务会议很少会从测试驱动的角度来讨论 app,而是将重点放在最终的结果或是预期的行为上。考虑到这些因素,很自然地就会倾向于 BDD。会议记录会完全以行为为基础,在一个紧迫的时间表里完成任务。

这可能对快速原型设计或概念验证可能非常有用,但对于大型 app 来说,复杂集成着一系列服务,或是有一个大的团队在同时贡献(区块链就是一个很好的例子),以测试为优先的开发方法,即「测试驱动开发」就很有意义。而这个方法也恰好适合 React 开发。

测试驱动开发(Test Driven Development)

不论你是在开发前端用户体验还是数以千计的开发人员和数百万终端用户所依赖的 NPM 包,稳定性和可靠性都将是你代码的最高优先。TDD 所做的是推动清晰和功能性的代码,促使开发考虑组件如何继承,如何重用以及如何重构。

TDD 使用意图而非过程来推动开发。这样做,消除了将整个体验拼接在一起的大量复杂度。React 组件已经在划分逻辑上做得非常出色,使得他们很适合 TDD,从而在每个组件的基础上进行单元测试和集成测试。

归根到底,TDD 旨在通过将测试步骤作为开发过程中的一部分来解决向终端消费者提供了有故障的软件的问题,而不是当做在收尾时候的工作。最小化 bug 数量当然是为了整个组织的利益,而不仅仅是开发团队。换句话说,测试应该是每个人都关心的事,而不仅仅是开发人员。

这一切都非常抽象,所以让我们开始深入探索 React 下的 TDD,以了解在这里可以做些什么。

本文以 Enzyme 和 Jest 的一些基本知识为基础。如果您想先熟悉一下 Jest 和 Enzyme,请参考一下我写的介绍文章。
Testing in React with Jest and Enzyme: An Introduction

构建用于测试的 React 组件

习惯于开发组件的 React 开发人员都知道,组件逻辑可以在 state 发生变化、API 调用和其他逻辑(如使用 map)的时候快速构建。我们想做的是确保它在所有被使用的情况下都能正常工作。要做到这一点,我们需要实现两件事:

1. 以孤立方式测试逻辑

抽象代码是 TDD 的主要关注点,它也间接促进了包括 DRY(不要重复你自己)在内的一些原则,以实现最大化的组件重用。这在拥有组件灵活性的 React 中很容易实现。

考虑一个例子。一个导航 app,带有一个 <SideBar /><TopBar /> 组件,他们都使用了一个 <Item /> 组件作为导航链接。通过分离每个组件(和他们的专用样式),我们就把逻辑和每个组件相关的测试隔开来了。

记住,考虑到组件隔离的 TDD 会限制组件只做一件事,并且拒绝其他任何事情的概念。

我们假设这个 app 的项目结构是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// @rossbulat/nav package**
src/
Item/
index.js
index.scss
SideBar/
index.js
index.scss
TopBar/
index.js
index.scss
tests/
Item.test.js
nav.test.js
SideBar.test.js
TopBar.test.js

// @rossbulat/app**
src/
App.js
api.js
Home/
index.js
index.scss

tests/
app.test.js
api.test.js

// src/Home/index.js**
import { SideBar, TopBar } from '@rossbulat/nav';

在最终抽象后,导航组件事实上是作为一个包,@rossbulat/nav ,允许我们在更多的项目中引用他们来实现导航需求。我们不直接接触 <Item /> 以及 src/ 里面的所有组件。由于这些组件完全隔离,并且在包里有测试套件,因此我们的导航更易于管理。

<SideBar /><TopBar /> 分开可以确保我能够独立测试每个组件。不仅如此,每个导航链接包含一个 <Item /> 组件。这个组件可能会包括一些像 title、link、icon 等等的 props,这些东西都可以以孤立的方式进行测试。

除了单独的组件测试以外,这里还有一个 nav.test.js 文件,可以用来进行集成测试,或是全部 3 个组件一起工作,确保他们能够符合预期的快照——也许可以检查有多少个 <li> 标签嵌套在 <SideBar /> 里,表示展示了多少个 <Item /> 组件。我们会在之后进一步讨论快照。

我的 API 也完全和其他组件是分离的。api.test.js 文件专门用于 API 调用,毫无疑问将包括各种 mock 函数来提供伪 API 调用。之后我们会进一步探讨 mock。

2. 在一系列环境中测试代码

只有在满足抽象逻辑的先验条件时才能实现这一点,从而可以将可扩展的 props 传递到组件中,或者在集成测试的情况下,可扩展数量的组件一起工作。

考虑到隔离的力量,让我们来看一些测试 React 组件的例子。

也许测试一个 React 组件最简单的方法就是比较 render 方法的结果与一个快照文件。Jest 为我们提供了完成这项工作所必须的工具。

使用 Jest 的快照功能

用 Jest 的术语来说,快照测试是一个有用的功能,确保你的标记(即 HTML)不会意外更改。这同时也保证 render() 输出了你想要的内容。

可以使用 Enzyme 提供的方法对组件进行快照测试,也可以用 react-test-renderer - npm(非常有名,每周下载量超过 200 万次)。通过 NPM 或者 yarn 来添加到你的项目里:

1
yarn add react-test-renderer

当你在测试中调用 toMatchSnapshot 方法时,一个快照文件就会自动生成。通常和 expect() 方法一起使用,形式如下:

1
expect(<component>).toMatchSnapshot();

快照文件的内容主要是是由表示 React 组件与其输出的标记组成。这里是一个 Facebook 提供的 .snap 文件的例子,测试了不同链接的状态。

尽管我们不会再正常情况下手动编辑快照文件,但为了代码审查,他们是被设计为易读的,同时也被设计为添加到源码控制中——在代码更新的时候提交你的快照文件。

让我们来看一个快照测试的实际例子——我将测试之前的 <Item /> 组件是否正常渲染。请注意,首次运行测试的时候会生成快照文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react'
import { Item } from '@rossbulat/nav'
import renderer from 'react-test-renderer'
import icon from './img/ross.png'
describe('testing navigation', () => {
it('renders correctly', () => {
const item = renderer
.create(
<Item
link="https://rossbulat.co"
text="My Homepage"
icon={icon} />
).toJSON();
expect(item).toMatchSnapshot();
});
});

下一个例子使用了 Enzyme 的 shallow() 方法,测试了我的 <SideBar /> 组件是否正常渲染:

1
2
3
4
5
...
it('sidebar should render correctly', () => {
const sidebar = shallow(<SideBar />);
expect(sidebar).toMatchSnapshot();
});

我也可以测试 <SideBar /> 是否能在给一个数组的链接下正确渲染:

1
2
3
4
5
6
7
it('sidebar should render links correctly', () => {
const links =
[{link: 'https://rossbulat.co', text: 'My Homepage'},
{link: 'https://medium.com/@rossbulat', text: 'Medium'}];
const component = shallow(<MyComponent links={links} />);
expect(component).toMatchSnapshot();
});

这个测试会创建另一个快照。准确来说,每一个我的测试都会生成一个不同的快照文件,即使是在使用同一个组件。

如果你在观察模式(watch mode)下运行测试,使用 yarn test --watch,然后再按下 i 来进入快照模式,失败的快照会显示出来,并显示具体的错误信息,允许你去查看问题。

快照文件会生成在你测试目录的 __snapshots__ 文件夹下,所有的文件会被命名为 <test_name.snap>

注意:用于将 JS 对象转换为这些 .snap 文件中字符串表示的包是格式化的。这个包只是另一个 NPM 包,你可以在自己的项目里想 debug 或者 记录 JS 调用的时候使用。

如果之后,你更新了 UI,然后想要更新快照,我们有一个方便的命令来执行此操作。在你的项目目录中运行以下命令来更新全部的快照:

1
jest --updateSnapshot

你需要在增强组件时更新快照。在对他们运行后续测试之前,请确保初始快照输出符合预期。

有关 Jest 中快照相关的完整文档和功能介绍,请访问此页面: https://jestjs.io/docs/en/snapshot-testing.html

模拟方法

可以使用 Enzyme 提供的 simulate() 方法来模拟点击和输入文本等事件。比如,假设我想要测试点击一个组件内的按钮的结果。我可以这样做:

1
2
3
4
5
6
7
8
9
it('should update form submitted state with button click', () => {
const component = mount(<RegistrationForm />);
component
.find('button#submit_form')
.simulate('click');

expect(component.state('form_submitted')).toEqual(true);
component.unmount();
});

这里,我们测试了组件 <RegistrationFrom /> 以及点击 id 为 #submit_form 的按钮的结果。如上所示,组件的 state 可以通过 component.state('<key>') 来获取,然后通过 expect() 来进行对比。

实际上,我们可能会需要在提交注册表之前填写表格,至少是电子邮件地址或电话号码。我们可以轻松测试输入值吗?是的,就像这样:

1
2
3
component
.find('#name')
.simulate('change', { target: { value: 'Ross' } });

类似地,我们可以用 simulate() 来切换 checkbox:

1
2
3
component
.find('#agreetoterms')
.simulate('change', {target: {checked: true}});

甚至通过 keycode 来模拟一个键盘输入(keycode 参考: http://keycode.info/ ):

1
component.find('#input').simulate('keydown', { keyCode: 70 });

你也可能想要在测试 state、快照或其他东西之前调用组件的方法。我们可以通过 .instance() 的属性来获取组件的方法:

1
2
const component = shallow(<MyComponent />);
const result = component.instance().callMethod();

使用 Mock 函数

顾名思义, Mock 函数 允许我们重新实现一个函数,剥离逻辑以捕获对函数的调用,测试参数和返回值。

使用 Mock 函数最简单的方法就是定义一个空函数,并将其放在测试中的实际函数位置。这样做的好处是跟踪这个函数是否真的被调用(你有多少次调试并注意到由于没有调用某个特定的方法而导致出错?)。

我们可以使用 jest.fn() 来定义这样的函数。这里展示了我们如何使用一个空的 mock 函数来取代一个 onClick 方法,并测试它是否被调用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//define empty mock function
const fnClick = jest.fn();
describe('click events', () => {
it('button click should show menu', () => {
//replace actual function with mock function
const component = shallow(<MyButton onClick={fnClick}/>);

//simulate a click
component
.find('button#btn_open_menu')
.simulate('click');

//check if function was called**
expect(fnClick).toHaveBeenCalled();
});
});

匹配器 .toHaveBeenCalled() 是仅有的几个我们可以测试 mock 的方法之一。还有这些:

1
2
3
4
5
6
7
8
// Test how many times the function is called
expect(fnClick.mock.calls.length).toBe(3);
// Test the values passed as arguments
// The second argument of the third call to the function was 'yes'
expect(mockCallback.mock.calls[2][1]).toBe('yes');
// Test return values
// The return value of the second call to the function was true
expect(mockCallback.mock.results[1].value).toBe(true);

在这里,我们已经了解了 Mock 函数的 .mock 属性,让我们可以访问有关如何调用函数以及返回内容的信息。在这里阅读更多相关信息。

注入 Mock 函数

我们可以用 mocks 做的另一个很酷的事情就是在我们想要检索返回值的时候,使用 console.log() 注入测试:

1
2
3
const myMock = jest.fn();
console.log(myMock('return this string'));
// > return this string

在调用它之前,我们也可以指定 mock 函数返回特定值。这样可以很方便地去模拟一系列返回值,作为你的 APP 可以处理他们的证据:

1
2
3
4
5
6
7
8
// return `true` for first call, 
// return `false` for the second call
// return a string 'hello mock' for the third call
myMock.mockReturnValueOnce(true)
.mockReturnValueOnce(false)
.mockReturnValueOnce('hello mock');
//call the mock three times to witness the results:
console.log(myMock(), myMock(), myMock());

这对于检索数据和模拟提交任意数据的表单非常有用。测试你的 APP 是否可以处理 undefined、输入框里字符串的长度以及意外值(例如一个期望为布尔值的对象)。

想象一下,返回的 JSON 字符串是 {result: true} 而不是 true的情况。JSON 字符串本身就是 true 的,值 {result: false} 也是 true 的。使用 mocks 来测试返回值会确保这些情况不会发生。

Mock 方法可以与我们目前为止讨论的所有内容相结合,包括快照和模拟测试。随着更多场景的覆盖,你会发现测试变得更加复杂。你当然可以自由地实现 mock 函数:

1
2
3
const myMock = jest.fn(() => {
...
});

这样做的一般原则是不向原函数引入任何逻辑。如果一个实现还不够怎么办——如果我们想测试同一个函数的多种实现怎么办?这可以通过 mockImplementationOnce(() => {...}) 来完成,将实现本身作为参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
//define implementations
const myMockFn = jest.fn(() => 'default return value')
.mockImplementationOnce(() => {
...
return 'first implementation';
})
.mockImplementationOnce(() => {
...
return 'second implementation';
});
//call function
console.log(myMockFn(), myMockFn());
//any calls hereafter will refer to the default implementation.

查看更多的 mock 实现: https://jestjs.io/docs/en/mock-functions.html#mock-implementations

模块也可以通过 jest.mock() 来进行 mock。想象数据库调用或 API 请求很慢很脆弱的情况,这些会导致测试不可靠。Jest 提供了一个模拟 axios 模块来覆盖 API 测试的简单例子,但这是如何模拟模块函数的一般要点:
(原文的示例代码比较奇怪,这里引用 Jest 的例子)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// users.js
import axios from 'axios';

class Users {
static all() {
return axios.get('/users.json').then(resp => resp.data);
}
}

export default Users;

// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
const users = [{name: 'Bob'}];
const resp = {data: users};
axios.get.mockResolvedValue(resp);

// or you could use the following depending on your use case:
// axios.get.mockImplementation(() => Promise.resolve(resp))

return Users.all().then(data => expect(data).toEqual(users));
});

这是绕过模块方法的一个非常优雅的解决方法。通过 Jest 来覆盖功能—— .mockResolvedValue(<return value>) 允许我们这么做。

调试组件

值得一提的是,如果你希望以 HTML 的方式记录组件的 render() 结果,使用 debug() 方法:

1
2
const component = shallow(<MyComponent />);
console.log(component.debug());

在你的测试中调试组件可以快速定位为什么测试失败了。

总结

结合我最初的测试介绍,我们已经充分介绍了重要的 React 测试套件,其中的一系列方法在一起使用的时候可以提供全面的组件测试。

我们接触到了全部吗?不,但这些强大的工具将使用 Jest 和 Enzyme 来推动你的测试套件:

  • 快照:允许你将组件的 render() 结果与预期结果进行比较
  • 模拟:执行设计浏览器中预期事件的测试——点击、表单输入和函数调用等
  • Mock 函数:使用空的 mock 函数来测试参数与返回值,或是执行一系列实现来测试预期的结果。此外,还能够覆盖依赖外部数据源的脆弱模块方法,或是涉及减慢测试速度的繁重处理
  • 抽象能力:保持逻辑隔离并与特定组件相关联,促进可重用性,DRY 和广泛的集成测试

你会如何写测试?

我们在这里讨论的工具,BDD 和 TDD 都能使用。我个人认为 TDD 可以用于大多数应用,并且特别适用于长期的、基于协议的项目、API 服务、关键以及基础设施项目,以及尖端的研究项目。在敏捷开发中,根据冲刺目标来选择特定的方法,对你的团队是有意义的。