如何對 react hooks 進行單元測試

寫在前面

使用 react hook 來做公司的新項目有一段時間了,大大小小的坑踩了不少。由於是公司項目,因此必須要編寫單元測試來確保業務邏輯的正確性以及重構時代碼的可維護性與穩定性,之前的項目使用的是 [email protected] 的版本,使用 enzyme 配合 jest 來做單元測試毫無壓力,但新項目使用的是 [email protected] ,編寫單元測試的時候,遇到不少阻礙,因此總結此篇文章算作心得分享出來。

配合 enzyme 來進行測試

首先,enzyme 對於 hook 的支持程度,可以參考這個 issue,對於各個 hook 的支持程度,裏面有鏈接,有說明,這裏就不贅述了。我在這裏想說的是,使用 enzyme 來測試 hook 在測試以及驗證方式上的一些轉變。

測試狀態

由於 function component 沒有實例的概念,我們無法通過類似 instance.xxx 的方式來直接對狀態進行驗證,比如:
對於這裏的 count 是無法通過 enzymewrapper.stateapi 來訪問的,但是我們可以通過 wrapper.text 來取出 button 的文字節點,間接地測試 count 狀態,如:

const Counter = () => {
  const [count, setCount] = useState(0)
  return <button>{count}</button>
}

測試方法

同理,我們也無法通過 instance.methodXXX 的方式來直接獲取組件實例的方法,進而進行調用和測試,比如:

const wrapper = mount(<Counter/>)
expect(wrapper.find('button').text()).toBe('0')

如何獲取 inc 方法的引用呢?我們可以通過 wrapper.prop 來曲線救國:

const Counter = () => {
  const [count, setCount] = useState(0)
  const inc = useCallback(() => setCount(c => c + 1), [])
  return <button onClick={inc}>{count}</button>
}

另外,有些情況下,我們以返回值的方式來暴露 hook 中的一些狀態以及方法,如果是這樣的話,就更簡單了,可以通過編寫 Wrapper 組件或者直接使用下一小節提及的工具庫來進行測試。

使用 @testing-library/react-hooks

測試有返回值的 hook

關於這個工具庫,在它的代碼倉庫中的 README.md 對它要解決的問題、實現原理進行了詳細的說明,有興趣的甚至可以直接看它的源碼,十分簡單。這裏給出一個示例來演示如何測試上一小節最後所說的情況,比如我們有一個 hook:

function useCounter() {
  const [count, setCount] = useState(0)
  const inc = useCallback(() => setCount(c => c + 1), [])
  const dec = useCallback(() => setCount(c => c - 1), [])
  
  return {
    count,
    inc,
    dec
  }
}

首先,我們完全可以通過上一小節的方式來對它進行測試,只需要實現一個臨時的 Wrapper,比如:

const CounterIncWrapper = () => {
  const {count, inc} = useCounter()
  return <button onClick={inc}>{count}</button>
}

const CounterDecWrapper = () => {
  const {count, dec} = useCounter()
  return <button onClick={dec}>{count}</button>
}

然後單獨按照上一節提及的方式來測試 CounterIncWrapper 或者 CounterDecWrapper 就可以了,但我們會發現,這裏的 Wrapper 的邏輯是很相似的,我們是否可以將它抽離爲一個公用的邏輯呢?答案當然是可以的,這正是 @testing-library/react-hooks 做的,使用它我們可以這樣測試 hook ,如下:

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter())

  act(() => {
    result.current.inc()
  })

  expect(result.current.count).toBe(1)
  
  act(() => {
    result.current.dec()
  })

  expect(result.current.count).toBe(0)
})

這裏的 act 是內置的工具方法,可以參考官方文檔進行了解,任何對於狀態的修改,都應該在它的回調函數中進行,不然會出現錯誤警告。

測試有依賴項的 hook

