Morris's blog

Custom Hook pattern 與 如何為Custom Hook寫測試,然後要怎麼克服那些困難的點

2019-11-30

在開始討論單元測試之前,還是必須要介紹一下什麼是 Custom Hook

在 2018 年底的 react16.8 推出之後,我們的function components開始有 hook api 可用,就算是function components也可以有狀態、也可以作到像在 class components 時代那樣在類似componentDidMount的時間點打 API, 用 ref 抓 Dom 節點....etc 那樣的事

react Hook 時代開始後,繼 HOC, render pros 之後, 新的 pattern - Custom Hook

  • Custom Hook 就是你寫一個 function,然後在裡面自己組合useState, useEffect, useMemo.....這些 react 的 api 提供的 hook去凝聚好一些常常做的事,例如 抓資料, 把業務邏輯封裝......etc

例: 寫一個專門 fetch 會 return JSON 的 API 的 Custom Hook

function useFetch(url) {
  const [result, setResult] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(data => {
        //  做任何可能很複雜的事, 資料處理, 執行業務邏輯....etc
        //  這裡的code可能會像個 `地獄`, 但是至少它被 `集中` 在這裡,相對容易管理
        setResult(prevdata => data)
      })
      .catch(err => setError(prevErr => err))
  }, [url])

  return [result, error]
}

export default useFetch

在某個 component 裡使用它

import useFetch from './useFetch'

function MyComponent(props) {
  // use的地方就可以不用管任何事,只需要寫好有資料後要做什麼,查詢錯誤的話要做什麼
  const [data, error] = useFetch(myApiURL)

  if (data) {
    // dispaly data
  }
  if (error) {
    // display error
  } else {
    // display loading spinner
  }
}

useFetch裡有一個useEffect裡面做的事是去向 API 發出 request,在 promise 被 resolve 之前,return 的 data 是 null, use'它'的 component 就看它有沒有值,如果還沒有值就代表資料讀取中。 當資料回來了,promise 被 resolve, setResult 之後,因為 state 被 update, 驅動了使用了 useFetch 的MyComponent再整個重新執行, 重新執行, 重新執行。 很重要所以講三遍。重新執行一次*, 這時MyComponent又再 執行了一次useFetch, 但是這次因為 url 還是相同的,所以雖然又再執行了一次 useFetch,但是 request 不會再發出一次,而 result 這時已經有值了,或可能查詢過程裡發生錯誤(HTTP 500, 404...etc),這一次 return 的 [result, error] 就已經不會是[null, null]了,use 了它的 MyComponent 這時就可以根據 useFetch return 的資料或錯誤 return 出需要的畫面。

MyCompoent 變得非常地簡單,因為我們把取資料, 資料處理, 錯誤處理 全部都寫在useFetch裡了,MyComponent 只需要useXXX 用就對了

但是 MyComponent(或是說 寫了 MyComponent 這段 react 程式碼的人)一定要知道的是,第一次 MyComponent執行的時候(這裡的執行就是一般 react user 們所說的承認吧,function components 的整個 body 裡全部就都是`render`render*),拿到的 data 會是 null(因為useFetch第一次被執行的時候就是 return [null, null]),那這時就代表資料還在讀取中,剛好可以顯示讀取畫面。

這就是一個簡單又實際的 Custom Hook 的範例。

HOC, renderProps, Custom Hook。どっち!?

Custom Hook 是 hook API 出來之後才有的新 pattern,但是它不一定能夠完全取代之前的舊 pattern,只能說各自有各自適合的使用情境(usage scenario),就好比說是map, forEach, filter...也沒有辦法完全取代traditional for Loop這樣

它讓一些邏輯變得是可以到處帶著走,但是只要某個 component 裡一使用了某個 Custom Hook,那個 Custom Hook 可以說就是對那個 component 造成了一種侵入式依賴(dependency):沒它不行,而且做不到可抽拔。HOC 反而就沒這個問題。但Custom Hook形成了一個非常高程度的抽象,達成了一個容易使用的 API,用的人只需要關心它會拿到什麼,然後思考再來他要用這些東西做什麼事,可以完全不用去在乎 Custom Hook 裡面的執行細節。

但是只要你想,也是可以把這幾種模式一起使用,形成一個更強大的模組。 關於這些問題可以再寫成另外的長篇大論,這裡就不再多做討論,或是可以看看專家們怎麼說:

