React 组件的单元测试

AnndyR 2020-06-09

单元测试是一门非常伟大的学科,它可以减少40%-80%的 bug。同时,还有以下几个重要的好处:

  • 改善应用的结构和可维护性。
  • 在具体实现之前,让开发者更加关注开发体验,从而实现更好的 API 和更好的组合能力。
  • 每当保存文件不管是否正确,都会提供快速的反馈。这可以避免使用 console.log() 和直接点击UI验证改变是否正确。作为一个单元测试新手可能需要在 TDD 流程上花费额外的15% - 30% 的时间了解如何测试各种组件,但是,TDD 经验丰富的开发者会节省具体实现的时间。
  • 当需要添加功能或者重构现有功能时,提供强大的安全保障。

有些情况单元测试相对比较容易。举例来说,单元测试对纯函数更加有效:一个函数,也就意味着同样的输入总会得到同样的输出,不会有副作用。

但是,UI 组件并不属于这一类,这使得 TDD 更加艰难,需要先编写测试。

对于我列出好处中有一些先编写测试用例是必要的,比如:在开发应用过程中,改善结构、更好的开发体验和更快的反馈。作为一个开发者要练习使用 TDD。很多开发者喜欢在编写测试之前编写业务,如果,你不先编写测试,你就会失去很多单元测试带来的好处。

尽管如此,先编写测试还是值得实践的。TDD 和单元测试可以让你编写 UI 组件更加简单、更容易维护和更容易组合复用组件。

我在测试领域最新的一个发明就是:实现了单元测试框架 RITEway,它是对 Tape 的简单封装,让你编写测试更加简单、更容易维护。

不管你用什么测试框架,接下来的提示都可以帮助你编写更好、更易测试、更具可读性和更具可组合性的 UI 组件:

  • 对于 UI 组件选用纯函数组件:同样的 props,总是同样的渲染。如果,你的应用需要 state,你可以用容器组件管理 state 和副作用,然后来包装纯函数组件。
  • 在纯 reducer 函数中隔离应用的业务逻辑
  • 使用容器组件隔离副作用

函数组件

一个函数组件,也就意味着同样的 props,中会渲染同样的 UI,也不会有副作用。比如:

import React from ‘react‘;
const Hello = ({ userName }) => (
  <div className="greeting">Hello, {userName}!</div>
);
export default Hello;
复制代码

这类组件通常非常容易测试。你需要一方式选择定位组件(在这个示例中,我们通过类名 greeting 来选择组件),然后,你需要知道组件输出什么。为纯函数组件编写测试用例,我使用 RITEway 中的 render-component

首先,需要安装 RITEway:

npm install --save-dev riteway
复制代码

RITEway 内部使用 react-dom/server renderToStaticMarkup() 然后把输出的内容包装成 Cheerio 对象以便选择。如果,你不使用 RITEway,你也创建属于自己的功能函数把 React 组件渲染成静态标签,然后用 Cheerio 来操作。

一旦,你得到 Cheerio 对象,你就可以像下面这样编写测试:

import { describe } from ‘riteway‘;
import render from ‘riteway/render-component‘;
import React from ‘react‘;
import Hello from ‘../hello‘;
describe(‘Hello component‘, async assert => {
  const userName = ‘Spiderman‘;
  const $ = render(<Hello userName={userName} />);
  assert({
    given: ‘a username‘,
    should: ‘Render a greeting to the correct username.‘,
    actual: $(‘.greeting‘)
      .html()
      .trim(),
    expected: `Hello, ${userName}!`
  });
});
复制代码

但是,这并没什么神奇的。如果,你需要测试 stateful 组件或者有副作用的组件呢?这才是 TDD 对 React 组件真正神奇的地方,因为,这个问题的答案同另外一个非常重要的问题答案相同:“如何让组件更容易维护和 debug?”。

回答是:从组件中隔离 state 和副作用。你可以把 state 和副作用封装到一个容器组件,然后,把 state 做为纯函数组件的 props 向下传递。