有些情況下,我們的 hook 會存在依賴的,比較常見的是 useContext 這個 hook ,它依賴一個 Provider 父組件,比如輕量級的狀態管理庫 unstated-next ,假設我們將上面的 hook 抽象成了一個獨立的 Container (這裏會涉及 unstated-nextapi ,但不影響理解):

const Counter = createContainer(useCounter)

要使用這個 Container ,我們需要這樣:
可以發現,這裏的 CounterDisplay 依賴於 Counter.Provider ,要測試 CounterDisplay ,我們通過 renderHookwrapper 參數來注入父組件,比如:

function CounterDisplay() {
  let counter = Counter.useContainer()
  return (
    <div>
      <button onClick={counter.dec}>-</button>
      <span>{counter.count}</span>
      <button onClick={counter.inc}>+</button>
    </div>
  )
}

function App() {
  return (
    <Counter.Provider>
      <CounterDisplay />
    </Counter.Provider>
  )
}

另外, renderHook 還支持 initialProps 參數,它代表回調函數中的參數,這裏接不贅述了。

測試副作用

hook 中比較難搞的應該算是 useEffect ,我花了很長時間來看別人是如何對它進行單元測試的,但是並沒有得到一些有用的信息,後來我仔細想了想,其實這個問題應該這樣來想, useEffect 是用來封裝副作用的,它只用來負責副作用的運行時機,對於副作用幹了什麼,對於 useEffect 完全是透明的。因此我們沒有必要對它進行單元測試,而應該在副作用的實現層確保它的正確性。但我們通常會將副作用的實現與 hook 的實現耦合起來,那怎麼對副作用的實現進行測試呢?這裏可以分兩種情況。

useEffect 會運行 props 中傳遞的回調函數

這種情況相對簡單一些,只需要通過 jest.fn() 來構造一個 spy 函數,之後通過上一節的方式渲染 hook ,通過 jest 對於 spy 函數的 api 來進行驗證即可。

useEffect 自成一體

這種情況下,我當前是通過將副作用代碼,直接聲明在 hook 外部的方式來進行測試的,比如:

export function updateDocumentTitle(title) {
  document.title = title
  
  return () => {
     document.title = 'default title'
  }
}

export function useDocumentTitle(title) {
  useEffect(() => updateDocumentTitle(title), [title])
}

這樣,只需要單獨測試 updateDocumentTitle 就好,而不需要在 useEffect 上花費功夫了。

這裏可能有的人會問,你這裏無法覆蓋 title 改變時, effect 是否重新運行的場景,確實,當前我也沒有辦法解決這種問題,如果要解決,辦法還是有的,就是通過 useDocumentTitle 的參數,來傳遞 updateDocumentTitle ,但這對於代碼有很強的侵入性,我不建議這樣做,如果 hook 本身的實現方式就是這樣,那完全可以針對它編寫相關的測試用例,如果不是,也沒有必要爲了寫測試用例而改寫原來的實現。

hook 無法被測試的原因

在對公司項目各個 hook 編寫單元測試時,發現一些 hook 非常難以測試,大體的特徵如下:

  • hook 的實現非常複雜,狀態繁多,依賴繁多
  • hook 的實現不復雜,但外部依賴難以 mock
  • hook 的實現自成一體,沒有入口

關於第一點,解決的方法當然是,化繁爲簡,將複雜的 hook,劃分爲多個簡單的 hook,使其職責更單一。對於第二點,如果外部依賴難以 mock ,我建議將它的測試用例放到集成測試階段進行實現,而不要花費過多精力在編寫單元測試的 mock 邏輯上。最後一點的解決方法詳見上一小節。

寫在最後

本身純屬個人觀點,如有錯誤,還望指正。


關注公衆號,全棧_101,只談技術,不談人生。

clipboard.png


另:本人最近比較缺錢,業餘時間接手各種規模的外包項目:

  • 前端 react/vue/angular 及性能優化
  • 後端 java/python

有意者私聊。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章