Custom Hook pattern 與 如何為Custom Hook寫測試,然後要怎麼克服那些困難的點
在開始討論單元測試之前,還是必須要介紹一下什麼是 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 裡面的執行細節。
但是只要你想,也是可以把這幾種模式一起使用,形成一個更強大的模組。 關於這些問題可以再寫成另外的長篇大論,這裡就不再多做討論,或是可以看看專家們怎麼說:
- Eric Elliott - Do React Hooks Replace Higher Order Components (HOCs)?
- Kent C. Dodds - React Hooks: What's going to happen to render props?
終於可以開始講測試了....嗎?
因為 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
PHP in 1999
好啦 我相信即使都已經 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
然後
- Custom Hook 裡使用了
useSelector
,所以必須提供一個Provider
的 wrapper 讓 Custom Hook 執行在這個環境裡 - 倒入為了生出假的 store 所需要用到的假資料, 所以你一定要寫好一個很好用的能搭配 redux 的能 custom render 的 util function不然光是 mock Provider 這一步就會讓你想放棄寫測試這件事。
- 流程裡可能會在
useEffect
裡去 fetch 資料,那我們可能要另外mock好 axios 或是瀏覽器的 native fetch API, 一樣,CRA
專案的話,這些可以寫在src/setupTests.js
底下, - 或是你的資料是從
localStorage
或是indexedDB
裡取得?一樣,你必須先 mock 好這些WebAPI
,因為它們在 test runner 執行時的 node.js 的境 下是不存在的。 - 如果有用 axio, dexie 這些在 node_modules 裡的 3rd-party library 的話,還要會mock 這些 module
- 最重要的是,Custom Hook 雖然看起來很像只是一個 function,但是它只能在 react component 裡被執行,所以
@testing-library/react
render 它時 候,還不能不用一個 component 把它包起來 - custommHook 最後 return 的是自己裡面的 useState(或 useReducer) hold 住的值,但是我們的 testRunner,所以要拿到非同步動作之後的 setXXX 之 後的值,我們必須要讓 testRunner 等,這可以簡單地利用 Promise api 寫一個 util function 做到
- 在負責 use 了 Custom Hook 的
function component
外部用一個let
接出 update 後的值,然後對它expect
- @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
Okay so I actually couldn’t resist doing a variation on this one. Very basic but you get the idea. https://t.co/4kYd0zNWyt pic.twitter.com/gmBjJy7iO0
— Dan Abramov (@dan_abramov) November 3, 2018
上面的 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,和它目前的值,非常容易幫助使用者思考。