单元测试反应组件

转到Eric Elliott的个人资料 Eric Elliott封锁UnblockFollow发布于3月7日
第一次尝试通过clement127测试React组件的照片(CC BY-NC-ND 2.0)

单元测试是一项伟大的学科,可以使生产错误密度降低40%-80% 。单元测试还有其他几个重要的好处:

  • 改进您的应用程序架构和可维护性。
  • 在实现细节之前,通过将开发人员的经验(API)集中在开发人员体验(API)上,可以提
  • 提供有关文件保存的快速反馈,以告知您的更改是否有效。这可以替换console.log()并在UI中单击以测试更改。单元测试的新手可能在TDD过程中花费额外的15%-30%,因为他们想出如何测试各种组件,但经验丰富的TDD从业者可能会使用TDD节省实施时间。
  • 提供一个很好的安全网,可以增加您的时间来添加功能或重构现有功能。

但有些事情比其他事情更容易进行单元测试。具体来说,单元测试对于纯函数非常有用 :给出相同输入的函数总是返回相同的输出,并且没有副作用。

通常,UI组件不属于易于单元测试的那类事物,这使得更难坚持TDD的规则:首先编写测试。

首先编写测试对于实现我列出的一些好处是必要的:架构改进,更好的开发人员体验设计以及在开发应用程序时更快的反馈。培训自己使用TDD需要纪律和实践。许多开发人员在编写测试之前更喜欢修补,但如果你不先编写测试,就会剥夺自己很多单元测试的最佳功能。

不过,值得练习和训练。带有单元测试的TDD可以训练您编写UI组件,这些组件更简单,更易于维护,并且更易于与其他组件组合和重用。

我的测试学科最近的一项创新是开发了RITEway单元测试框架 ,它是一个很小的磁带包装器,可以帮助您编写更简单,更易于维护的测试。

无论您使用何种框架,以下提示都将帮助您编写更好,更可测试,更易读,更易组合的UI组件:

  • 赞成UI代码的纯组件: 给定相同的道具,总是呈现相同的组件。如果您需要来自应用程序的状态,您可以使用容器组件包装这些纯组件,该组件管理状态和副作用。
  • 在纯reducer函数中 隔离应用程序逻辑/业务规则
  • 使用容器组件 隔离副作用

偏爱纯组件

纯组件是一个组件,在给定相同的道具的情况下,它总是呈现相同的UI,并且没有副作用。例如,

 `import React from 'react';` 
 `const Hello = ({ userName }) => ( <div className="greeting">Hello, {userName}!</div> );` 
 `export default Hello;` 

