如何測自定義的 React Hooks唁盏?

前言

哈嘍内狸,大家好,我是海怪厘擂。

最近把項(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)力,比心 ??

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末假抄,一起剝皮案震驚了整個(gè)濱河市怎栽,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌宿饱,老刑警劉巖熏瞄,帶你破解...
    沈念sama閱讀 206,214評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異谬以,居然都是意外死亡强饮,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門为黎,熙熙樓的掌柜王于貴愁眉苦臉地迎上來邮丰,“玉大人,你說我怎么就攤上這事铭乾〖袅” “怎么了?”我有些...
    開封第一講書人閱讀 152,543評(píng)論 0 341
  • 文/不壞的土叔 我叫張陵炕檩,是天一觀的道長斗蒋。 經(jīng)常有香客問我,道長捧书,這世上最難降的妖魔是什么吹泡? 我笑而不...
    開封第一講書人閱讀 55,221評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮经瓷,結(jié)果婚禮上爆哑,老公的妹妹穿的比我還像新娘。我一直安慰自己舆吮,他們只是感情好揭朝,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,224評(píng)論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著色冀,像睡著了一般潭袱。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上锋恬,一...
    開封第一講書人閱讀 49,007評(píng)論 1 284
  • 那天屯换,我揣著相機(jī)與錄音,去河邊找鬼。 笑死彤悔,一個(gè)胖子當(dāng)著我的面吹牛嘉抓,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播晕窑,決...
    沈念sama閱讀 38,313評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼抑片,長吁一口氣:“原來是場噩夢(mèng)啊……” “哼!你這毒婦竟也來了杨赤?” 一聲冷哼從身側(cè)響起敞斋,我...
    開封第一講書人閱讀 36,956評(píng)論 0 259
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎疾牲,沒想到半個(gè)月后植捎,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,441評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡阳柔,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,925評(píng)論 2 323
  • 正文 我和宋清朗相戀三年鸥跟,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片盔沫。...
    茶點(diǎn)故事閱讀 38,018評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖枫匾,靈堂內(nèi)的尸體忽然破棺而出架诞,到底是詐尸還是另有隱情,我是刑警寧澤干茉,帶...
    沈念sama閱讀 33,685評(píng)論 4 322
  • 正文 年R本政府宣布谴忧,位于F島的核電站,受9級(jí)特大地震影響角虫,放射性物質(zhì)發(fā)生泄漏沾谓。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,234評(píng)論 3 307
  • 文/蒙蒙 一戳鹅、第九天 我趴在偏房一處隱蔽的房頂上張望均驶。 院中可真熱鬧,春花似錦枫虏、人聲如沸妇穴。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽腾它。三九已至,卻和暖如春死讹,著一層夾襖步出監(jiān)牢的瞬間瞒滴,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評(píng)論 1 261
  • 我被黑心中介騙來泰國打工赞警, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留妓忍,地道東北人虏两。 一個(gè)月前我還...
    沈念sama閱讀 45,467評(píng)論 2 352
  • 正文 我出身青樓,卻偏偏與公主長得像单默,于是被迫代替她去往敵國和親碘举。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,762評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容