測試 React Hooks 的完整指南
已發表: 2022-03-11Hooks 於 2018 年底在 React 16.8 中引入。它們是掛鉤到功能組件的函數,允許我們使用 state 和組件特性,如componentDidUpdate
、 componentDidMount
等。 這在以前是不可能的。
此外,鉤子允許我們在不同組件之間重用組件和狀態邏輯。 這在以前很難做到。 因此,鉤子已經改變了遊戲規則。
在本文中,我們將探討如何測試 React Hooks。 我們將選擇一個足夠複雜的鉤子並進行測試。
我們希望您是已經熟悉 React Hooks 的狂熱 React 開發人員。 如果你想複習你的知識,你應該看看我們的教程,這裡是官方文檔的鏈接。
我們將用於測試的鉤子
對於本文,我們將使用我在之前的文章 Stale-while-revalidate Data Fetching with React Hooks 中編寫的鉤子。 該鉤子稱為useStaleRefresh
。 如果你還沒有讀過這篇文章,別擔心,我會在這裡重述那部分。
這是我們將要測試的鉤子:
import { useState, useEffect } from "react"; const CACHE = {}; export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); setData(defaultValue); } // fetch new data fetch(url) .then((res) => res.json()) .then((newData) => { CACHE[cacheID] = newData; setData(newData); setLoading(false); }); }, [url, defaultValue]); return [data, isLoading]; }
如您所見, useStaleRefresh
是一個鉤子,可幫助從 URL 獲取數據,同時返回數據的緩存版本(如果存在)。 它使用一個簡單的內存存儲來保存緩存。
如果還沒有數據或緩存可用,它還會返回一個isLoading
值。 客戶端可以使用它來顯示加載指示器。 當緩存或新響應可用時, isLoading
值設置為 false。
在這一點上,我建議你花一些時間閱讀上面的鉤子,以全面了解它的作用。
在本文中,我們將看到如何測試這個鉤子,首先不使用測試庫(僅使用 React Test Utilities 和 Jest),然後使用 react-hooks-testing-library。
不使用測試庫(即僅使用測試運行器Jest
)背後的動機是為了演示測試鉤子的工作原理。 有了這些知識,您將能夠調試在使用提供測試抽象的庫時可能出現的任何問題。
定義測試用例
在我們開始測試這個鉤子之前,讓我們想出一個我們想要測試的計劃。 既然我們知道鉤子應該做什麼,這是我測試它的八步計劃:
- 當使用 URL
url1
掛載掛鉤時,isLoading
為true
且 data 為defaultValue
。 - 在異步獲取請求之後,鉤子會使用數據
data1
更新,並且isLoading
為false
。 - 當 URL 更改為
url2
時,isLoading
再次變為 true 並且 data 為defaultValue
。 - 在異步獲取請求之後,使用新數據
data2
更新掛鉤。 - 然後,我們將 URL 改回
url1
。 數據data1
被緩存後立即被接收。isLoading
為假。 - 在異步獲取請求之後,當收到新的響應時,數據將更新為
data3
。 - 然後,我們將 URL 改回
url2
。 數據data2
被緩存後立即被接收。isLoading
為假。 - 在異步獲取請求之後,當接收到新的響應時,數據將更新為
data4
。
上面提到的測試流程清楚地定義了鉤子如何運行的軌跡。 因此,如果我們能確保這個測試有效,我們就很好。
在沒有庫的情況下測試 Hooks
在本節中,我們將了解如何在不使用任何庫的情況下測試鉤子。 這將使我們深入了解如何測試 React Hooks。
要開始這個測試,首先,我們要模擬fetch
。 這樣我們就可以控制 API 返回的內容。 這是模擬的fetch
。
function fetchMock(url, suffix = "") { return new Promise((resolve) => setTimeout(() => { resolve({ json: () => Promise.resolve({ data: url + suffix, }), }); }, 200 + Math.random() * 300) ); }
此修改後的fetch
假定響應類型始終為 JSON,默認情況下,它返回參數url
作為data
值。 它還為響應添加了 200 毫秒到 500 毫秒之間的隨機延遲。
如果我們想更改響應,我們只需將第二個參數suffix
設置為非空字符串值。
此時,您可能會問,為什麼會延遲? 為什麼我們不立即返迴響應? 這是因為我們想盡可能地複制現實世界。 如果我們立即返回它,我們將無法正確測試鉤子。 當然,我們可以將延遲減少到 50-100 毫秒以加快測試速度,但在本文中我們不必擔心。
準備好 fetch 模擬後,我們可以將其設置為fetch
函數。 我們使用beforeAll
和afterAll
這樣做是因為這個函數是無狀態的,所以我們不需要在單獨測試後重置它。
// runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });
然後,我們需要將鉤子掛載到一個組件中。 為什麼? 因為鉤子本身就是函數。 只有在組件中使用時才能響應useState
、 useEffect
等。
所以,我們需要創建一個TestComponent
來幫助我們掛載我們的鉤子。
// defaultValue is a global variable to avoid changing the object pointer on re-render // we can also deep compare `defaultValue` inside the hook's useEffect const defaultValue = { data: "" }; function TestComponent({ url }) { const [data, isLoading] = useStaleRefresh(url, defaultValue); if (isLoading) { return <div>loading</div>; } return <div>{data.data}</div>; }
這是一個簡單的組件,它要么呈現數據,要么在數據正在加載(正在獲取)時呈現“正在加載”文本提示。
一旦我們有了測試組件,我們就需要將它掛載到 DOM 上。 我們使用beforeEach
和afterEach
來為每個測試安裝和卸載我們的組件,因為我們希望在每次測試之前從一個新的 DOM 開始。
let container = null; beforeEach(() => { // set up a DOM element as a render target container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { // cleanup on exiting unmountComponentAtNode(container); container.remove(); container = null; });
請注意, container
必須是全局變量,因為我們希望能夠訪問它以進行測試斷言。
有了這個設置,讓我們在渲染 URL url1
的地方進行第一個測試,由於獲取 URL 需要一些時間(請參閱fetchMock
),它應該首先呈現“正在加載”的文本。
it("useStaleRefresh hook runs correctly", () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); })
使用yarn test
運行測試,它按預期工作。 這是 GitHub 上的完整代碼。
現在,讓我們測試這個loading
文本何時更改為獲取的響應數據url1
。
我們如何做到這一點? 如果您查看fetchMock
,您會看到我們等待了 200-500 毫秒。 如果我們在等待 500 毫秒的測試中設置sleep
會怎樣? 它將涵蓋所有可能的等待時間。 讓我們試試看。
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } it("useStaleRefresh hook runs correctly", async () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); await sleep(500); expect(container.textContent).toBe("url1"); });
測試通過了,但我們也看到了一個錯誤(代碼)。
PASS src/useStaleRefresh.test.js ✓ useStaleRefresh hook runs correctly (519ms) console.error node_modules/react-dom/cjs/react-dom.development.js:88 Warning: An update to TestComponent inside a test was not wrapped in act(...).
這是因為useStaleRefresh
鉤子中的狀態更新發生在 act() 之外。 為了確保及時處理 DOM 更新,React 建議您在每次可能發生重新渲染或 UI 更新時使用act()
。 因此,我們需要用act
包裹我們的睡眠,因為這是狀態更新發生的時間。 這樣做之後,錯誤就消失了。
import { act } from "react-dom/test-utils"; // ... await act(() => sleep(500));
現在,再次運行它(GitHub 上的代碼)。 正如預期的那樣,它沒有錯誤地通過。
讓我們測試下一種情況,我們首先將 URL 更改為url2
,然後檢查loading
屏幕,然後等待 fetch 響應,最後檢查url2
文本。 由於我們現在知道如何正確等待異步更改,這應該很容易。

