前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >[译] 如何测试 React Hooks ?

[译] 如何测试 React Hooks ?

作者头像
江米小枣
发布2020-06-15 21:25:58
1.5K0
发布2020-06-15 21:25:58
举报
文章被收录于专栏:云前端云前端

原文:https://blog.kentcdodds.com/react-hooks-whats-going-to-happen-to-my-tests-df4c2b4d67b7

我们该如何准备好 React 新特性 hooks 的测试呢?

对于即将来临的 React Hooks 特性,我听到最常见的问题都是关于测试的。我都能想像出你测试这种时的焦虑:

代码语言:javascript
复制
// 借用另一篇博文中的例子:
// 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 /> 组件:

代码语言:javascript
复制
// 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 重构后也能应对:

代码语言:javascript
复制
// __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 版本:

代码语言:javascript
复制
// 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,我们来试验一下这个想法:

代码语言:javascript
复制
const sum = (a, b) => a + b

对于该函数的一种重构:

代码语言:javascript
复制
const sum = (a, b) => b + a

它依然会一摸一样的运行,但其自身的实现却有了一点不同。基本上这也算得上是个“重构”。Ok,现在看看什么是错误的重构:

代码语言:javascript
复制
const sum = (...args) => args.reduce((s, n) => s + n, 0)

看起来很牛,sum 更神通广大了。但从技术上说这不叫重构,而是一种增强。比较一下:

代码语言:javascript
复制
| call         | result before | result after |
|--------------|---------------|--------------|
| sum()        | NaN           | 0            |
| sum(1)       | NaN           | 1            |
| sum(1, 2)    | 3             | 3            |
| sum(1, 2, 3) | 3             | 6            |

为什么说这不叫重构呢?因为虽说我们的改变令人满意,但也“改变了其外部行为”。

那么这一切和 useEffect 有何关系呢?让我们看看有关计数器组件的另一个例子,这次这个类组件有一个新特性:

代码语言:javascript
复制
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 里面。以下是我们的“与实现细节无关”的测试用例:

代码语言:javascript
复制
// __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')
})

好么家伙的!测试又通过啦!现在再对这个有着新特性的组件“重构”一番:

代码语言:javascript
复制
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 就是好!性能都进步了!我们增强了组件的功能,代码也更简洁了!爽!

但是...说回来,这不叫重构。实际上这是改变行为了。对于终端用户来说,改变难以察觉;但从我们的测试视角可以观察到这种改变。这也解释了为何原来的测试一旦运行就会这样 :-(

代码语言:javascript
复制
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 以方便的实现这一目的。这也是我推荐的选项。

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

代码语言:javascript
复制
@@ -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 组件:

代码语言:javascript
复制
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>

这是我的测试方法:

代码语言:javascript
复制
// __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 的:

代码语言:javascript
复制
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 中分享代码的更好的一种原生方法。所以我们照葫芦画瓢的重写一下:

代码语言:javascript
复制
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 组件可能被普遍引用,这样的重写是行不通的。

好吧,其实只要这样替代就可以了:

代码语言:javascript
复制
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 时照样能发挥作用。祝你好运!

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-01-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云前端 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • useEffect 可不是 componentDidMount + componentDidUpdate + componentWillUnmount
  • render props 组件又如何?
  • 结论
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档