原文:https://blog.kentcdodds.com/react-hooks-whats-going-to-happen-to-my-tests-df4c2b4d67b7
我们该如何准备好 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
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
提供一个实验性的 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 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 时照样能发挥作用。祝你好运!