这些组件通常很容易测试。您需要一种方法来选择组件(在这种情况下,我们通过greeting className选择),您需要知道预期的输出。要编写纯组件测试,我使用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}!` }); });`` 

但那不是很有趣。如果您需要测试有状态组件或具有副作用的组件,该怎么办?这就是TDD对React组件非常有趣的地方,因为这个问题的答案与另一个重要问题的答案相同:"我怎样才能使我的React组件更易于维护和调试?"

答案:从您的演示文稿组件中隔离您的状态和副作用。您可以通过将状态和副作用管理封装在容器组件中,然后通过props将状态传递到纯组件中来实现。

但是钩子API是不是因为我们可以拥有扁平的组件层次结构而忘记所有组件嵌套的东西?嗯,不太好。将代码保存在三个不同的存储桶中仍然是一个好主意,并使这些存储桶彼此隔离:

  • 显示/ UI组件
  • 程序逻辑/业务规则 ---处理您为用户解决的问题的东西。
  • 副作用 (I / O,网络,磁盘等)

根据我的经验,如果您将显示/ UI问题与程序逻辑和副作用分开,它会让您的生活更轻松。对于我来说,这个经验法则始终适用于我曾经使用的每种语言和每个框架,包括React with hooks。

让我们通过构建一个点击计数器来演示有状态的组件。首先,我们将构建UI组件。它应该显示类似"Clicks:13"的内容,告诉您单击按钮的次数。按钮只会说"点击"。

显示组件的单元测试非常简单。我们真的只需要测试按钮是否被渲染(我们不关心标签所说的内容 - 它可能会说不同语言的不同内容,具体取决于用户的语言环境设置)。我们 确实 希望确保显示正确的点击次数。让我们编写两个测试:一个用于按钮显示,另一个用于正确呈现的点击次数。

当使用TDD时,我经常使用两个不同的断言来确保我已经编写了组件,以便从props中提取适当的值。可以编写测试,以便您可以对函数中的值进行硬编码。为了防范这种情况,您可以编写两个测试,每个测试测试不同的值。

在这种情况下,我们将创建一个名为<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 ,我们试图在它上面运行.trim() 。让我们通过渲染预期的选择器来解决这个问题:

 `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) ...` 

要修复它,将计数作为道具,并使用JSX中的实时道具值:

 `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` 

是时候测试按钮了。首先,添加测试并观察它失败(TDD样式):

 `{ 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` 

现在我们只需要实现状态逻辑并挂钩事件处理程序。

单元测试状态组件

我要向您展示的方法对于点击计数器可能有点过分,但大多数应用程序远比点击计数器复杂。状态通常保存到数据库或在组件之间共享。 React社区中流行的副词是从本地组件状态开始,然后根据需要将其提升到父组件或全局应用程序状态。

事实证明,如果使用纯函数启动本地组件状态管理,则以后可以更轻松地管理该过程。由于这个和其他原因(如React生命周期混乱,状态一致性,避免常见错误),我喜欢使用纯reducer函数实现我的状态管理。对于本地组件状态,您可以导入它们并应用useReducer React挂钩。

如果您需要解除由Redux等州经理管理的状态,那么在您开始之前就已经有了一半:单元测试等等。

首先,我将为状态缩减器创建一个新的测试文件。我将它放在同一个文件夹中,但使用不同的文件。我称之为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,它将调用每个reducer没有状态,以便为商店生成初始状态。这也使得在您需要单元测试或初始化组件状态时,可以非常轻松地创建有效的初始状态。

当然,我们需要创建一个相应的reducer文件。我称之为click-counter/click-counter-reducer.js

 `const click = () => {};` 
 `const reducer = () => {};` 
 `export { reducer, click };` 

我首先只是导出一个空的reducer和action creator。要了解有关动作创建者和选择器等重要角色的更多信息,请阅读"10个更好的Redux架构技巧" 。我们现在不打算深入研究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。

我也没有为动作创建者和选择者编写单独的单元测试。我总是和减速机一起测试它们。测试reducer是测试动作创建者和选择器,反之亦然。如果您遵循此经验法则,您将需要更少的测试,但仍然可以获得与单独测试时相同的测试和案例覆盖率。

 `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())} />; };` 

而已。该组件唯一的工作是连接我们的状态管理并将状态作为道具传递给我们经过单元测试的纯组件。要测试它,请在浏览器中加载应用程序,然后单击单击按钮。

到目前为止,我们还没有在浏览器中查看组件或完成任何类型的样式。为了澄清我们正在计算的内容,我将为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的交互,端到端运行。您需要在应用程序中进行两种测试(单元和功能),并且对容器组件进行单元测试(主要是连接/接线组件,如上面连接减速器的那些组件)对于我的口味的功能测试来说太多余了,并不是特别容易进行适当的单元测试。通常,您必须模拟各种容器组件依赖关系才能使它们工作。

与此同时,我们对所有不依赖于副作用的重要单元进行了单元测试:我们正在测试是否呈现了正确的数据以及正确管理状态。您还应该在浏览器中加载组件,并亲自查看按钮是否正常工作以及UI是否响应。

为React实现功能/ e2e测试与为任何其他框架实现它们相同。我不会在这里讨论它们,但是在没有Selenium舞蹈的情况下,请查看TestCafeTestCafe StudioCypress.io进行e2e测试。

查看英文原文

查看更多文章

公众号:银河系1号

联系邮箱:public@space-explore.com

(未经同意,请勿转载)