但是,Hooks API 不就是为了让组件层级更加扁平,避免更深层的嵌套吗?不完全是。把组件分成三类仍旧是一个很好的注意,让彼此相互隔离:

  • 显示/UI 组件
  • 程序逻辑/业务规则 — 处理解决用户相关的问题。
  • 副作用((I/O, network, disk 等)

根据我个人的经验,如果,你将显示/UI 与程序逻辑和副作用分离开,会提升你的开发体验。这种规则在我使用过的每种语言或者每个框架中,包括 React Hooks,都适用。

我们来创建一个 Counter 组件来演示 stateful 组件。首先,我们需要创建 UI 组件。它应该包括这些内容:“Clicks: 13” 来表示按钮被点击了多少次。按钮的值是“Click”。

为这个显示组件编写单元测试非常简单。我们只需测试按钮是否被渲染(我们不关心按钮的值是什么 — 根据用户的设置,不同的语言会有不同的显示)。我们还想知道是否显示了正确的点击数。我们需要编写两个测试:一个测试按钮是否显示,另外一个验证点击次数是否显示正确。

使用 TDD 时,我习惯使用两种不同的断言来确保组件可以正确显示相关的 props。如果,只编写一个测试有可能正好对应组件中的 hard-code。为了避免这种情况,你可以用两个不同的值来编写不同测试用例。

这个示例中,我们创建了一个名叫 <ClickCounter> 的组件,组件会有一个名为 clicks 的属性代表点击次数。为了使用它,只需为组件设置一个 clicks 属性,来表示需要显示的数字即可。

我们来看一下单元测试是如何保证组件渲染的。我们需要创建新文件:click-counter/click-counter-component.test.js

import { describe } from ‘riteway‘;
import render from ‘riteway/render-component‘;
import React from ‘react‘;
import ClickCounter from ‘../click-counter/click-counter-component‘;
describe(‘ClickCounter component‘, async assert => {
  const createCounter = clickCount =>
    render(<ClickCounter clicks={ clickCount } />)
  ;
  {
    const count = 3;
    const $ = createCounter(count);
    assert({
      given: ‘a click count‘,
      should: ‘render the correct number of clicks.‘,
      actual: parseInt($(‘.clicks-count‘).html().trim(), 10),
      expected: count
    });
  }
  {
    const count = 5;
    const $ = createCounter(count);
    assert({
      given: ‘a click count‘,
      should: ‘render the correct number of clicks.‘,
      actual: parseInt($(‘.clicks-count‘).html().trim(), 10),
      expected: count
    });
  }
});
复制代码

为了更加简单的编写测试用例,我喜欢创建小的工厂函数。这个示例中,createCounter 需要一个数字参数,然后,返回一个渲染后的组件:

const createCounter = clickCount =>
  render(<ClickCounter clicks={ clickCount } />)
;
复制代码

有了测试用例,是时候实现 ClickCounter 组件了。我把组件和测试文件放在了同一目录下,并命名为 click-counter-component.js。首先,我们先编写组件的框架,然后,你会看到测试用例报错了:

import React, { Fragment } from ‘react‘;
export default () =>
  <Fragment>
  </Fragment>
;
复制代码

如果,我们保存然后运行测试用例,你会看到报错TypeError,它触发了 Node 的 UnhandledPromiseRejectionWarning 。最终,Node 将不会使用烦人的警告 DeprecationWarning,而是抛出一个 UnhandledPromiseRejectionError 错误。我们之所以遇到这个 TypeError,是因为我们选择器返回了 null,然后,我们尝试调用 nulltrim() 方法。我们可以通过渲染期望的结构来修复这个错误:

import React, { Fragment } from ‘react‘;
export default () =>
  <Fragment>
    <span className="clicks-count">3</span>
  </Fragment>
;
复制代码

很好。现在,我们应该会有一个测试通过,一个测试失败:

# ClickCounter component
ok 2 Given a click count: should render the correct number of clicks.
not ok 3 Given a click count: should render the correct number of clicks.
  ---
    operator: deepEqual
    expected: 5
    actual:   3
    at: assert (/home/eric/dev/react-pure-component-starter/node_modules/riteway/source/riteway.js:15:10)
...
复制代码

为了修复它,我们需要把 count 设置为组件的 prop,然后用真实的值来渲染:

import React, { Fragment } from ‘react‘;
export default ({ clicks }) =>
  <Fragment>
    <span className="clicks-count">{ clicks }</span>
  </Fragment>
;
复制代码

现在,我们所有的测试都通过了:

TAP version 13
# Hello component
ok 1 Given a username: should Render a greeting to the correct username.
# ClickCounter component
ok 2 Given a click count: should render the correct number of clicks.
ok 3 Given a click count: should render the correct number of clicks.
1..3
# tests 3
# pass  3
# ok
复制代码

是时候测试点击按钮了。首先,添加测试用例,很显然会失败:

{
  const $ = createCounter(0);
  assert({
    given: ‘expected props‘,
    should: ‘render the click button.‘,
    actual: $(‘.click-button‘).length,
    expected: 1
  });
}
复制代码

这是测试失败后的提示:

not ok 4 Given expected props: should render the click button
  ---
    operator: deepEqual
    expected: 1
    actual:   0
...
复制代码

现在,我们来实现点击按钮:

export default ({ clicks }) =>
  <Fragment>
    <span className="clicks-count">{ clicks }</span>
    <button className="click-button">Click</button>
  </Fragment>
;
复制代码

测试通过了:

TAP version 13
# Hello component
ok 1 Given a username: should Render a greeting to the correct username.
# ClickCounter component
ok 2 Given a click count: should render the correct number of clicks.
ok 3 Given a click count: should render the correct number of clicks.
ok 4 Given expected props: should render the click button.
1..4
# tests 4
# pass  4
# ok
复制代码

现在,我们只需要实现 state 相关的逻辑和相关事件即可。

Stateful 组件的单元测试

我告诉你的方法对于 ClickCounter 来说过于复杂,但是,大部分应用比这个组件更加复杂。State 经常会保存在数据库或者在多个组件之间共享。React 社区流行的做法是先从组件本地 state 开始,然后,根据需要把 state 提升到父级组件或者全局。

事实证明,如果一开始你就使用纯函数组件本地管理 state,对于以后也更易于管理。出于此原因和其它原因(比如:React 生命周期的混乱、state 的一致性、避免常见的bug),我更喜欢使用 reducer 管理组件 state。对于本地组件 state,你可以使用 React Hook API useReducer 引入。

如果,你需要使用 state 管理框架,比如:Redux,在此之前你已经实现了一半的工作,比如:单元测试等等。

If you need to lift the state to be managed by a state manager like Redux, you’re already half way there before you even start: Unit tests and all.

译者注:我的理解是,如果,你一开始就使用 useReducer 本地维护 state,在需要过渡到 Redux 时更加顺畅,之前的单元测试也可以很好的重用

首先,我为 state reducer 创建了相应的测试文件。我将会把它放在相同目录下,只是用了不同文件名。我把它命名为 click-counter/click-counter-reducer.test.js

import { describe } from ‘riteway‘;
import { reducer, click } from ‘../click-counter/click-counter-reducer‘;
describe(‘click counter reducer‘, async assert => {
  assert({
    given: ‘no arguments‘,
    should: ‘return the valid initial state‘,
    actual: reducer(),
    expected: 0
  });
});
复制代码

我习惯以断言开始,以确保 reducer 可以产出一个正常的初始值。如果,你以后决定使用 Redux,它将会在没有 state 的情况下,调用每一个 reducer,以便为 store 初始化 state。这也使得在需要为单元测试提供有效的初始 state 或者组件 state 时更加方便。

当然,我还需要创建相应的 reducer 文件:click-counter/click-counter-reducer.js

const click = () => {};
const reducer = () => {};
export { reducer, click };
复制代码

一开始,我只是简单的导出空的reducer 和 action creator。想知道更多有关 action creators 和 selectors 的知识,请查看:“改善 Redux 体系的 10 个提示”。今天,我们不打算深入探讨 React/Redux 设计模式相关内容,但是,理解了这类问题,即使,你不使用 Redux 对于理解我们今天所做的事情也有所帮助。

首先,我们将会看到测试失败:

# click counter reducer
not ok 5 Given no arguments: should return the valid initial state
  ---
    operator: deepEqual
    expected: 0
    actual:   undefined
复制代码

现在,我来修复测试用例中的问题:

const reducer = () => 0;
复制代码

初始化相关的测试用例现在可以通过了,是时候添加更有意义的测试用例了:

assert({
    given: ‘initial state and a click action‘,
    should: ‘add a click to the count‘,
    actual: reducer(undefined, click()),
    expected: 1
  });
  assert({
    given: ‘a click count and a click action‘,
    should: ‘add a click to the count‘,
    actual: reducer(3, click()),
    expected: 4
  });
复制代码

我们看到测试用例都失败了(两个分别应该返回 14的,都返回了0)。我们来修复它们。

注意我把 click() 作为 reducer 的公共 API 使用。在我看来,你应该把 reducer 作为应用的一部分,而不是直接与它交互。相反,reducer 的公共 API 应该是 action creators 和 selectors。

我没有单独为 action creators 和 selectors 编写测试用例。我总是与 reducer 相结合来测试它们。测试 reducer 也就是测试 action creators 和 selectors,反之亦然。如果,你遵守这些规则,你将会需要更少的测试用例,但是,如果,你单独的测试它们,仍旧可以实现同样的测试和覆盖率。

const click = () => ({
  type: ‘click-counter/click‘,
});
const reducer = (state = 0, { type } = {}) => {
  switch (type) {
    case click().type: return state + 1;
    default: return state;
  }
};
export { reducer, click };
复制代码

现在,所有的单元测试都应该可以通过:

TAP version 13
# Hello component
ok 1 Given a username: should Render a greeting to the correct username.
# ClickCounter component
ok 2 Given a click count: should render the correct number of clicks.
ok 3 Given a click count: should render the correct number of clicks.
ok 4 Given expected props: should render the click button.
# click counter reducer
ok 5 Given no arguments: should return the valid initial state
ok 6 Given initial state and a click action: should add a click to the count
ok 7 Given a click count and a click action: should add a click to the count
1..7
# tests 7
# pass  7
# ok
复制代码

最后一步:为组件绑定行为事件。我们可以使用容器组件来处理。我在本地目录中创建了一个名为 index.js 的文件。它的内容如下:

import React, { useReducer } from ‘react‘;
import Counter from ‘./click-counter-component‘;
import { reducer, click } from ‘./click-counter-reducer‘;
export default () => {
  const [clicks, dispatch] = useReducer(reducer, reducer());
  return <Counter
    clicks={ clicks }
    onClick={() => dispatch(click())}
  />;
};
复制代码

就是这样。这个组件只是用来管理 state,然后把 state 作为纯函数组件的 prop 向下传递。在浏览器中打开应用,点击按钮看是否正常运行。

到现在为止,我们还没有在浏览器中查看组件和处理样式的问题。为了更加的清晰,我将会在 ClickCounter 组件中添加一个标签和一些空格。同时,也会绑定 onClick 事件。代码如下:

import React, { Fragment } from ‘react‘;
export default ({ clicks, onClick }) =>
  <Fragment>
    Clicks: <span className="clicks-count">{ clicks }</span>&nbsp;
    <button className="click-button" onClick={onClick}>Click</button>
  </Fragment>
;
复制代码

所有的测试用例还是可以通过。

容器组件的测试呢?我不会为容器组件编写单元测试。相反,我使用功能测试,这种测试运行在浏览器中或者模拟器中,用户可以与真实的 UI 交互,运行 end-to-end 测试。你的应用需要两种测试(单元和功能测试),为容器组件(那些为了连接 reducer 的组件)编写单元测试我觉得有点多余,而且,很难实现正确的单元测试。通常,你需要模拟各种容器组件的依赖关系以便可以正常工作。

在此期间,我们只是测试那些比较重要而不依赖副作用的组件:我们测试了是否可以正确的渲染,state 的管理是否正确。你还是需要在浏览器中运行组件,然后查看按钮是否正确工作。

不管是为 React 组件实施功能/e2e测试,还是为其它框架实施都是相同的。详情可以查看 “Behavior Driven Development (BDD) and Functional Testing”。

下一步

注册 TDD Day:可获得 5 小时有关 TDD 的高质量的视频内容和交互课程。这是一个很棒的速成教程,可以提高团队的 TDD 技能。不管,你当前的 TDD 经验如何,你都会学到更多知识。


作者:xiaoT
链接:https://juejin.im/post/5edf0fc2518825431c09018c
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

相关推荐