前言
哈嘍内狸,大家好,我是海怪厘擂。
最近把項(xiàng)目里的 utils
以及 components
里的東西都測完了,算是完成了這次單測引入的第一個(gè)里程碑了锰瘸。之后刽严,我又把目光放到了 hooks
的文件夾上面,因?yàn)檫@些自定義 Hooks 一般都當(dāng)工具包來使用避凝,所以給它們上一上單測還是很有必要的舞萄。
正好我在 Kent C. Dodds 的博客里也發(fā)現(xiàn)了這篇 《How to test custom React hooks》,里面正好提到了如何高效地對(duì)自定義 Hooks 進(jìn)行測試管削。今天就把這篇文章也分享給大家吧倒脓。
翻譯中會(huì)盡量用更地道的語言,這也意味著會(huì)給原文加一層 Buf含思,想看原文的可點(diǎn)擊 這里崎弃。
正片開始
如果你現(xiàn)在正在用 react@>=16.8
,那你可能已經(jīng)在項(xiàng)目里寫好幾個(gè)自定義 Hooks 了含潘。或許你會(huì)思考:如何才能讓別人更安心地使用這些 Hooks 呢?當(dāng)然這里的 Hooks 不是指那些你為了減少組件體積而抽離出來的業(yè)務(wù)邏輯 Hooks(這些應(yīng)該通過組件測試來測的)贿衍,而是那些你要發(fā)布到 NPM 或者 Github 上的弦蹂,可重復(fù)使用的 Hooks。
假如現(xiàn)在我們有一個(gè) useUndo
的 Hooks漱逸。
(這里 useUndo
的代碼邏輯對(duì)本文不是很重要泪姨,不過如果你想知道它是怎么實(shí)現(xiàn)的,可以讀一下 Homer Chen 寫的源碼)
import * as React from 'react'
const UNDO = 'UNDO'
const REDO = 'REDO'
const SET = 'SET'
const RESET = 'RESET'
function undoReducer(state, action) {
const {past, present, future} = state
const {type, newPresent} = action
switch (action.type) {
case UNDO: {
if (past.length === 0) return state
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
return {
past: newPast,
present: previous,
future: [present, ...future],
}
}
case REDO: {
if (future.length === 0) return state
const next = future[0]
const newFuture = future.slice(1)
return {
past: [...past, present],
present: next,
future: newFuture,
}
}
case SET: {
if (newPresent === present) return state
return {
past: [...past, present],
present: newPresent,
future: [],
}
}
case RESET: {
return {
past: [],
present: newPresent,
future: [],
}
}
default: {
throw new Error(`Unhandled action type: ${type}`)
}
}
}
function useUndo(initialPresent) {
const [state, dispatch] = React.useReducer(undoReducer, {
past: [],
present: initialPresent,
future: [],
})
const canUndo = state.past.length !== 0
const canRedo = state.future.length !== 0
const undo = React.useCallback(() => dispatch({type: UNDO}), [])
const redo = React.useCallback(() => dispatch({type: REDO}), [])
const set = React.useCallback(
newPresent => dispatch({type: SET, newPresent}),
[],
)
const reset = React.useCallback(
newPresent => dispatch({type: RESET, newPresent}),
[],
)
return {...state, set, reset, undo, redo, canUndo, canRedo}
}
export default useUndo
假如現(xiàn)在讓我們來對(duì)這個(gè) Hook 進(jìn)行測試饰抒,提高代碼可維護(hù)性肮砾。為了能最大化測試效果,我們應(yīng)該確保我們的測試趨近于軟件的真實(shí)使用方式循集。 要記住唇敞,軟件的作用就是專門用來處理那些我們不想,或者不能手動(dòng)去做的事的咒彤。寫測試也是同理疆柔,所以先來想想我們會(huì)如何手動(dòng)地測它,然后再來寫自動(dòng)化測試去替代手動(dòng)镶柱。
我看到很多人都會(huì)犯的一個(gè)錯(cuò)就是:總是想 “Hook 嘛旷档,不就是個(gè)純函數(shù)么?就因?yàn)檫@樣我們才喜歡用 Hook 的嘛歇拆。那是不是就可以像直接調(diào)普通函數(shù)那樣鞋屈,測試函數(shù)的返回值呢范咨?” 對(duì)但是不完全對(duì),它確實(shí)是個(gè)函數(shù)厂庇,但嚴(yán)格來說渠啊,它并不是 純函數(shù),你的 Hooks 應(yīng)該是 冪等 的权旷。如果是純函數(shù)替蛉,那直接調(diào)用然后看看返回輸出是否正確的就可以了。
然而拄氯,如果你直接在測試?yán)镎{(diào)用 Hooks躲查,你就會(huì)因?yàn)槠茐?React 的規(guī)則,而得到這樣的報(bào)錯(cuò):
Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.
現(xiàn)在你可能會(huì)想:“如果我把 React 內(nèi)置的 Hooks(useEffect
译柏,useState
) 都 Mock 了镣煮,那不就可以像普通函數(shù)那樣去做測試了么?” 求你了鄙麦,別典唇!因?yàn)檫@樣會(huì)讓你對(duì)測試代碼失去很多信心的。
不過黔衡,別慌蚓聘。如果你只是想手動(dòng)測試,可以不用像普通函數(shù)那樣去調(diào)用盟劫,你完全可以寫一個(gè)組件來使用這個(gè) Hook夜牡,然后再用它來和組件交互,最終渲染到頁面侣签。下面來實(shí)現(xiàn)一下吧:
import * as React from 'react'
import useUndo from '../use-undo'
function UseUndoExample() {
const {present, past, future, set, undo, redo, canUndo, canRedo} =
useUndo('one')
function handleSubmit(event) {
event.preventDefault()
const input = event.target.elements.newValue
set(input.value)
input.value = ''
}
return (
<div>
<div>
<button onClick={undo} disabled={!canUndo}>
undo
</button>
<button onClick={redo} disabled={!canRedo}>
redo
</button>
</div>
<form onSubmit={handleSubmit}>
<label htmlFor="newValue">New value</label>
<input type="text" id="newValue" />
<div>
<button type="submit">Submit</button>
</div>
</form>
<div>Present: {present}</div>
<div>Past: {past.join(', ')}</div>
<div>Future: {future.join(', ')}</div>
</div>
)
}
export {UseUndoExample}
最終渲染結(jié)果:
[圖片上傳失敗...(image-613dd4-1650603357241)]
好塘装,現(xiàn)在就可以通過這個(gè)能和 Hook 交互的樣例來測試我們的 Hook 了。把上面的手動(dòng)測試轉(zhuǎn)為自動(dòng)化影所,我們可以寫一個(gè)測試來實(shí)現(xiàn)和手動(dòng)做的一樣的事蹦肴。比如:
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import {UseUndoExample} from '../use-undo.example'
test('allows you to undo and redo', () => {
render(<UseUndoExample />)
const present = screen.getByText(/present/i)
const past = screen.getByText(/past/i)
const future = screen.getByText(/future/i)
const input = screen.getByLabelText(/new value/i)
const submit = screen.getByText(/submit/i)
const undo = screen.getByText(/undo/i)
const redo = screen.getByText(/redo/i)
// assert initial state
expect(undo).toBeDisabled()
expect(redo).toBeDisabled()
expect(past).toHaveTextContent(`Past:`)
expect(present).toHaveTextContent(`Present: one`)
expect(future).toHaveTextContent(`Future:`)
// add second value
input.value = 'two'
userEvent.click(submit)
// assert new state
expect(undo).not.toBeDisabled()
expect(redo).toBeDisabled()
expect(past).toHaveTextContent(`Past: one`)
expect(present).toHaveTextContent(`Present: two`)
expect(future).toHaveTextContent(`Future:`)
// add third value
input.value = 'three'
userEvent.click(submit)
// assert new state
expect(undo).not.toBeDisabled()
expect(redo).toBeDisabled()
expect(past).toHaveTextContent(`Past: one, two`)
expect(present).toHaveTextContent(`Present: three`)
expect(future).toHaveTextContent(`Future:`)
// undo
userEvent.click(undo)
// assert "undone" state
expect(undo).not.toBeDisabled()
expect(redo).not.toBeDisabled()
expect(past).toHaveTextContent(`Past: one`)
expect(present).toHaveTextContent(`Present: two`)
expect(future).toHaveTextContent(`Future: three`)
// undo again
userEvent.click(undo)
// assert "double-undone" state
expect(undo).toBeDisabled()
expect(redo).not.toBeDisabled()
expect(past).toHaveTextContent(`Past:`)
expect(present).toHaveTextContent(`Present: one`)
expect(future).toHaveTextContent(`Future: two, three`)
// redo
userEvent.click(redo)
// assert undo + undo + redo state
expect(undo).not.toBeDisabled()
expect(redo).not.toBeDisabled()
expect(past).toHaveTextContent(`Past: one`)
expect(present).toHaveTextContent(`Present: two`)
expect(future).toHaveTextContent(`Future: three`)
// add fourth value
input.value = 'four'
userEvent.click(submit)
// assert final state (note the lack of "third")
expect(undo).not.toBeDisabled()
expect(redo).toBeDisabled()
expect(past).toHaveTextContent(`Past: one, two`)
expect(present).toHaveTextContent(`Present: four`)
expect(future).toHaveTextContent(`Future:`)
})
我其實(shí)還挺喜歡這種方法的,因?yàn)橄鄬?duì)來說猴娩,它也挺好懂的阴幌。大多數(shù)情況下,我也推薦這樣去測 Hooks卷中。
然而矛双,有時(shí)候你得把組件寫得非常復(fù)雜才能拿來做測試。最終結(jié)果就是蟆豫,測試掛了并不是因?yàn)?Hook 有問題议忽,而是因?yàn)槟愕睦犹珡?fù)雜而導(dǎo)致的問題。
還有一個(gè)問題會(huì)讓這個(gè)問題變得更復(fù)雜十减。在很多場景中栈幸,一個(gè)組件是不能完全滿足你的測試用例場景的愤估,所以你就得寫一大堆 Example Component 來做測試。
雖然寫多點(diǎn) Example Component 也挺好的(比如速址,storybook 就是這樣的)玩焰,但是,如果能創(chuàng)建一個(gè)沒有任何 UI 關(guān)聯(lián)的 Helper 函數(shù)壳繁,讓它的返回值和 Hook 做交互可能會(huì)很好震捣。
下面這個(gè)例子就是用這個(gè)想法來做的測試:
import * as React from 'react'
import {render, act} from '@testing-library/react'
import useUndo from '../use-undo'
function setup(...args) {
const returnVal = {}
function TestComponent() {
Object.assign(returnVal, useUndo(...args))
return null
}
render(<TestComponent />)
return returnVal
}
test('allows you to undo and redo', () => {
const undoData = setup('one')
// assert initial state
expect(undoData.canUndo).toBe(false)
expect(undoData.canRedo).toBe(false)
expect(undoData.past).toEqual([])
expect(undoData.present).toEqual('one')
expect(undoData.future).toEqual([])
// add second value
act(() => {
undoData.set('two')
})
// assert new state
expect(undoData.canUndo).toBe(true)
expect(undoData.canRedo).toBe(false)
expect(undoData.past).toEqual(['one'])
expect(undoData.present).toEqual('two')
expect(undoData.future).toEqual([])
// add third value
act(() => {
undoData.set('three')
})
// assert new state
expect(undoData.canUndo).toBe(true)
expect(undoData.canRedo).toBe(false)
expect(undoData.past).toEqual(['one', 'two'])
expect(undoData.present).toEqual('three')
expect(undoData.future).toEqual([])
// undo
act(() => {
undoData.undo()
})
// assert "undone" state
expect(undoData.canUndo).toBe(true)
expect(undoData.canRedo).toBe(true)
expect(undoData.past).toEqual(['one'])
expect(undoData.present).toEqual('two')
expect(undoData.future).toEqual(['three'])
// undo again
act(() => {
undoData.undo()
})
// assert "double-undone" state
expect(undoData.canUndo).toBe(false)
expect(undoData.canRedo).toBe(true)
expect(undoData.past).toEqual([])
expect(undoData.present).toEqual('one')
expect(undoData.future).toEqual(['two', 'three'])
// redo
act(() => {
undoData.redo()
})
// assert undo + undo + redo state
expect(undoData.canUndo).toBe(true)
expect(undoData.canRedo).toBe(true)
expect(undoData.past).toEqual(['one'])
expect(undoData.present).toEqual('two')
expect(undoData.future).toEqual(['three'])
// add fourth value
act(() => {
undoData.set('four')
})
// assert final state (note the lack of "third")
expect(undoData.canUndo).toBe(true)
expect(undoData.canRedo).toBe(false)
expect(undoData.past).toEqual(['one', 'two'])
expect(undoData.present).toEqual('four')
expect(undoData.future).toEqual([])
})
上面這樣可以更直接地和 Hook 進(jìn)行交互(這就是為什么 act
是必需的),可以讓我們不用寫那么多復(fù)雜的 Examaple Component 來覆蓋 Use Case 了闹炉。
有的時(shí)候,你會(huì)有更復(fù)雜的 Hook润樱,比如等待 Mock 的 HTTP 請(qǐng)求返回的 Hook渣触,或者你要用不同的 Props
來使用 Hooks 去 重新渲染
組件等等。這里每種情況都會(huì)讓你的 setup
函數(shù)和你真實(shí)的例子變得非常不可復(fù)用壹若,沒有規(guī)律可循嗅钻。
這就是為什么會(huì)有 @testing-library/react-hooks,如果我們用了它店展,會(huì)變成這樣:
import {renderHook, act} from '@testing-library/react-hooks'
import useUndo from '../use-undo'
test('allows you to undo and redo', () => {
const {result} = renderHook(() => useUndo('one'))
// assert initial state
expect(result.current.canUndo).toBe(false)
expect(result.current.canRedo).toBe(false)
expect(result.current.past).toEqual([])
expect(result.current.present).toEqual('one')
expect(result.current.future).toEqual([])
// add second value
act(() => {
result.current.set('two')
})
// assert new state
expect(result.current.canUndo).toBe(true)
expect(result.current.canRedo).toBe(false)
expect(result.current.past).toEqual(['one'])
expect(result.current.present).toEqual('two')
expect(result.current.future).toEqual([])
// add third value
act(() => {
result.current.set('three')
})
// assert new state
expect(result.current.canUndo).toBe(true)
expect(result.current.canRedo).toBe(false)
expect(result.current.past).toEqual(['one', 'two'])
expect(result.current.present).toEqual('three')
expect(result.current.future).toEqual([])
// undo
act(() => {
result.current.undo()
})
// assert "undone" state
expect(result.current.canUndo).toBe(true)
expect(result.current.canRedo).toBe(true)
expect(result.current.past).toEqual(['one'])
expect(result.current.present).toEqual('two')
expect(result.current.future).toEqual(['three'])
// undo again
act(() => {
result.current.undo()
})
// assert "double-undone" state
expect(result.current.canUndo).toBe(false)
expect(result.current.canRedo).toBe(true)
expect(result.current.past).toEqual([])
expect(result.current.present).toEqual('one')
expect(result.current.future).toEqual(['two', 'three'])
// redo
act(() => {
result.current.redo()
})
// assert undo + undo + redo state
expect(result.current.canUndo).toBe(true)
expect(result.current.canRedo).toBe(true)
expect(result.current.past).toEqual(['one'])
expect(result.current.present).toEqual('two')
expect(result.current.future).toEqual(['three'])
// add fourth value
act(() => {
result.current.set('four')
})
// assert final state (note the lack of "third")
expect(result.current.canUndo).toBe(true)
expect(result.current.canRedo).toBe(false)
expect(result.current.past).toEqual(['one', 'two'])
expect(result.current.present).toEqual('four')
expect(result.current.future).toEqual([])
})
你會(huì)發(fā)現(xiàn)它用起來很像我們自己寫的 setup
函數(shù)养篓。實(shí)際上,@testing-library/react-hooks
底層也是做了一些和我們上面 setup
類似的事赂蕴。@testing-library/react-hooks
還提供了如何內(nèi)容:
- 一套用來 “rerender” 使用 Hook 的組件的工具函數(shù)(用來測試依賴項(xiàng)變更的情況)
- 一套用來 “unmount” 使用 Hook 的組件的工具函數(shù)(用來測試清除副作用的情況)
- 一些用來等待指定時(shí)間的異步工具方法(可以測異步邏輯)
注意柳弄,你可以把所有的 Hooks 都放在
renderHook
的回調(diào)里來一次性地調(diào)用,然后就能一次測多個(gè) Hooks 了
如果非要用寫 “Test Component” 的方法來支持上面的功能概说,你要寫非常多容易出錯(cuò)的模板代碼碧注,而且你會(huì)花大量時(shí)間在編寫和測試你的 “Test Component”,而不是你真正想測的東西糖赔。
總結(jié)
還是說明一下萍丐,如果我只對(duì)特定的 useUndo
Hook 做測試,我會(huì)使用真實(shí)環(huán)境的用例來測放典,因?yàn)槲矣X得它能在易懂性和用例覆蓋之間可以取得一個(gè)很好的平衡逝变。當(dāng)然,肯定會(huì)有更復(fù)雜的 Hooks奋构,使用 @testing-library/react-hooks
則更有用壳影。
好了,這篇外文就給大家?guī)У竭@里了声怔。這篇文章也給我們帶來了兩種測試 Hooks 的思路:使用 Test Componet 以及 @testing-library/react-hooks
态贤。對(duì)我來說,因?yàn)轫?xiàng)目里的 Hooks 偏工具類醋火,所以我可能會(huì)選用第二種方法來做測試悠汽。希望也能給小伙伴們帶來一些啟發(fā)和思考箱吕。
如果你喜歡我的分享,可以來一波一鍵三連柿冲,點(diǎn)贊茬高、在看就是我最大的動(dòng)力,比心 ??