前言
哈嘍,大家好,我是海怪。
最近把項目里的 utils
以及 components
里的東西都測完了,算是完成了這次單測引入的第一個里程碑了。之后,我又把目光放到了 hooks
的文件夾上面,因為這些自定義 Hooks 一般都當工具包來使用,所以給它們上一上單測還是很有必要的。
正好我在 Kent C. Dodds 的博客里也發現了這篇 《How to test custom React hooks》,里面正好提到了如何高效地對自定義 Hooks 進行測試。今天就把這篇文章也分享給大家吧。
翻譯中會盡量用更地道的語言,這也意味著會給原文加一層 Buf,想看原文的可點擊 這里。
正片開始
如果你現在正在用 react@>=16.8
,那你可能已經在項目里寫好幾個自定義 Hooks 了。或許你會思考:如何才能讓別人更安心地使用這些 Hooks 呢?當然這里的 Hooks 不是指那些你為了減少組件體積而抽離出來的業務邏輯 Hooks(這些應該通過組件測試來測的),而是那些你要發布到 NPM 或者 Github 上的,可重復使用的 Hooks。
假如現在我們有一個 useUndo
的 Hooks。
(這里 useUndo
的代碼邏輯對本文不是很重要,不過如果你想知道它是怎么實現的,可以讀一下 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
假如現在讓我們來對這個 Hook 進行測試,提高代碼可維護性。為了能最大化測試效果,我們應該確保我們的測試趨近于軟件的真實使用方式。 要記住,軟件的作用就是專門用來處理那些我們不想,或者不能手動去做的事的。寫測試也是同理,所以先來想想我們會如何手動地測它,然后再來寫自動化測試去替代手動。
我看到很多人都會犯的一個錯就是:總是想 “Hook 嘛,不就是個純函數么?就因為這樣我們才喜歡用 Hook 的嘛。那是不是就可以像直接調普通函數那樣,測試函數的返回值呢?” 對但是不完全對,它確實是個函數,但嚴格來說,它并不是 純函數,你的 Hooks 應該是 冪等 的。如果是純函數,那直接調用然后看看返回輸出是否正確的就可以了。
然而,如果你直接在測試里調用 Hooks,你就會因為破壞 React 的規則,而得到這樣的報錯:
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.
現在你可能會想:“如果我把 React 內置的 Hooks(useEffect
,useState
) 都 Mock 了,那不就可以像普通函數那樣去做測試了么?” 求你了,別!因為這樣會讓你對測試代碼失去很多信心的。
不過,別慌。如果你只是想手動測試,可以不用像普通函數那樣去調用,你完全可以寫一個組件來使用這個 Hook,然后再用它來和組件交互,最終渲染到頁面。下面來實現一下吧:
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}
最終渲染結果:
[圖片上傳失敗...(image-613dd4-1650603357241)]
好,現在就可以通過這個能和 Hook 交互的樣例來測試我們的 Hook 了。把上面的手動測試轉為自動化,我們可以寫一個測試來實現和手動做的一樣的事。比如:
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:`)
})
我其實還挺喜歡這種方法的,因為相對來說,它也挺好懂的。大多數情況下,我也推薦這樣去測 Hooks。
然而,有時候你得把組件寫得非常復雜才能拿來做測試。最終結果就是,測試掛了并不是因為 Hook 有問題,而是因為你的例子太復雜而導致的問題。
還有一個問題會讓這個問題變得更復雜。在很多場景中,一個組件是不能完全滿足你的測試用例場景的,所以你就得寫一大堆 Example Component 來做測試。
雖然寫多點 Example Component 也挺好的(比如,storybook 就是這樣的),但是,如果能創建一個沒有任何 UI 關聯的 Helper 函數,讓它的返回值和 Hook 做交互可能會很好。
下面這個例子就是用這個想法來做的測試:
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 進行交互(這就是為什么 act
是必需的),可以讓我們不用寫那么多復雜的 Examaple Component 來覆蓋 Use Case 了。
有的時候,你會有更復雜的 Hook,比如等待 Mock 的 HTTP 請求返回的 Hook,或者你要用不同的 Props
來使用 Hooks 去 重新渲染
組件等等。這里每種情況都會讓你的 setup
函數和你真實的例子變得非常不可復用,沒有規律可循。
這就是為什么會有 @testing-library/react-hooks,如果我們用了它,會變成這樣:
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([])
})
你會發現它用起來很像我們自己寫的 setup
函數。實際上,@testing-library/react-hooks
底層也是做了一些和我們上面 setup
類似的事。@testing-library/react-hooks
還提供了如何內容:
- 一套用來 “rerender” 使用 Hook 的組件的工具函數(用來測試依賴項變更的情況)
- 一套用來 “unmount” 使用 Hook 的組件的工具函數(用來測試清除副作用的情況)
- 一些用來等待指定時間的異步工具方法(可以測異步邏輯)
注意,你可以把所有的 Hooks 都放在
renderHook
的回調里來一次性地調用,然后就能一次測多個 Hooks 了
如果非要用寫 “Test Component” 的方法來支持上面的功能,你要寫非常多容易出錯的模板代碼,而且你會花大量時間在編寫和測試你的 “Test Component”,而不是你真正想測的東西。
總結
還是說明一下,如果我只對特定的 useUndo
Hook 做測試,我會使用真實環境的用例來測,因為我覺得它能在易懂性和用例覆蓋之間可以取得一個很好的平衡。當然,肯定會有更復雜的 Hooks,使用 @testing-library/react-hooks
則更有用。
好了,這篇外文就給大家帶到這里了。這篇文章也給我們帶來了兩種測試 Hooks 的思路:使用 Test Componet 以及 @testing-library/react-hooks
。對我來說,因為項目里的 Hooks 偏工具類,所以我可能會選用第二種方法來做測試。希望也能給小伙伴們帶來一些啟發和思考。
如果你喜歡我的分享,可以來一波一鍵三連,點贊、在看就是我最大的動力,比心 ??