Eine vollständige Anleitung zum Testen von React Hooks
Veröffentlicht: 2022-03-11Hooks wurden Ende 2018 in React 16.8 eingeführt. Sie sind Funktionen, die sich in eine funktionale Komponente einklinken und es uns ermöglichen, Zustands- und Komponentenfunktionen wie componentDidUpdate
, componentDidMount
und mehr zu verwenden. Dies war vorher nicht möglich.
Außerdem ermöglichen uns Hooks, Komponenten- und Zustandslogik über verschiedene Komponenten hinweg wiederzuverwenden. Das war früher schwierig. Daher waren Hooks ein Game-Changer.
In diesem Artikel werden wir untersuchen, wie man React Hooks testet. Wir werden einen ausreichend komplexen Haken auswählen und daran arbeiten, ihn zu testen.
Wir gehen davon aus, dass Sie ein begeisterter React-Entwickler sind, der bereits mit React Hooks vertraut ist. Falls Sie Ihr Wissen auffrischen möchten, sollten Sie sich unser Tutorial ansehen, und hier ist der Link zur offiziellen Dokumentation.
Der Haken, den wir zum Testen verwenden werden
Für diesen Artikel verwenden wir einen Hook, den ich in meinem vorherigen Artikel Stale-while-revalidate Data Fetching with React Hooks geschrieben habe. Der Hook heißt useStaleRefresh
. Wenn Sie den Artikel noch nicht gelesen haben, machen Sie sich keine Sorgen, denn ich werde diesen Teil hier noch einmal zusammenfassen.
Dies ist der Haken, den wir testen werden:
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]; }
Wie Sie sehen können, ist useStaleRefresh
ein Hook, der dabei hilft, Daten von einer URL abzurufen und gleichzeitig eine zwischengespeicherte Version der Daten zurückzugeben, falls vorhanden. Es verwendet einen einfachen In-Memory-Speicher, um den Cache zu halten.
Es gibt auch einen isLoading
-Wert zurück, der wahr ist, wenn noch keine Daten oder kein Cache verfügbar sind. Der Client kann es verwenden, um einen Ladeindikator anzuzeigen. Der isLoading
Wert wird auf „false“ gesetzt, wenn ein Cache oder eine neue Antwort verfügbar ist.
An dieser Stelle schlage ich vor, dass Sie einige Zeit damit verbringen, den obigen Hook zu lesen, um ein vollständiges Verständnis dessen zu bekommen, was er tut.
In diesem Artikel werden wir sehen, wie wir diesen Hook testen können, indem wir zunächst keine Testbibliotheken verwenden (nur React Test Utilities und Jest) und dann mithilfe von require-hooks-testing-library.
Die Motivation dafür, keine Testbibliotheken zu verwenden, dh nur einen Testrunner Jest
, besteht darin, zu demonstrieren, wie das Testen eines Hooks funktioniert. Mit diesem Wissen sind Sie in der Lage, alle Probleme zu debuggen, die auftreten können, wenn Sie eine Bibliothek verwenden, die Testabstraktion bereitstellt.
Definieren der Testfälle
Bevor wir mit dem Testen dieses Hooks beginnen, lassen Sie uns einen Plan erstellen, was wir testen möchten. Da wir wissen, was der Hook tun soll, hier mein Acht-Schritte-Plan zum Testen:
- Wenn der Hook mit URL
url1
gemountet wird, istisLoading
true
und data istdefaultValue
. - Nach einer asynchronen Abrufanforderung wird der Hook mit den Daten
data1
aktualisiert undisLoading
istfalse
. - Wenn die URL in
url2
geändert wird, wirdisLoading
wieder wahr und data istdefaultValue
. - Nach einer asynchronen Abrufanforderung wird der Hook mit neuen Daten
data2
aktualisiert. - Dann ändern wir die URL wieder in
url1
. Die Datendata1
werden sofort empfangen, da sie zwischengespeichert sind.isLoading
ist falsch. - Wenn nach einer asynchronen Abrufanforderung eine neue Antwort empfangen wird, werden die Daten in
data3
aktualisiert. - Dann ändern wir die URL wieder in
url2
. Die Datendata2
werden sofort empfangen, da sie zwischengespeichert sind.isLoading
ist falsch. - Wenn nach einer asynchronen Abrufanforderung eine neue Antwort empfangen wird, werden die Daten in
data4
aktualisiert.
Der oben erwähnte Testablauf definiert klar die Trajektorie, wie der Haken funktionieren wird. Wenn wir also sicherstellen können, dass dieser Test funktioniert, sind wir gut.
Hooks ohne Bibliothek testen
In diesem Abschnitt werden wir sehen, wie man Hooks testet, ohne Bibliotheken zu verwenden. Dadurch erhalten wir ein tiefes Verständnis dafür, wie man React Hooks testet.
Um mit diesem Test zu beginnen, möchten wir zunächst einen fetch
durchführen. Auf diese Weise können wir kontrollieren, was die API zurückgibt. Hier ist der verspottete fetch
.
function fetchMock(url, suffix = "") { return new Promise((resolve) => setTimeout(() => { resolve({ json: () => Promise.resolve({ data: url + suffix, }), }); }, 200 + Math.random() * 300) ); }
Dieser modifizierte fetch
geht davon aus, dass der Antworttyp immer JSON ist, und gibt standardmäßig die Parameter- url
als data
zurück. Außerdem wird der Antwort eine zufällige Verzögerung zwischen 200 ms und 500 ms hinzugefügt.
Wenn wir die Antwort ändern möchten, setzen wir einfach das zweite suffix
auf einen nicht leeren Zeichenfolgenwert.
An dieser Stelle fragen Sie sich vielleicht, warum die Verzögerung? Warum geben wir die Antwort nicht sofort zurück? Dies liegt daran, dass wir die reale Welt so weit wie möglich nachbilden möchten. Wir können den Haken nicht richtig testen, wenn wir ihn sofort zurücksenden. Sicher, wir können die Verzögerung für schnellere Tests auf 50–100 ms reduzieren, aber darüber machen wir uns in diesem Artikel keine Gedanken.
Wenn unser Fetch-Mock fertig ist, können wir es auf die fetch
-Funktion einstellen. Wir verwenden beforeAll
und afterAll
, da diese Funktion zustandslos ist und wir sie nach einem einzelnen Test nicht zurücksetzen müssen.
// runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });
Dann müssen wir den Haken in einer Komponente montieren. Warum? Weil Hooks nur Funktionen für sich sind. Nur wenn sie in Komponenten verwendet werden, können sie auf useState
, useEffect
usw. reagieren.
Also müssen wir eine TestComponent
erstellen, die uns hilft, unseren Hook zu montieren.
// 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>; }
Dies ist eine einfache Komponente, die entweder die Daten rendert oder eine „Loading“ -Textaufforderung rendert, wenn Daten geladen (abgerufen) werden.
Sobald wir die Testkomponente haben, müssen wir sie auf dem DOM mounten. Wir verwenden beforeEach
und afterEach
, um unsere Komponente für jeden Test zu mounten und zu unmounten, da wir vor jedem Test mit einem neuen DOM beginnen möchten.
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; });
Beachten Sie, dass container
eine globale Variable sein muss, da wir für Testassertionen Zugriff darauf haben möchten.
Lassen Sie uns mit diesem Satz unseren ersten Test durchführen, bei dem wir eine URL url1
rendern, und da das Abrufen der URL einige Zeit in Anspruch nehmen wird (siehe fetchMock
), sollte es anfänglich „ladenden“ Text rendern.
it("useStaleRefresh hook runs correctly", () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); })
Führen Sie den Test mit yarn test
und es funktioniert wie erwartet. Hier ist der vollständige Code auf GitHub.
Lassen Sie uns nun testen, wann sich dieser loading
in die abgerufenen Antwortdaten url1
.
Wie machen wir das? Wenn Sie sich fetchMock
, sehen Sie, dass wir 200-500 Millisekunden warten. Was wäre, wenn wir einen sleep
in den Test legen, der 500 Millisekunden wartet? Es deckt alle möglichen Wartezeiten ab. Versuchen wir das.
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"); });
Der Test wird bestanden, aber wir sehen auch einen Fehler (Code).
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(...).
Dies liegt daran, dass die Zustandsaktualisierung im useStaleRefresh
Hook außerhalb von act() erfolgt. Um sicherzustellen, dass DOM-Updates rechtzeitig verarbeitet werden, empfiehlt React, dass Sie act()
ungefähr jedes Mal verwenden, wenn ein erneutes Rendern oder UI-Update stattfinden könnte. Also müssen wir unseren Schlaf mit act
umhüllen, da dies der Zeitpunkt ist, an dem die Statusaktualisierung erfolgt. Danach verschwindet der Fehler.
import { act } from "react-dom/test-utils"; // ... await act(() => sleep(500));
Führen Sie es jetzt erneut aus (Code auf GitHub). Wie erwartet geht es ohne Fehler durch.
Testen wir die nächste Situation, in der wir zuerst die URL in url2
, dann den loading
überprüfen, dann auf die url2
warten und schließlich den Text von url2 überprüfen. Da wir jetzt wissen, wie man korrekt auf asynchrone Änderungen wartet, sollte dies einfach sein.

