如何測自定義的 React Hooks?

前言

哈嘍,大家好,我是海怪。

最近把項目里的 utils 以及 components 里的東西都測完了,算是完成了這次單測引入的第一個里程碑了。之后,我又把目光放到了 hooks 的文件夾上面,因為這些自定義 Hooks 一般都當工具包來使用,所以給它們上一上單測還是很有必要的。

正好我在 Kent C. Dodds 的博客里也發(fā)現(xiàn)了這篇 《How to test custom React hooks》,里面正好提到了如何高效地對自定義 Hooks 進行測試。今天就把這篇文章也分享給大家吧。

翻譯中會盡量用更地道的語言,這也意味著會給原文加一層 Buf,想看原文的可點擊 這里


正片開始

如果你現(xiàn)在正在用 react@>=16.8,那你可能已經(jīng)在項目里寫好幾個自定義 Hooks 了?;蛟S你會思考:如何才能讓別人更安心地使用這些 Hooks 呢?當然這里的 Hooks 不是指那些你為了減少組件體積而抽離出來的業(yè)務(wù)邏輯 Hooks(這些應(yīng)該通過組件測試來測的),而是那些你要發(fā)布到 NPM 或者 Github 上的,可重復(fù)使用的 Hooks。

假如現(xiàn)在我們有一個 useUndo 的 Hooks。

(這里 useUndo 的代碼邏輯對本文不是很重要,不過如果你想知道它是怎么實現(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)在讓我們來對這個 Hook 進行測試,提高代碼可維護性。為了能最大化測試效果,我們應(yīng)該確保我們的測試趨近于軟件的真實使用方式。 要記住,軟件的作用就是專門用來處理那些我們不想,或者不能手動去做的事的。寫測試也是同理,所以先來想想我們會如何手動地測它,然后再來寫自動化測試去替代手動。

我看到很多人都會犯的一個錯就是:總是想 “Hook 嘛,不就是個純函數(shù)么?就因為這樣我們才喜歡用 Hook 的嘛。那是不是就可以像直接調(diào)普通函數(shù)那樣,測試函數(shù)的返回值呢?” 對但是不完全對,它確實是個函數(shù),但嚴格來說,它并不是 純函數(shù),你的 Hooks 應(yīng)該是 冪等 的。如果是純函數(shù),那直接調(diào)用然后看看返回輸出是否正確的就可以了。

然而,如果你直接在測試里調(diào)用 Hooks,你就會因為破壞 React 的規(guī)則,而得到這樣的報錯:

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)在你可能會想:“如果我把 React 內(nèi)置的 Hooks(useEffectuseState) 都 Mock 了,那不就可以像普通函數(shù)那樣去做測試了么?” 求你了,別!因為這樣會讓你對測試代碼失去很多信心的。

不過,別慌。如果你只是想手動測試,可以不用像普通函數(shù)那樣去調(diào)用,你完全可以寫一個組件來使用這個 Hook,然后再用它來和組件交互,最終渲染到頁面。下面來實現(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)在就可以通過這個能和 Hook 交互的樣例來測試我們的 Hook 了。把上面的手動測試轉(zhuǎn)為自動化,我們可以寫一個測試來實現(xiàn)和手動做的一樣的事。比如:

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ù)情況下,我也推薦這樣去測 Hooks。

然而,有時候你得把組件寫得非常復(fù)雜才能拿來做測試。最終結(jié)果就是,測試掛了并不是因為 Hook 有問題,而是因為你的例子太復(fù)雜而導(dǎo)致的問題。

還有一個問題會讓這個問題變得更復(fù)雜。在很多場景中,一個組件是不能完全滿足你的測試用例場景的,所以你就得寫一大堆 Example Component 來做測試。

雖然寫多點 Example Component 也挺好的(比如,storybook 就是這樣的),但是,如果能創(chuàng)建一個沒有任何 UI 關(guān)聯(lián)的 Helper 函數(shù),讓它的返回值和 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 是必需的),可以讓我們不用寫那么多復(fù)雜的 Examaple Component 來覆蓋 Use Case 了。

有的時候,你會有更復(fù)雜的 Hook,比如等待 Mock 的 HTTP 請求返回的 Hook,或者你要用不同的 Props 來使用 Hooks 去 重新渲染 組件等等。這里每種情況都會讓你的 setup 函數(shù)和你真實的例子變得非常不可復(fù)用,沒有規(guī)律可循。

這就是為什么會有 @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([])
})

你會發(fā)現(xiàn)它用起來很像我們自己寫的 setup 函數(shù)。實際上,@testing-library/react-hooks 底層也是做了一些和我們上面 setup 類似的事。@testing-library/react-hooks 還提供了如何內(nèi)容:

  • 一套用來 “rerender” 使用 Hook 的組件的工具函數(shù)(用來測試依賴項變更的情況)
  • 一套用來 “unmount” 使用 Hook 的組件的工具函數(shù)(用來測試清除副作用的情況)
  • 一些用來等待指定時間的異步工具方法(可以測異步邏輯)

注意,你可以把所有的 Hooks 都放在 renderHook 的回調(diào)里來一次性地調(diào)用,然后就能一次測多個 Hooks 了

如果非要用寫 “Test Component” 的方法來支持上面的功能,你要寫非常多容易出錯的模板代碼,而且你會花大量時間在編寫和測試你的 “Test Component”,而不是你真正想測的東西。

總結(jié)

還是說明一下,如果我只對特定的 useUndo Hook 做測試,我會使用真實環(huán)境的用例來測,因為我覺得它能在易懂性和用例覆蓋之間可以取得一個很好的平衡。當然,肯定會有更復(fù)雜的 Hooks,使用 @testing-library/react-hooks 則更有用。


好了,這篇外文就給大家?guī)У竭@里了。這篇文章也給我們帶來了兩種測試 Hooks 的思路:使用 Test Componet 以及 @testing-library/react-hooks。對我來說,因為項目里的 Hooks 偏工具類,所以我可能會選用第二種方法來做測試。希望也能給小伙伴們帶來一些啟發(fā)和思考。

如果你喜歡我的分享,可以來一波一鍵三連,點贊、在看就是我最大的動力,比心 ??

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容