Skip to main content

[note] React Hook Testing Library

react-hooks-testing-library @ testing/library

React Hooks Testing Library 主要提供三個方法:renderHook, actcleanup

什麼時候該用此 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);
});