[译] 如何测试 React Hooks ?

栏目: 服务器 · 发布时间: 5年前

内容简介:原文:我们该如何准备好 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 后,通常是把逻辑从 componentDidMountcomponentDidUpdatecomponentWillUnmount 中移动到一个或多个 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, 我们在 componentDidMountcomponentDidUpdate 中把 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 文档中的这一段

不像 componentDidMountcomponentDidUpdate ,用 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 的新值,但现在却变成了异步行为。

要解决这个问题,这里有一些方法:

  1. 按照上面提过的官网文档把 React.useEffect 改为 React.useLayoutEffect 。这是最简单的办法了,但除非你真的需要相关行为同步发生才能那么做,因为实际上这会伤及性能。

  2. 使用 react-testing-library 库的 wait 工具并把测试设置为 async 。这招被认为是最好的解决之道,因为操作实际上就是异步的,可从功效学的角度并不尽善尽美 -- 因为当前在 jsdom(工作在浏览器中) 中这样尝试的话实际上是有 bug 的。我还没特别调查 bug 的所在(我猜是在 jsdom 中),因为我更喜欢下面一种解决方式。

  3. 实际上你可以通过 ReactDOM.render 强制副作用同步的刷新。 react-testing-library 提供一个实验性的 API flushEffects 以方便的实现这一目的。这也是我推荐的选项。

那么来看看我们的测试为这项新增强特性所需要考虑做出的改变:

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

[译] 如何测试 React Hooks ?

搜索 fewelife 关注公众号

转载请注明出处


以上所述就是小编给大家介绍的《[译] 如何测试 React Hooks ?》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

着陆页:获取网络订单的关键

着陆页:获取网络订单的关键

谢松杰 / 电子工业出版社 / 2017-1-1 / CNY 55.00

着陆页是用户点击广告后看到的第一个页面,是相关产品和服务的商业模式与营销思想的载体,是实现客户转化的关键。本书从“宏观”和“微观”两个层面对着陆页的整体框架和局部细节进行了深入的讨论,既有理论和方法,又有技术与工具,为读者呈现了着陆页从策划到技术实现的完整知识体系,帮助读者用最低的成本实现网站最高的收益。 谢松杰老师作品《网站说服力》版权输出台湾,深受两岸读者喜爱。本书是《网站说服力》的姊妹......一起来看看 《着陆页:获取网络订单的关键》 这本书的介绍吧!

MD5 加密
MD5 加密

MD5 加密工具

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具