act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");
Führen Sie diesen Test durch, und er wird ebenfalls bestanden. Jetzt können wir auch den Fall testen, in dem sich Antwortdaten ändern und der Cache ins Spiel kommt.
Sie werden feststellen, dass wir in unserer suffix
-Funktion ein zusätzliches Argumentsuffix haben . Dies dient zum Ändern der Antwortdaten. Also aktualisieren wir unseren Fetch-Mock, um das suffix
zu verwenden.
global.fetch.mockImplementation((url) => fetchMock(url, "__"));
Jetzt können wir den Fall testen, in dem die URL erneut auf url1
gesetzt ist. Es lädt zuerst url1
und dann url1__
. Wir können dasselbe für url2
, und es sollte keine Überraschungen geben.
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__"); });
Dieser gesamte Test gibt uns die Gewissheit, dass der Hook tatsächlich wie erwartet funktioniert (Code). Hurra! Werfen wir nun einen kurzen Blick auf die Optimierung mithilfe von Hilfsmethoden.
Optimieren des Testens durch Verwendung von Hilfsmethoden
Bisher haben wir gesehen, wie wir unseren Haken vollständig testen können. Der Ansatz ist nicht perfekt, aber er funktioniert. Und doch, können wir es besser machen?
Jawohl. Beachten Sie, dass wir auf feste 500 ms warten, bis jeder Abruf abgeschlossen ist, aber jede Anfrage dauert zwischen 200 und 500 ms. Wir verschwenden hier also eindeutig Zeit. Wir können dies besser handhaben, indem wir einfach die Zeit abwarten, die jede Anfrage benötigt.
Wie machen wir das? Eine einfache Technik besteht darin, die Assertion auszuführen, bis sie erfolgreich ist oder eine Zeitüberschreitung erreicht ist. Lassen Sie uns eine waitFor
-Funktion erstellen, die das tut.
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"); } }
Diese Funktion führt einfach alle 10 ms einen Callback (cb) innerhalb eines try...catch
-Blocks aus, und wenn das timeout
erreicht ist, wird ein Fehler ausgegeben. Dies ermöglicht es uns, eine Assertion auszuführen, bis sie auf sichere Weise (dh ohne Endlosschleifen) übergeben wird.
Wir können es in unserem Test wie folgt verwenden: Anstatt 500 ms zu schlafen und dann zu behaupten, verwenden wir unsere waitFor
Funktion.
// INSTEAD OF await act(() => sleep(500)); expect(container.textContent).toBe("url1"); // WE DO await act(() => waitFor(() => { expect(container.textContent).toBe("url1"); }) );
Tun Sie es in all diesen Behauptungen, und wir können einen beträchtlichen Unterschied darin sehen, wie schnell unsere Tests (Code) ausgeführt werden.
Nun, das ist alles großartig, aber vielleicht möchten wir den Hook nicht über die Benutzeroberfläche testen. Vielleicht wollen wir einen Hook anhand seiner Rückgabewerte testen. Wie machen wir das?
Es wird nicht schwierig sein, da wir bereits Zugriff auf die Rückgabewerte unseres Hooks haben. Sie befinden sich direkt innerhalb der Komponente. Wenn wir diese Variablen in den globalen Geltungsbereich bringen können, wird es funktionieren. Also lass uns das tun.
Da wir unseren Hook über seinen Rückgabewert und nicht über das gerenderte DOM testen werden, können wir das HTML-Rendering aus unserer Komponente entfernen und es zu render null
machen. Wir sollten auch die Destrukturierung in Hook's return entfernen, um es generischer zu machen. Daher haben wir diese aktualisierte Testkomponente.
// global variable let result; function TestComponent({ url }) { result = useStaleRefresh(url, defaultValue); return null; }
Jetzt wird der Rückgabewert des Hooks in result
gespeichert, einer globalen Variablen. Wir können es für unsere Behauptungen abfragen.
// 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");
Nachdem wir es überall geändert haben, können wir sehen, dass unsere Tests bestehen (Code).
An dieser Stelle erhalten wir das Wesentliche zum Testen von React Hooks. Es gibt ein paar Verbesserungen, die wir noch vornehmen können, wie zum Beispiel:
-
result
in einen lokalen Geltungsbereich verschieben - Es entfällt die Notwendigkeit, für jeden Hook, den wir testen möchten, eine Komponente zu erstellen
Wir können dies tun, indem wir eine Factory-Funktion erstellen, die eine Testkomponente enthält. Es sollte auch den Hook in der Testkomponente rendern und uns Zugriff auf die result
geben. Mal sehen, wie wir das machen können.
Zuerst verschieben wir TestComponent
und result
in die Funktion. Außerdem müssen wir Hook und die Hook-Argumente als Funktionsargumente übergeben, damit sie in unserer Testkomponente verwendet werden können. Wenn Sie das verwenden, haben wir Folgendes. Wir nennen diese Funktion renderHook
.
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } act(() => { render(<TestComponent hookArgs={args} />, container); }); return result; }
Der Grund, warum wir result
als Objekt haben, das Daten in result.current
speichert, ist, dass wir möchten, dass die Rückgabewerte während der Testläufe aktualisiert werden. Der Rückgabewert unseres Hooks ist ein Array, also wäre er nach Wert kopiert worden, wenn wir ihn direkt zurückgegeben hätten. Indem wir es in einem Objekt speichern, geben wir einen Verweis auf dieses Objekt zurück, sodass die Rückgabewerte durch Aktualisieren von result.current
aktualisiert werden können.
Wie gehen wir nun vor, um den Hook zu aktualisieren? Da wir bereits eine Closure verwenden, fügen wir eine weitere rerender
-Funktion hinzu, die das kann.
Die endgültige renderHook
Funktion sieht folgendermaßen aus:
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 }; }
Jetzt können wir es in unserem Test verwenden. Anstatt act
und render
zu verwenden, gehen wir wie folgt vor:
const { rerender, result } = renderHook(useStaleRefresh, [ "url1", defaultValue, ]);
Dann können wir mit result.current
behaupten und den Hook mit rerender
. Hier ist ein einfaches Beispiel:
rerender(["url2", defaultValue]); expect(result.current[1]).toBe(true); // check isLoading is true
Sobald Sie es an allen Stellen geändert haben, werden Sie sehen, dass es ohne Probleme funktioniert (Code).
Brillant! Jetzt haben wir eine viel sauberere Abstraktion zum Testen von Hooks. Wir können es noch besser machen – zum Beispiel muss defaultValue
jedes Mal übergeben werden, um es neu zu rerender
, obwohl es sich nicht ändert. Wir können das reparieren.
Aber lassen Sie uns nicht zu viel um den heißen Brei herumreden, da wir bereits eine Bibliothek haben, die diese Erfahrung erheblich verbessert.
Geben Sie die Reaktionshaken-Testbibliothek ein.
Testen mit der React-Hooks-Testing-Library
React-Hooks-Testing-Library macht alles, worüber wir zuvor gesprochen haben, und noch einiges mehr. Beispielsweise übernimmt es das Mounten und Unmounten von Containern, sodass Sie dies in Ihrer Testdatei nicht tun müssen. Dadurch können wir uns auf das Testen unserer Hooks konzentrieren, ohne abgelenkt zu werden.
Es enthält eine renderHook
Funktion, die rerender
und result
zurückgibt. Es gibt auch wait
zurück, was waitFor
ähnelt, sodass Sie es nicht selbst implementieren müssen.
So rendern wir einen Hook in der React-Hooks-Testing-Library. Beachten Sie, dass der Hook in Form eines Callbacks übergeben wird. Dieser Rückruf wird jedes Mal ausgeführt, wenn die Testkomponente erneut gerendert wird.
const { result, wait, rerender } = renderHook( ({ url }) => useStaleRefresh(url, defaultValue), { initialProps: { url: "url1", }, } );
Dann können wir testen, ob das erste Rendern dazu geführt hat, dass isLoading
als true und der Rückgabewert als defaultValue
wurde, indem wir dies tun. Genau ähnlich dem, was wir oben implementiert haben.
expect(result.current[0]).toEqual(defaultValue); expect(result.current[1]).toBe(true);
Um auf asynchrone Updates zu testen, können wir die wait
-Methode verwenden, die renderHook
zurückgegeben hat. Es wird mit act()
act()
müssen.
await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);
Dann können wir rerender
verwenden, um es mit neuen Requisiten zu aktualisieren. Beachten Sie, dass wir defaultValue
hier nicht übergeben müssen.
rerender({ url: "url2" });
Schließlich wird der Rest des Tests ähnlich ablaufen (Code).
Einpacken
Mein Ziel war es, Ihnen anhand eines Beispiels für einen asynchronen Hook zu zeigen, wie Sie React Hooks testen können. Ich hoffe, dies hilft Ihnen dabei, das Testen jeder Art von Haken souverän anzugehen, da für die meisten von ihnen der gleiche Ansatz gelten sollte.
Ich würde Ihnen empfehlen, die React-Hooks-Testing-Library zu verwenden, da sie vollständig ist und ich bisher keine nennenswerten Probleme damit hatte. Falls Sie auf ein Problem stoßen, wissen Sie jetzt, wie Sie es angehen können, indem Sie die in diesem Artikel beschriebenen Feinheiten des Testens von Hooks verwenden.