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再整個重新執行[1]一次, 這時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 們所說的render[2]),拿到的 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 年

[3]

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,和它目前的值,非常容易幫助使用者思考。

參考

  • [1] 重新執行, 重新執行, 重新執行。 很重要所以講三遍。
  • [2] 承認吧,function components 的整個 body 裡全部就都是render
  • [3] 這是 10 月底時在 Las Vegas 舉辦的 react conf 2019 其中一個 talk,Lee Byron(@leeb) - Let's Program Like It's 1999,非常精彩,他是 facebook 的早期員工,還是immutable.js的作者
response