Reactフックをテストするための完全なガイド

公開: 2022-03-11

フックは2018年後半にReact16.8で導入されました。フックは機能コンポーネントにフックし、 componentDidUpdatecomponentDidMountなどの状態およびコンポーネント機能を使用できるようにする関数です。 これは以前は不可能でした。

また、フックを使用すると、コンポーネントと状態ロジックをさまざまなコンポーネント間で再利用できます。 これは以前はやっかいでした。 したがって、フックはゲームチェンジャーでした。

この記事では、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つのステップの計画を次に示します。

  1. フックがurl1でマウントされている場合、 isLoadingtrueであり、データはdefaultValue
  2. 非同期フェッチ要求の後、フックはデータdata1で更新され、 isLoadingfalseです。
  3. URLがurl2に変更されると、 isLoadingは再びtrueになり、データはdefaultValueになります。
  4. 非同期フェッチ要求の後、フックは新しいデータdata2で更新されます。
  5. 次に、URLをurl1に戻します。 データdata1はキャッシュされているため、即座に受信されます。 isLoadingはfalseです。
  6. 非同期フェッチ要求の後、新しい応答を受信すると、データはdata3に更新されます。
  7. 次に、URLをurl2に戻します。 データdata2はキャッシュされているため、即座に受信されます。 isLoadingはfalseです。
  8. 非同期フェッチ要求の後、新しい応答を受信すると、データは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であり、デフォルトでパラメーターurldata値として返すことを前提としています。 また、応答に200msから500msのランダムな遅延を追加します。

応答を変更する場合は、2番目の引数のsuffixを空でない文字列値に設定するだけです。

この時点で、なぜ遅れるのかと疑問に思うかもしれません。 すぐに応答を返さないのはなぜですか? これは、現実の世界を可能な限り再現したいからです。 すぐに戻すと、フックを正しくテストできません。 もちろん、テストを高速化するために遅延を50〜100ミリ秒に減らすことができますが、この記事ではそれについて心配する必要はありません。

フェッチモックの準備ができたら、 fetch関数に設定できます。 この関数はステートレスであり、個々のテストの後でリセットする必要がないため、これを行うためにbeforeAllafterAllを使用します。

 // runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });

次に、フックをコンポーネントに取り付ける必要があります。 なんで? フックはそれ自体が単なる機能だからです。 コンポーネントで使用された場合にのみ、 useStateuseEffectなどに応答できます。

したがって、フックをマウントするのに役立つ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から開始する必要があるため、 beforeEachafterEachを使用して、各テストのコンポーネントをマウントおよびアンマウントします。

 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フックのテストの要点を理解します。 次のような、まだ実行できる改善点がいくつかあります。

  1. result変数をローカルスコープに移動する
  2. テストするすべてのフックのコンポーネントを作成する必要がなくなります

テストコンポーネントを内部に持つファクトリ関数を作成することでそれを行うことができます。 また、テストコンポーネントのフックをレンダリングし、 result変数へのアクセスを提供する必要があります。 それをどのように行うことができるか見てみましょう。

まず、 TestComponentresultを関数内に移動します。 また、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 }; }

これで、テストで使用できます。 actrenderを使用する代わりに、次のことを行います。

 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は、これまでに説明したすべてのことを実行し、その後いくつかを実行します。 たとえば、コンテナのマウントとアンマウントを処理するため、テストファイルでそれを行う必要はありません。 これにより、気を散らすことなくフックのテストに集中できます。

rerenderresultを返す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は完全なので、使用することをお勧めします。これまでのところ、重大な問題は発生していません。 問題が発生した場合に備えて、この記事で説明する複雑なテストフックを使用して問題に対処する方法を理解しました。