[note] React Hook Testing Library
react-hooks-testing-library @ testing/library
React Hooks Testing Library 主要提供三個方法:renderHook
, act
和 cleanup
。
什麼時候該用此 library
根據官網的描述,使用此 library 前需要留意:
不要使用此 library 的時機
- 你的 Hook 只專門給某個對應的 component 使用時(通常 hook 檔案就直接放在 component 旁邊)
- 你的 Hook 非常容易測試,因此只需要直接針對元件進行測試即可
使用此 library 的時機
- 你正在寫一個函式庫,且裏面的 Hooks 並不是只在某個對應的元件中被使用
- 你的 Hook 非常複雜,以至於它沒有辦法單純靠測試元件就測到所有 hook 的功能
renderHook
透過 result.current
可以取得最新的資料狀態:
import { renderHook } from "@testing-library/react-hooks";
const renderHookResult = renderHook(callback, renderHookOptions);
// renderHookOptions
const renderHookOptions = {
initialProps,
wrapper,
};
// RenderHookResult
type RenderHookResult = {
// RenderResult
result: {
readonly all: Array<TValue | Error>;
readonly current: TValue;
readonly error?: Error;
}
// Renderer
render: (props?: TProps) => void;
rerender: (props?: TProps) => void;
unmount: () => void; // 用來觸發 useEffect 的 cleanup 函式
act: Act;
// AsyncUtils
waitFor: [Function: waitFor];
waitForNextUpdate: [Function: waitForNextUpdate];
waitForValueToChange: [Function: waitForValueToChange];
} ;
不需要取出 Hook 的回傳值
renderHook
後續如果沒有需要帶入其他參數來 update 時,可以直接把要放入的參數傳入:
// 這兩種寫法都可以
it('xxx', () => {
renderHook(() => useFoobar(args));
});
it('xxx', () => {
renderHook((args) => useFoobar(args), { initialProps: 'foobar' });
});
實際的例子像這樣:
import { renderHook } from '@testing-library/react-hooks';
import useBlockDocumentScroll from './use-block-document-scroll';
it('should add hidden to body when shouldBlockScroll is true', () => {
expect(document.body.style.overflow).not.toBe('hidden');
// 呼叫 render hook 但不需要取出 Hook 的回傳值
renderHook((shouldBlockScroll) => useBlockDocumentScroll(shouldBlockScroll), {
initialProps: true,
});
expect(document.body.style.overflow).toBe('hidden');
});
需要取出 Hook 的回傳值
如果需 要取得 hook 的回傳值,需要把 renderHook
回傳的 result
取出,並且透過 result.current
可以取得當前最新的值:
it('get null if not invoking refreshPromoStorage', () => {
const { result } = renderHook(() => usePromoStorage());
const { promoStorage } = result.current;
expect(promoStorage).toBe(null);
});
需要使用 rerender 並帶入新的 props
如果後續會透過 rerender
來更新 hooks 中的參數值時,renderHook
裡面帶入的 callback function 也要能接收參數:
it('xxx', () => {
const initialValue = 'foo';
const { rerender } = renderHook((args) => useFoobar(args), { initialProps: 'foobar' });
// newValue 會是 hook 取得的新參數值
rerender('newValue');
});
實際的範例:
it('should not add hidden after shouldBlockScroll update from true to false', () => {
const initialOverflow = document.body.style.overflow;
expect(initialOverflow).not.toBe('hidden');
// renderHook 的 callback function 能夠接收參數(shouldBlockScroll)
// 並當成參數帶入 useBlockDocumentScroll 中
const { rerender } = renderHook(
(shouldBlockScroll) => useBlockDocumentScroll(shouldBlockScroll),
{
initialProps: true,
},
);
expect(document.body.style.overflow).toBe('hidden');
// 使用 rerender,並可以在傳入 hooks 接受到的參數
rerender(false);
expect(document.body.style.overflow).toBe(initialOverflow);
});
使用 unmount
it('should not add hidden to body after unmount', () => {
const initialOverflow = document.body.style.overflow;
expect(initialOverflow).not.toBe('hidden');
// 取得 unmount
const { unmount } = renderHook(() => useBlockDocumentScroll(true));
expect(document.body.style.overflow).toBe('hidden');
// 呼叫 unmount
unmount();
expect(document.body.style.overflow).toBe(initialOverflow);
});
act
將會導致 Hook 內部 state 改變的方法包在 act 中
當要執行會導致 hook 內部 state 改變的方法時(例如,setXXX
),需要把這個方法包在 act 後才加以呼叫:
it('get promoCode after invoking refreshPromoStorage', () => {
const { result } = renderHook(() => usePromoStorage());
// 呼叫 usePromoStorage hook 中提供的方法
act(() => {
result.current.refreshPromoStorage();
});
// 取得 hook 中最新的值
const { promoStorage } = result.current;
expect(promoStorage).toEqual(
expect.objectContaining({
code: 'this is promo code',
}),
);
});
Example
import { renderHook, act } from '@testing-library/react-hooks';
import useOpenTokReducer from './use-opentok-reducer';
test('addConnection and removeConnection', () => {
const { result } = renderHook(() => useOpenTokReducer());
// 透過 result.current 取得初始的資料狀態
const [initialState, actions] = result.current;
const { addConnection, removeConnection } = actions;
const initialConnections = initialState.connections;
// 檢測起始狀態
expect(initialConnections).toEqual([]);
// 執行某個方法
act(() => {
addConnection({
id: mockData.id,
connectionId: mockData.id,
});
});
// 透過 result.current 取得最新的資料狀態
const [stateAfterAddConnection] = result.current;
// 檢測變更後的狀態
expect(stateAfterAddConnection.connections.length).toBe(initialConnections.length + 1);
// 執行某個方法
act(() => {
removeConnection({
id: mockData.id,
connectionId: mockData.id,
});
});
// 透過 result.current 取得最新的資料狀態
const [stateAfterRemoveConnection] = result.current;
// 檢測變更後的狀態
expect(stateAfterRemoveConnection.connections.length).toBe(initialConnections.length);
});
其他
針對透過陣列解構賦值的 Hook
針對會回傳陣列的 Hooks,可以在 renderHook
的 callback 中回傳解構後的內容:
describe('useOpenClose', () => {
// 在 renderHook 的 callback 中透過解構賦值來重新定義想要的結構
const { result } = renderHook(() => {
const [isOpen, actions] = useOpenClose();
return { isOpen, ...actions };
});
test('Should have initial value of false', () => {
console.log(result.current.isOpen);
});
test('Should update value to true', () => {
act(() => result.current.open());
console.log(result.current.isOpen);
});
});
act() doesn't seem to be updating state @ github issue
使用 Wrapper Options
如果該測試的 Hook 需要被包在其他 Provider 中才能進行測試,可以使用 renderHook
中的 wrapper
options:
// https://www.udemy.com/course/learn-react-query/learn/lecture/26581764
import { act, renderHook } from '@testing-library/react-hooks';
import { createQueryClientWrapper } from '../../../test-utils';
import { useStaff } from '../hooks/useStaff';
test('filter staff', async () => {
// render the custom hook
const { result, waitFor } = renderHook(() => useStaff(), {
// 這裡因為 react-query 需要被包在 <QueryClientProvider /> 才能使用
wrapper: createQueryClientWrapper(),
});
/// assert the all staff length should larger thant 0
await waitFor(() => result.current.staff.length > 0);
// can ge the number of staffs after waitFor
const allStaffLength = result.current.staff.length;
// wrap method that will change the state in the hook in the act function
act(() => {
// filter by treatment
result.current.setFilter('scrub');
});
// assert the all staff length is greater than the filtered staff length
await waitFor(() => allStaffLength > result.current.staff.length);
});