Reactフックをテストするための完全なガイド
公開: 2022-03-11フックは2018年後半にReact16.8で導入されました。フックは機能コンポーネントにフックし、 componentDidUpdate
、 componentDidMount
などの状態およびコンポーネント機能を使用できるようにする関数です。 これは以前は不可能でした。
また、フックを使用すると、コンポーネントと状態ロジックをさまざまなコンポーネント間で再利用できます。 これは以前はやっかいでした。 したがって、フックはゲームチェンジャーでした。
この記事では、Reactフックをテストする方法を探ります。 十分に複雑なフックを選び、テストに取り組みます。
あなたはすでにReactフックに精通している熱心なReact開発者であることを期待しています。 知識を磨きたい場合は、チュートリアルを確認してください。公式ドキュメントへのリンクは次のとおりです。
テストに使用するフック
この記事では、前回の記事「Reactフックを使用した再検証中のデータフェッチ」で書いたフックを使用します。 フックは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からデータをフェッチするのに役立つフックです。 単純なメモリ内ストアを使用してキャッシュを保持します。
また、データまたはキャッシュがまだ使用できない場合にtrueとなるisLoading
値を返します。 クライアントはこれを使用して、ロードインジケータを表示できます。 キャッシュまたは新しい応答が使用可能な場合、 isLoading
値はfalseに設定されます。
この時点で、上記のフックを読んで、それが何をするのかを完全に理解することをお勧めします。
この記事では、最初にテストライブラリを使用せず(React Test UtilitiesとJestのみ)、次にreact-hooks-testing-libraryを使用して、このフックをテストする方法を説明します。
テストライブラリを使用しない、つまりテストランナーのJest
のみを使用する動機は、フックのテストがどのように機能するかを示すことです。 その知識があれば、テストの抽象化を提供するライブラリを使用するときに発生する可能性のある問題をデバッグできます。
テストケースの定義
このフックのテストを開始する前に、テストする対象の計画を立てましょう。 フックが何をするのかがわかっているので、フックをテストするための8つのステップの計画を次に示します。
- フックが
url1
でマウントされている場合、isLoading
はtrue
であり、データはdefaultValue
。 - 非同期フェッチ要求の後、フックはデータ
data1
で更新され、isLoading
はfalse
です。 - URLが
url2
に変更されると、isLoading
は再びtrueになり、データはdefaultValue
になります。 - 非同期フェッチ要求の後、フックは新しいデータ
data2
で更新されます。 - 次に、URLを
url1
に戻します。 データdata1
はキャッシュされているため、即座に受信されます。isLoading
はfalseです。 - 非同期フェッチ要求の後、新しい応答を受信すると、データは
data3
に更新されます。 - 次に、URLを
url2
に戻します。 データdata2
はキャッシュされているため、即座に受信されます。isLoading
はfalseです。 - 非同期フェッチ要求の後、新しい応答を受信すると、データは
data4
に更新されます。
上記のテストフローは、フックがどのように機能するかの軌道を明確に定義します。 したがって、このテストが機能することを確認できれば、問題ありません。
ライブラリなしでフックをテストする
このセクションでは、ライブラリを使用せずにフックをテストする方法を説明します。 これにより、Reactフックをテストする方法を深く理解できます。
このテストを開始するには、まず、 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
値として返すことを前提としています。 また、応答に200msから500msのランダムな遅延を追加します。
応答を変更する場合は、2番目の引数のsuffix
を空でない文字列値に設定するだけです。
この時点で、なぜ遅れるのかと疑問に思うかもしれません。 すぐに応答を返さないのはなぜですか? これは、現実の世界を可能な限り再現したいからです。 すぐに戻すと、フックを正しくテストできません。 もちろん、テストを高速化するために遅延を50〜100ミリ秒に減らすことができますが、この記事ではそれについて心配する必要はありません。
フェッチモックの準備ができたら、 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にマウントする必要があります。 各テストの前に新しいDOMから開始する必要があるため、 beforeEach
とafterEach
を使用して、各テストのコンポーネントをマウントおよびアンマウントします。
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
画面を確認し、フェッチ応答を待ち、最後にurl2
テキストを確認する次の状況をテストしてみましょう。 非同期の変更を正しく待つ方法がわかったので、これは簡単なはずです。

act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");
このテストを実行すると、同様に合格します。 これで、応答データが変更されてキャッシュが機能する場合をテストすることもできます。
fetchMock関数に追加の引数suffix
があることに気付くでしょう。 応答データを変更するためのものです。 したがって、 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
に達するとエラーをスローします。 これにより、安全な方法で(つまり、無限ループがなくなるまで)アサーションを実行できます。
テストでは、次のように使用できます。500ミリ秒間スリープしてからアサートする代わりに、 waitFor
関数を使用します。
// 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フックのテストの要点を理解します。 次のような、まだ実行できる改善点がいくつかあります。
-
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.current
にデータを格納するオブジェクトとしてresult
を取得する理由は、テストの実行時に戻り値を更新する必要があるためです。 フックの戻り値は配列であるため、直接返すと値によってコピーされます。 オブジェクトに格納することで、そのオブジェクトへの参照を返すため、 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, ]);
次に、 rerender
を使用してアサートし、 result.current
を使用してフックを更新できます。 簡単な例を次に示します。
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
関数が付属しています。 また、 waitFor
に似たwait
を返すため、自分で実装する必要はありません。
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()
をラップact()
必要はありません。
await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);
次に、 rerender
を使用して、新しい小道具で更新できます。 ここでdefaultValue
を渡す必要がないことに注意してください。
rerender({ url: "url2" });
最後に、残りのテストも同様に進行します(コード)。
まとめ
私の目的は、非同期フックの例を取り上げて、Reactフックをテストする方法を示すことでした。 同じアプローチがほとんどのフックに適用されるので、これがあらゆる種類のフックのテストに自信を持って取り組むのに役立つことを願っています。
React-hooks-testing-libraryは完全なので、使用することをお勧めします。これまでのところ、重大な問題は発生していません。 問題が発生した場合に備えて、この記事で説明する複雑なテストフックを使用して問題に対処する方法を理解しました。