原文: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 關注公眾號
轉載請註明出處