終於可以開始講測試了....嗎?

因為 Custom Hook 裡封裝了很特定的邏輯,有可能裡面會寫了很根據專案的特定業務流程,而我們必須針對 Custom Hook 寫測試,難就難在 Custom Hook 裡做的事可能是包山包海的程度。

舉例來說,專案可能有用redux用來紀錄一些 app state,我們的 Custom Hook 裡就有可能使用了react-redux的提供的Custom Hook - useSelector

沒錯,你寫的 Custom Hook 裡又用了別人寫的 Custom Hook,這是很正常的事情,這取決於你想要提供多 high-level 的 API,
redux來做個例子,假如今天你的程式裡有用到redux,那你可以把從store裡取得需要的資料的這段流程,寫在 Custom Hook 裡,外面use了你寫的 Custom Hook 的那一層就不必再做一次那樣的事,component 裡的流程可以變得很簡單 i.e. 拿到資料然後做 XXX,這是多美妙的一件事,code 看起來簡單到彷彿回到了 1999 年

這是 10 月底時在 Las Vegas 舉辦的 react conf 2019 其中一個 talk,Lee Byron(@leeb) - Let's Program Like It's 1999,非常精彩,他是 facebook 的早期員工,還是immutable.js的作者

react hooks react hooks code

PHP in 1999
1999 php code 好啦 我相信即使都已經 20XX 了還是有可能看到這樣的 code

看起來超越 87%的相似 😂😂 (但是當然背後的執行過程差非常多)

而且還可以保證只要用到的那一份 store 裡的資料一變動,use 到 Custom Hook 的 component 就會 update,連帶地重新執行一次 Custom Hook,然後 Custom Hook 裡面我們寫好的流程就會重新運算一次,然後在 Custom Hook 裡面還能夠使用useMemo去做客製化的 cache strategy, 再來,這個 Custom Hook 把你的業務邏輯隔離掉了 redux,要是哪天你的資料沒有要存在 redux 裡了,你只要修改 Custom Hook 裡取資料的流程即可,因為外部使用了 Custom Hook 的 component 不知道 redux 的存在,看到這裡又覺得剛剛說的侵入式的依賴不是缺點了...。

但是,當你要為這樣子的一個 Custom Hook 的時候寫測試的時候,你就會發現這測試有多難寫...

必須要 mock 很多東西

  • 這裡的 mock 不只是 mock 假資料,而是還需要做到 mock 掉使用到的 function,jest 上怎麼做,可以參考:jest mock

