内容简介:原文:我们该如何准备好 React 新特性hooks 的测试呢?对于即将来临的 React Hooks 特性,我听到最常见的问题都是关于测试的。我都能想像出你测试这种时的焦虑:
原文: blog.kentcdodds.com/react-hooks…
我们该如何准备好 React 新特性hooks 的测试呢?
对于即将来临的 React Hooks 特性,我听到最常见的问题都是关于测试的。我都能想像出你测试这种时的焦虑:
// 借用另一篇博文中的例子: // https://kcd.im/implementation-details test('setOpenIndex sets the open index state properly', () => { const wrapper = mount(<Accordion items={[]} />) expect(wrapper.state('openIndex')).toBe(0) wrapper.instance().setOpenIndex(1) expect(wrapper.state('openIndex')).toBe(1) }) 复制代码
该 Enzyme 测试用例适用于一个存在真正实例的类组件 Accordion
,但当组件为函数式时却并没有 instance
的概念。所以当你把有状态和生命周期的类组件重构成用了 hooks 的函数式组件后,再调用诸如 .instance()
或 .state()
等就不能如愿了。
一旦你把类组件 Accordion
重构为函数式组件,那些测试就会挂掉。所以为了确保我们的代码库能在不推倒重来的情况下准备好 hooks 的重构,我们能做些什么呢?可以从绕开上例中涉及组件实例的 Enzyme API 开始。
* 阅读这篇文章“关于实现细节” 以了解更多相关内容。
来看个简单的类组件,我喜欢的一个例子是 <Counter />
组件:
// counter.js import React from 'react' class Counter extends React.Component { state = {count: 0} increment = () => this.setState(({count}) => ({count: count + 1})) render() { return ( <button onClick={this.increment}>{this.state.count}</button> ) } } export default Counter 复制代码
现在我们瞧瞧用一种什么方式对其测试,可以在用 hooks 重构后也能应对:
// __tests__/counter.js import React from 'react' import 'react-testing-library/cleanup-after-each' import {render, fireEvent} from 'react-testing-library' import Counter from '../counter.js' test('用 counter 增加计数', () => { const {container} = render(<Counter />) const button = container.firstChild expect(button.textContent).toBe('0') fireEvent.click(button) expect(button.textContent).toBe('1') }) 复制代码
测试将会通过。现在我们来将其重构为 hooks 版本:
// counter.js import React, {useState} from 'react' function Counter() { const [count, setCount] = useState(0) const incrementCount = () => setCount(c => c + 1) return <button onClick={incrementCount}>{count}</button> } export default Counter 复制代码
你猜怎么着?!因为我们的测试用例规避了关于实现的细节,所以 hooks 也没问题!多么的优雅~ :)
useEffect 可不是 componentDidMount + componentDidUpdate + componentWillUnmount
另一件要顾及的事情是 useEffect
hook,因为要用独一无二、特别、与众不同、了不得来形容它,还真都有那么一点。当你从类重构到 hooks 后,通常是把逻辑从 componentDidMount
、 componentDidUpdate
和 componentWillUnmount
中移动到一个或多个 useEffect
回调中(取决于你组件生命周期中关注点的数量)。但其实这并不算真正的重构,我们还是看看“重构”该有的样子吧。
所谓重构代码,就是在不改变用户体验的情况下将代码的实现加以改动。 wikipedia 上关于 “code refactoring” 的解释 :
代码重构(Code refactoring)是重组既有计算机代码结构的过程 — 改变因子(factoring) — 而不改变其外部行为。
Ok,我们来试验一下这个想法:
const sum = (a, b) => a + b 复制代码
对于该函数的一种重构:
const sum = (a, b) => b + a 复制代码
它依然会一摸一样的运行,但其自身的实现却有了一点不同。基本上这也算得上是个“重构”。Ok,现在看看什么是错误的重构:
const sum = (...args) => args.reduce((s, n) => s + n, 0) 复制代码
看起来很牛, sum
更神通广大了。但从技术上说这不叫重构,而是一种增强。比较一下:
| call | result before | result after | |--------------|---------------|--------------| | sum() | NaN | 0 | | sum(1) | NaN | 1 | | sum(1, 2) | 3 | 3 | | sum(1, 2, 3) | 3 | 6 | 复制代码
为什么说这不叫重构呢?因为虽说我们的改变令人满意,但也“改变了其外部行为”。
那么这一切和 useEffect
有何关系呢?让我们看看有关计数器组件的另一个例子,这次这个类组件有一个新特性:
class Counter extends React.Component { state = { count: Number(window.localStorage.getItem('count') || 0) } increment = () => this.setState(({count}) => ({count: count + 1})) componentDidMount() { window.localStorage.setItem('count', this.state.count) } componentDidUpdate(prevProps, prevState) { if (prevState.count !== this.state.count) { window.localStorage.setItem('count', this.state.count) } } render() { return ( <button onClick={this.increment}>{this.state.count}</button> ) } } 复制代码
Ok, 我们在 componentDidMount
和 componentDidUpdate
中把 count
保存在了 localStorage
里面。以下是我们的“与实现细节无关”的测试用例:
// __tests__/counter.js import React from 'react' import 'react-testing-library/cleanup-after-each' import {render, fireEvent, cleanup} from 'react-testing-library' import Counter from '../counter.js' afterEach(() => { window.localStorage.removeItem('count') }) test('用 counter 增加计数', () => { const {container} = render(<Counter />) const button = container.firstChild expect(button.textContent).toBe('0') fireEvent.click(button) expect(button.textContent).toBe('1') }) test('读和改 localStorage', () => { window.localStorage.setItem('count', 3) const {container, rerender} = render(<Counter />) const button = container.firstChild expect(button.textContent).toBe('3') fireEvent.click(button) expect(button.textContent).toBe('4') expect(window.localStorage.getItem('count')).toBe('4') }) 复制代码
好么家伙的!测试又通过啦!现在再对这个有着新特性的组件“重构”一番:
import React, {useState, useEffect} from 'react' function Counter() { const [count, setCount] = useState(() => Number(window.localStorage.getItem('count') || 0), ) const incrementCount = () => setCount(c => c + 1) useEffect( () => { window.localStorage.setItem('count', count) }, [count], ) return <button onClick={incrementCount}>{count}</button> } export default Counter 复制代码
很棒,对于用户来说,组件用起来和原来一样。但其实它的工作方式异于从前了;真正的门道在于 useEffect
回调被预定在稍晚的时间执行 。所以在之前,是我们在渲染之后同步的设置 localStorage
的值;而现在这个动作被安排到渲染之后的某个时候。为何如此呢?让我们查阅 React Hooks 文档中的这一段 :
不像 componentDidMount
或 componentDidUpdate
,用 useEffect
调度的副作用不会阻塞浏览器更新屏幕。这使得你的应用使用起来更具响应性。多数副作用不需要同步发生。而在不常见的情况下(比如要度量布局的尺寸),另有一个单独的useLayoutEffect Hook,其 API 和 useEffect
一样。
Ok, 用了 useEffect
就是好!性能都进步了!我们增强了组件的功能,代码也更简洁了!爽!
但是...说回来,这不叫重构。实际上这是改变行为了。对于终端用户来说,改变难以察觉;但从我们的测试视角可以观察到这种改变。这也解释了为何原来的测试一旦运行就会这样 :-(
FAIL __tests__/counter.js ✓ counter increments the count (31ms) ✕ reads and updates localStorage (12ms) ● reads and updates localStorage expect(received).toBe(expected) // Object.is equality Expected: "4" Received: "3" 23 | fireEvent.click(button) 24 | expect(button.textContent).toBe('4') > 25 | expect(window.localStorage.getItem('count')).toBe('4') | ^ 26 | }) 27 | at Object.toBe (src/__tests__/05-testing-effects.js:25:48) 复制代码
我们的问题在于,测试用例试图在用户和组件交互(并且 state 被更新、组件被渲染)后同步的读取 localStorage
的新值,但现在却变成了异步行为。
要解决这个问题,这里有一些方法:
-
按照上面提过的官网文档把
React.useEffect
改为React.useLayoutEffect
。这是最简单的办法了,但除非你真的需要相关行为同步发生才能那么做,因为实际上这会伤及性能。 -
使用
react-testing-library
库的 wait 工具并把测试设置为async
。这招被认为是最好的解决之道,因为操作实际上就是异步的,可从功效学的角度并不尽善尽美 -- 因为当前在 jsdom(工作在浏览器中) 中这样尝试的话实际上是有 bug 的。我还没特别调查 bug 的所在(我猜是在 jsdom 中),因为我更喜欢下面一种解决方式。 -
实际上你可以通过
ReactDOM.render
强制副作用同步的刷新。react-testing-library
提供一个实验性的 APIflushEffects
以方便的实现这一目的。这也是我推荐的选项。
那么来看看我们的测试为这项新增强特性所需要考虑做出的改变:
@@ -1,6 +1,7 @@ import React from 'react' import 'react-testing-library/cleanup-after-each' -import {render, fireEvent} from 'react-testing-library' +import {render, fireEvent, flushEffects} from 'react-testing-library' import Counter from '../counter' afterEach(() => { window.localStorage.removeItem('count') @@ -21,5 +22,6 @@ test('读和改 localStorage', () => { expect(button.textContent).toBe('3') fireEvent.click(button) expect(button.textContent).toBe('4') + flushEffects() expect(window.localStorage.getItem('count')).toBe('4') }) 复制代码
Nice! 每当我们想让断言基于副作用回调函数运行,只要调用 flushEffects()
,就可以一切如常了。
等会儿… 这难道不是测试了实现细节么?YES! 恐怕是这样的。如果不喜欢,那就如你所愿的把每个交互都做成异步的好了,因为事实上任何事情都同步发生也是关乎一些实现细节的。相反,我通过把组件的测试写成同步,虽然付出了一点实现细节上的代价,但取得了功效学上的权衡。软件无绝对,我们要在这种事情上权衡利弊。我只是觉得在这个领域稍加研究以利于得到更好的测试功效。
render props 组件又如何?
大概真是我的爱好了,这里还有个简单的计数器 render prop 组件:
class Counter extends React.Component { state = {count: 0} increment = () => this.setState(({count}) => ({count: count + 1})) render() { return this.props.children({ count: this.state.count, increment: this.increment, }) } } // 用法: // <Counter> // {({ count, increment }) => <button onClick={increment}>{count}</button>} // </Counter> 复制代码
这是我的测试方法:
// __tests__/counter.js import React from 'react' import 'react-testing-library/cleanup-after-each' import {render, fireEvent} from 'react-testing-library' import Counter from '../counter.js' function renderCounter(props) { let utils const children = jest.fn(stateAndHelpers => { utils = stateAndHelpers return null }) return { ...render(<Counter {...props}>{children}</Counter>), children, // 这能让我们访问到 increment 及 count ...utils, } } test('用 counter 增加计数', () => { const {children, increment} = renderCounter() expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 0})) increment() expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 1})) }) 复制代码
Ok,再将组件重构为使用 hooks 的:
function Counter(props) { const [count, setCount] = useState(0) const increment = () => setCount(currentCount => currentCount + 1) return props.children({ count: count, increment, }) } 复制代码
很酷~ 并且由于我们按照既定的方法写出了测试,也是顺利通过。BUT! 按我们从 “React Hooks: 对 render props 有何影响?” 中学到过的,自定义 hooks 才是在 React 中分享代码的更好的一种原生方法。所以我们照葫芦画瓢的重写一下:
function useCounter() { const [count, setCount] = useState(0) const increment = () => setCount(currentCount => currentCount + 1) return {count, increment} } export default useCounter // 用法: // function Counter() { // const {count, increment} = useCounter() // return <button onClick={increment}>{count}</button> // } 复制代码
棒极了… 但是如何测试 useCounter
呢?并且等等!总不能为了新的 useCounter
更新整个代码库吧!正在使用的 <Counter />
render prop 组件可能被普遍引用,这样的重写是行不通的。
好吧,其实只要这样替代就可以了:
function useCounter() { const [count, setCount] = useState(0) const increment = () => setCount(currentCount => currentCount + 1) return {count, increment} } const Counter = ({children, ...props}) => children(useCounter(props)) export default Counter export {useCounter} 复制代码
最新版的 <Counter />
render prop 组件才真正和原来用起来一样,所以这才是真正的重构。并且如果现在谁有时间升级的话,也可以直接用我们的 useCounter
自定义 hook。
测试又过了,爽翻啦~
等到大家都升级完,我们就可以移除函数式组件 Counter 了吧?你当然可以那么做,但实际上我会把它挪到 __tests__
目录中,因为这就是我喜欢测试自定义 hooks 的原因。我宁愿用没有自定义 hooks 的 render-prop 组件,真实的渲染它,并对函数被如何调用写断言。
结论
在重构代码前可以做的最好的一件事就是有个良好的测试套件/类型定义,这样当你无意中破坏了某些事情时可以快速定位问题。同样要谨记 如果你在重构时把之前的测试套件丢在一边,那些用例将变得毫无助益 。将我关于避免实现细节的忠告用在你的测试中,让在当今的类组件上工作良好的类,在之后重构为 hooks 时照样能发挥作用。祝你好运!
--End--
搜索 fewelife 关注公众号
转载请注明出处
以上所述就是小编给大家介绍的《[译] 如何测试 React Hooks ?》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- Flutter 学习之路 - 测试(单元测试,Widget 测试,集成测试)
- itest(爱测试)接口测试&敏捷测试管理 7.7.7 发布,接口测试重大升级
- 性能测试vs压力测试vs负载测试
- SpringBoot | 第十三章:测试相关(单元测试、性能测试)
- 敏捷测试VS传统测试对比,6招玩转敏捷测试!
- itest(爱测试)接口测试&敏捷测试管理平台 8.1.0 发布
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
着陆页:获取网络订单的关键
谢松杰 / 电子工业出版社 / 2017-1-1 / CNY 55.00
着陆页是用户点击广告后看到的第一个页面,是相关产品和服务的商业模式与营销思想的载体,是实现客户转化的关键。本书从“宏观”和“微观”两个层面对着陆页的整体框架和局部细节进行了深入的讨论,既有理论和方法,又有技术与工具,为读者呈现了着陆页从策划到技术实现的完整知识体系,帮助读者用最低的成本实现网站最高的收益。 谢松杰老师作品《网站说服力》版权输出台湾,深受两岸读者喜爱。本书是《网站说服力》的姊妹......一起来看看 《着陆页:获取网络订单的关键》 这本书的介绍吧!