act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");
運行這個測試,它也通過了。 現在,我們還可以測試響應數據發生變化和緩存發揮作用的情況。
你會注意到我們的fetchMock函數中有一個額外的參數suffix
。 這是為了更改響應數據。 所以我們更新我們的 fetch mock 以使用suffix
.
global.fetch.mockImplementation((url) => fetchMock(url, "__"));
現在,我們可以再次測試將 URL 設置為url1
的情況。 它首先加載url1
,然後加載url1__
。 我們可以對url2
做同樣的事情,應該不會有什麼意外。
it("useStaleRefresh hook runs correctly", async () => { // ... // new response global.fetch.mockImplementation((url) => fetchMock(url, "__")); // set url to url1 again act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("url1"); await act(() => sleep(500)); expect(container.textContent).toBe("url1__"); // set url to url2 again act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toBe("url2"); await act(() => sleep(500)); expect(container.textContent).toBe("url2__"); });
整個測試使我們確信鉤子確實按預期工作(代碼)。 歡呼! 現在,讓我們快速瀏覽一下使用輔助方法進行的優化。
使用輔助方法優化測試
到目前為止,我們已經看到瞭如何完全測試我們的鉤子。 這種方法並不完美,但它確實有效。 然而,我們能做得更好嗎?
是的。 請注意,我們正在等待固定的 500 毫秒來完成每次提取,但每個請求需要 200 到 500 毫秒之間的任何時間。 所以,我們顯然是在浪費時間。 我們可以通過等待每個請求花費的時間來更好地處理這個問題。
我們如何做到這一點? 一種簡單的技術是執行斷言,直到它通過或達到超時。 讓我們創建一個執行此操作的waitFor
函數。
async function waitFor(cb, timeout = 500) { const step = 10; let timeSpent = 0; let timedOut = false; while (true) { try { await sleep(step); timeSpent += step; cb(); break; } catch {} if (timeSpent >= timeout) { timedOut = true; break; } } if (timedOut) { throw new Error("timeout"); } }
這個函數只是每 10 毫秒在try...catch
塊內運行一個回調(cb),如果達到timeout
,它會拋出一個錯誤。 這允許我們運行一個斷言,直到它以安全的方式通過(即,沒有無限循環)。
我們可以在測試中使用它,如下所示:我們使用waitFor
函數,而不是休眠 500 毫秒然後斷言。
// INSTEAD OF await act(() => sleep(500)); expect(container.textContent).toBe("url1"); // WE DO await act(() => waitFor(() => { expect(container.textContent).toBe("url1"); }) );
在所有這樣的斷言中都這樣做,我們可以看到我們的測試運行速度(代碼)有很大的不同。
現在,這一切都很好,但也許我們不想通過 UI 測試鉤子。 也許我們想使用它的返回值來測試一個鉤子。 我們如何做到這一點?
這並不難,因為我們已經可以訪問我們的鉤子的返回值。 它們就在組件內部。 如果我們可以將這些變量放到全局範圍內,它就會起作用。 所以讓我們這樣做。
由於我們將通過它的返回值而不是渲染 DOM 來測試我們的鉤子,我們可以從我們的組件中刪除 HTML 渲染並使其呈現null
。 我們還應該刪除鉤子返回中的解構以使其更通用。 因此,我們有了這個更新的測試組件。
// global variable let result; function TestComponent({ url }) { result = useStaleRefresh(url, defaultValue); return null; }
現在鉤子的返回值被存儲在一個全局變量result
中。 我們可以查詢它以獲取我們的斷言。
// INSTEAD OF expect(container.textContent).toContain("loading"); // WE DO expect(result[1]).toBe(true); // INSTEAD OF expect(container.textContent).toBe("url1"); // WE DO expect(result[0].data).toBe("url1");
在我們到處更改它之後,我們可以看到我們的測試正在通過(代碼)。
至此,我們了解了測試 React Hooks 的要點。 我們仍然可以進行一些改進,例如:
- 將
result
變量移動到本地範圍 - 無需為我們要測試的每個鉤子創建組件
我們可以通過創建一個內部有一個測試組件的工廠函數來做到這一點。 它還應該在測試組件中渲染鉤子並讓我們訪問result
變量。 讓我們看看如何做到這一點。
首先,我們在函數內部移動TestComponent
和result
。 我們還需要將 Hook 和 Hook 參數作為函數的參數傳遞,以便它們可以在我們的測試組件中使用。 使用它,這就是我們所擁有的。 我們調用這個函數renderHook
。
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } act(() => { render(<TestComponent hookArgs={args} />, container); }); return result; }
我們將result
作為一個在result.current
中存儲數據的對象的原因是因為我們希望在測試運行時更新返回值。 我們的鉤子的返回值是一個數組,所以如果我們直接返回它就會被按值複製。 通過將它存儲在一個對像中,我們返回對該對象的引用,因此可以通過更新result.current
來更新返回值。
現在,我們如何去更新鉤子? 由於我們已經在使用閉包,讓我們封裝另一個可以執行此操作的函數rerender
。
最終的renderHook
函數如下所示:
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } function rerender(args) { act(() => { render(<TestComponent hookArgs={args} />, container); }); } rerender(args); return { result, rerender }; }
現在,我們可以在測試中使用它。 我們不使用act
和render
,而是執行以下操作:
const { rerender, result } = renderHook(useStaleRefresh, [ "url1", defaultValue, ]);
然後,我們可以使用result.current
斷言並使用rerender
更新鉤子。 這是一個簡單的例子:
rerender(["url2", defaultValue]); expect(result.current[1]).toBe(true); // check isLoading is true
一旦你在所有地方改變它,你會看到它沒有任何問題(代碼)。
傑出的! 現在我們有了一個更清晰的抽象來測試鉤子。 我們仍然可以做得更好——例如,每次都需要傳遞defaultValue
來重新rerender
,即使它沒有改變。 我們可以解決這個問題。
但是我們不要繞彎子太多,因為我們已經有一個可以顯著改善這種體驗的庫。
輸入 react-hooks-testing-library。
使用 React-hooks-testing-library 進行測試
React-hooks-testing-library 完成了我們之前討論過的所有事情,然後是一些。 例如,它處理容器安裝和卸載,因此您不必在測試文件中執行此操作。 這使我們能夠專注於測試我們的鉤子而不會分心。
它帶有一個返回rerender
和result
的renderHook
函數。 它還返回wait
,類似於waitFor
,因此您不必自己實現它。
下面是我們如何在 React-hooks-testing-library 中渲染一個鉤子。 注意鉤子是以回調的形式傳遞的。 每次測試組件重新渲染時都會運行此回調。
const { result, wait, rerender } = renderHook( ({ url }) => useStaleRefresh(url, defaultValue), { initialProps: { url: "url1", }, } );
然後,我們可以通過這樣做來測試第一次渲染是否導致isLoading
為 true 並返回值為defaultValue
。 與我們上面實現的完全相似。
expect(result.current[0]).toEqual(defaultValue); expect(result.current[1]).toBe(true);
為了測試異步更新,我們可以使用renderHook
返回的wait
方法。 它帶有act()
包裹,因此我們不需要將act()
包裹在它周圍。
await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);
然後,我們可以使用rerender
用新的 props 更新它。 請注意,我們不需要在這里傳遞defaultValue
。
rerender({ url: "url2" });
最後,其餘的測試將以類似的方式進行(代碼)。
包起來
我的目的是通過一個異步鉤子的例子向你展示如何測試 React Hooks。 我希望這可以幫助您自信地處理任何類型的鉤子的測試,因為同樣的方法應該適用於大多數鉤子。
我建議你使用 React-hooks-testing-library,因為它已經完成了,到目前為止我還沒有遇到過重大問題。 如果您確實遇到了問題,您現在知道如何使用本文中描述的測試鉤子來解決它。