然後

  1. Custom Hook 裡使用了useSelector,所以必須提供一個Provider的 wrapper 讓 Custom Hook 執行在這個環境裡
  2. 倒入為了生出假的 store 所需要用到的假資料, 所以你一定要寫好一個很好用的能搭配 redux 的[能 custom render 的 util function](https://testing-library.com/docs/ native-testing-library/setup#custom-render)不然光是 mock Provider 這一步就會讓你想放棄寫測試這件事。
  3. 流程裡可能會在useEffect裡去 fetch 資料,那我們可能要另外mock好 axios 或是瀏覽器的 native fetch API, 一樣,CRA專案的話,這些可以寫在src/setupTests.js底下,
  4. 或是你的資料是從localStorage 或是 indexedDB裡取得?一樣,你必須先 mock 好這些WebAPI,因為它們在 test runner 執行時的 node.js 的境 下是不存在的。
  5. 如果有用 axio, dexie 這些在 node_modules 裡的 3rd-party library 的話,還要會[mock 這些 module](https://jestjs.io/docs/en/ mock-functions.html#mocking-modules)
  6. 最重要的是,Custom Hook 雖然看起來很像只是一個 function,但是它只能在 react component 裡被執行,所以@testing-library/reactrender 它時 候,還不能不用一個 component 把它包起來
  7. custommHook 最後 return 的是自己裡面的 useState(或 useReducer) hold 住的值,但是我們的 testRunner,所以要拿到非同步動作之後的 setXXX 之 後的值,我們必須要讓 testRunner 等,這可以簡單地利用 Promise api 寫一個 util function 做到
  8. 在負責 use 了 Custom Hook 的function component外部用一個let接出 update 後的值,然後對它expect
  9. @testing-library 有出一個react-hooks-testing-library,但如果 你的 Custom Hook 裡又 useSelector, 又 fetch, 又 access indexedDB...可能不是光靠它就有辦法處理的。

假設我們寫了一個 Custom Hook 為了要達到計時器的功能,記錄網頁開始執行後,經過了多少時間,就又叫它 usePassedTimeTimer

import { useState, useEffect, useRef } from 'react'

function usePassedTimeTimer() {
  const startTimeRef = useRef(Math.floor(Date.now() / 1000))
  const [passTimeNowStr, setPassTimeNowStr] = useState('00:00')

  useEffect(() => {
    const intervalId = setInterval(() => {
      const nowTime = Math.floor(Date.now() / 1000)
      const passesTime = nowTime - startTimeRef.current
      const minute = Math.floor(passesTime / 60)
      const second = passesTime % 60
      const minuteStr = minute < 10 ? `0${minute}` : `${minute}`
      const secondStr = second < 10 ? `0${second}` : `${second}`
      setPassTimeNowStr(minuteStr + ':' + secondStr)
    }, 1000)

    return () => {
      clearInterval(intervalId)
    }
  }, [])

  return passTimeNowStr
}

export { usePassedTimeTimer }

來看看它的測試該怎麼寫

import React from 'react'
import { render, cleanup, act } from '@testing-library/react'
import * as jestDom from '@testing-library/jest-dom'
import { usePassedTimeTimer } from '../index'

expect.extend(jestDom)

afterEach(cleanup)

// 上述 7.
const _sleep = times =>
  new Promise((res, rej) => {
    setTimeout(() => {
      console.log('wake up')
      res()
    }, times)
  })

// 注意因為要在這個test case裡做await,所以function會是async的
test('it can update the present passed time from `00:00` to `00:01` after one second', async () => {
  const expect1Str = '00:00'
  let passedTimeStr: string // 上述 8.
  function MyComponent() {
    // 上述 6.
    passedTimeStr = usePassedTimeTimer() // 上述 8.
    return null
  }

  render(<MyComponent />)

  expect(passedTimeStr).toBe(expect1Str) // expect一開始是 '00:00'
  await _sleep(1000) // 利用await 讓這個test執行時停在這裡一秒鐘,因為要等usePassedTimeTimer setPassTimeNowStr之後 造成MyComponent re-calculate
  // 沒有這樣的await的話,等不到MyComponent re-calculate,就會執行執行下一個expect,然後測試就結束了,怎麼跑都是錯。因為hook裡的值是要等它update的

  expect(passedTimeStr).toBe('00:01') // 過一秒之後變 '00:01'
})

辛苦是值得的

如果你的 Custom Hook 簡化了大量的業務邏輯和 boilerplate code,那只要能測好這些 Custom Hook, 整個從有資料到跑出畫面的測試可能就做好了 8 成以上,所以為 Custom Hook 寫測試是值得的。

斯斯有兩種,但我們寫出來的 Custom Hook 可能會有 3 種

  • 第一種,很 generic 的,比如說在裡面寫了一個 setTimeout 之類的,然後要用的時候就拿出來用,像下面 Kent C. Dodds 的這個影片這樣
  • 第二種,混有獨特的業務邏輯的,這就不會是有辦法到處使用的,至少出了目前的 project 之後,應該就沒辦法用了,但是在當前的專案裡重複使用到的機會很很大,因為它可能封裝了很多繁瑣的流程。

腦爆警告 👨‍💻👩‍💻🤯🤯

  • 第三種,經過 Custom Hook 裡的計算、處理之後,直接 return 新的 component 或是 JSX literal

上面的 twitter 提到的範例中,在一個叫useKnob的 Custom Hook 裡 return 了三個<input type="range" ... />跟它們當前的值 i.e. return [rawJSX, {...values}] ,然後將 state 跟它的 setter 封裝在該 hook 裡,外面use它的人就得到了一組把手 UI 和它們目前的值,然後把它用在想用的地方(在這範例中值是用來作為傳進useSpring的參數。沒錯,一個 Custom Hook 產生的值再傳進去另一個 Custom Hook 裡,因為事實上 Custom Hook 就是 function),把手只要一拉,外面使用react-spring去生出來的<animated.div />就會跟著變化。這樣做強大的地方在於,將狀態不會洩漏地封裝在useKnob裡,使用的人只要了就會得到一組 UI,和它目前的值,非常容易幫助使用者思考。

參考

response