Integrationstests passen gut zu interaktiven Websites, wie sie Sie vielleicht mit React erstellen. Sie validieren, wie ein Benutzer mit Ihrer App interagiert, ohne den Overhead von End-to-End-Tests.
Dieser Artikel folgt einer Übung, die mit einer einfachen Website beginnt, das Verhalten mit Unit- und Integrationstests validiert und zeigt, wie Integrationstests mit weniger Codezeilen mehr Wert liefern. Der Inhalt setzt Vertrautheit mit React und Tests in JavaScript voraus. Erfahrung mit Jest und React Testing Library ist hilfreich, aber nicht zwingend erforderlich.
Es gibt drei Arten von Tests
- Unit-Tests überprüfen ein Codestück isoliert. Sie sind einfach zu schreiben, können aber das Gesamtbild verpassen.
- End-to-End-Tests (E2E) verwenden ein Automatisierungs-Framework – wie z. B. Cypress oder Selenium –, um wie ein Benutzer mit Ihrer Website zu interagieren: Seiten laden, Formulare ausfüllen, Schaltflächen klicken usw. Sie sind im Allgemeinen langsamer zu schreiben und auszuführen, entsprechen aber eng der realen Benutzererfahrung.
- Integrationstests liegen irgendwo dazwischen. Sie validieren, wie mehrere Einheiten Ihrer Anwendung zusammenarbeiten, sind aber schlanker als E2E-Tests. Jest beispielsweise verfügt über einige integrierte Hilfsprogramme zur Erleichterung von Integrationstests; Jest verwendet jsdom im Hintergrund, um gängige Browser-APIs mit weniger Overhead als Automatisierung zu emulieren, und seine robusten Mocking-Tools können externe API-Aufrufe stubben.
Ein weiterer Punkt: In React-Apps werden Unit- und Integrationstests auf die gleiche Weise und mit den gleichen Tools geschrieben.
Erste Schritte mit React-Tests
Ich habe eine einfache React-App erstellt (verfügbar auf GitHub) mit einem Login-Formular. Ich habe diese mit reqres.in verbunden, einer praktischen API, die ich zum Testen von Front-End-Projekten gefunden habe.
Sie können sich erfolgreich anmelden

…oder eine Fehlermeldung von der API erhalten

Der Code ist wie folgt strukturiert
LoginModule/
├── components/
⎪ ├── Login.js // renders LoginForm, error messages, and login confirmation
⎪ └── LoginForm.js // renders login form fields and button
├── hooks/
⎪ └── useLogin.js // connects to API and manages state
└── index.js // stitches everything together
Option 1: Unit-Tests
Wenn Sie wie ich gerne Tests schreiben – vielleicht mit Kopfhörern auf und etwas Gutes auf Spotify –, dann sind Sie vielleicht versucht, für jede Datei einen Unit-Test zu schreiben.
Selbst wenn Sie kein Test-Aficionado sind, arbeiten Sie vielleicht an einem Projekt, das „gut im Testen sein will“, ohne eine klare Strategie und einen Testansatz à la „Ich schätze, jede Datei sollte ihren eigenen Test haben?“.
Das würde ungefähr so aussehen (wobei ich unit zu den Testdateinamen hinzugefügt habe, um die Klarheit zu erhöhen)
LoginModule/
├── components/
⎪ ├── Login.js
⎪ ├── Login.unit.test.js
⎪ ├── LoginForm.js
⎪ └── LoginForm.unit.test.js
├── hooks/
⎪ ├── useLogin.js
⎪ └── useLogin.unit.test.js
├── index.js
└── index.unit.test.js
Ich habe die Übung durchgeführt, alle diese Unit-Tests auf GitHub hinzuzufügen, und ein Skript test:coverage:unit erstellt, um einen Coverage-Bericht zu generieren (eine integrierte Funktion von Jest). Wir können mit den vier Unit-Testdateien 100 % Abdeckung erreichen

100% Abdeckung ist normalerweise übertrieben, aber für eine so einfache Codebasis erreichbar.
Schauen wir uns einen der Unit-Tests für den onLogin React Hook an. Machen Sie sich keine Sorgen, wenn Sie mit React Hooks oder deren Tests nicht vertraut sind.
test('successful login flow', async () => {
// mock a successful API response
jest
.spyOn(window, 'fetch')
.mockResolvedValue({ json: () => ({ token: '123' }) });
const { result, waitForNextUpdate } = renderHook(() => useLogin());
act(() => {
result.current.onSubmit({
email: '[email protected]',
password: 'password',
});
});
// sets state to pending
expect(result.current.state).toEqual({
status: 'pending',
user: null,
error: null,
});
await waitForNextUpdate();
// sets state to resolved, stores email address
expect(result.current.state).toEqual({
status: 'resolved',
user: {
email: '[email protected]',
},
error: null,
});
});
Dieser Test hat Spaß gemacht zu schreiben (weil React Hooks Testing Library das Testen von Hooks zum Kinderspiel macht), aber er hat ein paar Probleme.
Erstens validiert der Test, dass ein internes State-Element von 'pending' zu 'resolved' wechselt; dieses Implementierungsdetail ist für den Benutzer nicht sichtbar und daher wahrscheinlich kein guter Testgegenstand. Wenn wir die App refaktorisieren, müssen wir diesen Test aktualisieren, auch wenn sich aus Benutzersicht nichts ändert.
Zusätzlich ist dies als Unit-Test nur ein Teil des Ganzen. Wenn wir andere Funktionen des Login-Flows validieren wollen, z. B. dass sich der Text des Submit-Buttons zu „Loading“ ändert, müssen wir dies in einer anderen Testdatei tun.
Option 2: Integrationstests
Betrachten wir den alternativen Ansatz, einen Integrationstest hinzuzufügen, um diesen Flow zu validieren
LoginModule/
├── components/
⎪ ├─ Login.js
⎪ └── LoginForm.js
├── hooks/
⎪ └── useLogin.js
├── index.js
└── index.integration.test.js
Ich habe diesen Test implementiert und ein Skript test:coverage:integration zum Generieren eines Coverage-Berichts erstellt. Genau wie bei den Unit-Tests können wir 100% Abdeckung erreichen, aber diesmal ist alles in einer Datei und erfordert weniger Codezeilen.

Hier ist der Integrationstest, der einen erfolgreichen Login-Flow abdeckt
test('successful login', async () => {
jest
.spyOn(window, 'fetch')
.mockResolvedValue({ json: () => ({ token: '123' }) });
render(<LoginModule />);
const emailField = screen.getByRole('textbox', { name: 'Email' });
const passwordField = screen.getByLabelText('Password');
const button = screen.getByRole('button');
// fill out and submit form
fireEvent.change(emailField, { target: { value: '[email protected]' } });
fireEvent.change(passwordField, { target: { value: 'password' } });
fireEvent.click(button);
// it sets loading state
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Loading...');
await waitFor(() => {
// it hides form elements
expect(button).not.toBeInTheDocument();
expect(emailField).not.toBeInTheDocument();
expect(passwordField).not.toBeInTheDocument();
// it displays success text and email address
const loggedInText = screen.getByText('Logged in as');
expect(loggedInText).toBeInTheDocument();
const emailAddressText = screen.getByText('[email protected]');
expect(emailAddressText).toBeInTheDocument();
});
});
Ich mag diesen Test sehr, weil er den gesamten Login-Flow aus der Perspektive des Benutzers validiert: das Formular, den Ladezustand und die Erfolgsbestätigungsnachricht. Integrationstests eignen sich für React-Apps hervorragend für genau diesen Anwendungsfall; die Benutzererfahrung ist das Ding, das wir testen wollen, und das beinhaltet fast immer mehrere verschiedene Codeteile, die zusammenarbeiten.
Dieser Test hat kein spezifisches Wissen über die Komponenten oder Hooks, die das erwartete Verhalten ermöglichen, und das ist gut so. Wir sollten in der Lage sein, solche Implementierungsdetails neu zu schreiben und umzustrukturieren, ohne die Tests zu brechen, solange die Benutzererfahrung gleich bleibt.
Ich werde nicht auf die anderen Integrationstests für den initialen Zustand und die Fehlerbehandlung des Login-Flows eingehen, aber ich ermutige Sie, sie auf GitHub anzusehen.
Was braucht also einen Unit-Test?
Anstatt über Unit- vs. Integrationstests nachzudenken, treten wir einen Schritt zurück und überlegen uns, wie wir überhaupt entscheiden, was getestet werden muss. LoginModule muss getestet werden, da es eine Entität ist, deren Nutzung durch Verbraucher (andere Dateien in der App) mit Vertrauen erfolgen kann.
Der onLogin-Hook hingegen muss nicht getestet werden, da er nur ein Implementierungsdetail von LoginModule ist. Wenn sich jedoch unsere Bedürfnisse ändern und onLogin Anwendungsfälle an anderer Stelle hat, würden wir unsere eigenen (Unit-)Tests hinzufügen, um seine Funktionalität als wiederverwendbare Utility zu validieren. (Wir würden die Datei auch verschieben, da sie dann nicht mehr spezifisch für LoginModule wäre.)
Es gibt immer noch viele Anwendungsfälle für Unit-Tests, z. B. die Notwendigkeit, wiederverwendbare Selektoren, Hooks und reine Funktionen zu validieren. Bei der Entwicklung Ihres Codes kann es auch hilfreich sein, **testgetriebene Entwicklung** mit einem Unit-Test zu praktizieren, auch wenn Sie diese Logik später in einen Integrationstest verschieben.
Darüber hinaus eignen sich Unit-Tests hervorragend, um eine Vielzahl von Eingaben und Anwendungsfällen erschöpfend zu testen. Wenn mein Formular beispielsweise Inline-Validierungen für verschiedene Szenarien anzeigen müsste (z. B. ungültige E-Mail, fehlendes Passwort, kurzes Passwort), würde ich einen repräsentativen Fall in einem Integrationstest abdecken und dann die spezifischen Fälle in einem Unit-Test untersuchen.
Weitere Goodies
Während wir hier sind, möchte ich auf ein paar syntaktische Tricks eingehen, die mir geholfen haben, meine Integrationstests übersichtlich und organisiert zu halten.
Eindeutige waitFor-Blöcke
Unser Test muss die Verzögerung zwischen dem Lade- und dem Erfolgszustand von LoginModule berücksichtigen
const button = screen.getByRole('button');
fireEvent.click(button);
expect(button).not.toBeInTheDocument(); // too soon, the button is still there!
Das können wir mit dem waitFor-Helfer von DOM Testing Library tun
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
expect(button).not.toBeInTheDocument(); // ahh, that's better
});
Aber was ist, wenn wir auch andere Elemente testen wollen? Es gibt nicht viele gute Beispiele dafür online, und in früheren Projekten habe ich zusätzliche Elemente außerhalb von waitFor platziert.
// wait for the button
await waitFor(() => {
expect(button).not.toBeInTheDocument();
});
// then test the confirmation message
const confirmationText = getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
Das funktioniert, aber ich mag es nicht, weil die Bedingung des Buttons besonders aussieht, obwohl wir die Reihenfolge dieser Anweisungen genauso gut ändern könnten.
// wait for the confirmation message
await waitFor(() => {
const confirmationText = getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
});
// then test the button
expect(button).not.toBeInTheDocument();
Es ist meiner Meinung nach viel besser, alles, was mit demselben Update zusammenhängt, innerhalb des waitFor-Callbacks zu gruppieren.
await waitFor(() => {
expect(button).not.toBeInTheDocument();
const confirmationText = screen.getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
});
Ich mag diese Technik für einfache Assertionen wie diese wirklich, aber sie kann Ihre Tests in bestimmten Fällen verlangsamen, indem sie auf Fehler wartet, die außerhalb von waitFor sofort auftreten würden. Siehe „Mehrere Assertions in einem einzigen waitFor-Callback“ in Häufige Fehler mit React Testing Library für ein Beispiel.
Für Tests mit mehreren Schritten können wir mehrere waitFor-Blöcke hintereinander haben.
const button = screen.getByRole('button');
const emailField = screen.getByRole('textbox', { name: 'Email' });
// fill out form
fireEvent.change(emailField, { target: { value: '[email protected]' } });
await waitFor(() => {
// check button is enabled
expect(button).not.toBeDisabled();
expect(button).toHaveTextContent('Submit');
});
// submit form
fireEvent.click(button);
await waitFor(() => {
// check button is no longer present
expect(button).not.toBeInTheDocument();
});
Wenn Sie nur darauf warten, dass ein Element erscheint, können Sie stattdessen die findBy-Abfrage verwenden. Sie verwendet waitFor im Hintergrund.
Inline it-Kommentare
Eine weitere Best Practice beim Testen ist, weniger, längere Tests zu schreiben; dies ermöglicht es Ihnen, Ihre Testfälle mit bedeutenden Benutzer-Flows zu korrelieren und gleichzeitig die Tests isoliert zu halten, um unerwartetes Verhalten zu vermeiden. Ich folge diesem Ansatz, aber er kann Herausforderungen bei der Organisation des Codes und der Dokumentation des gewünschten Verhaltens mit sich bringen. Wir brauchen zukünftige Entwickler, um zu einem Test zurückkehren zu können und zu verstehen, was er tut, warum er fehlschlägt usw.
Nehmen wir zum Beispiel an, eine dieser Erwartungen beginnt zu fehlschlagen
it('handles a successful login flow', async () => {
// beginning of test hidden for clarity
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Loading...');
await waitFor(() => {
expect(button).not.toBeInTheDocument();
expect(emailField).not.toBeInTheDocument();
expect(passwordField).not.toBeInTheDocument();
const confirmationText = screen.getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
});
});
Ein Entwickler, der dies untersucht, kann nicht leicht feststellen, was getestet wird, und könnte Schwierigkeiten haben zu entscheiden, ob der Fehler ein Bug ist (was bedeutet, dass wir den Code beheben sollten) oder eine Verhaltensänderung (was bedeutet, dass wir den Test beheben sollten).
Meine Lieblingslösung für dieses Problem ist die Verwendung der weniger bekannten test-Syntax für jeden Test und das Hinzufügen von Inline-it-ähnlichen Kommentaren, die jedes wichtige getestete Verhalten beschreiben.
test('successful login', async () => {
// beginning of test hidden for clarity
// it sets loading state
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Loading...');
await waitFor(() => {
// it hides form elements
expect(button).not.toBeInTheDocument();
expect(emailField).not.toBeInTheDocument();
expect(passwordField).not.toBeInTheDocument();
// it displays success text and email address
const confirmationText = screen.getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();
});
});
Diese Kommentare werden nicht magisch mit Jest integriert, daher wird bei einem Fehler der Name des fehlerhaften Tests mit dem Argument übereinstimmen, das Sie dem test-Tag übergeben haben, in diesem Fall 'successful login'. Allerdings enthalten Jest-Fehlermeldungen den umliegenden Code, sodass diese it-Kommentare immer noch helfen, das fehlerhafte Verhalten zu identifizieren. Hier ist die Fehlermeldung, die ich erhalten habe, als ich das not aus einer meiner Erwartungen entfernt habe.

Für noch explizitere Fehler gibt es ein Paket namens jest-expect-message, das es Ihnen ermöglicht, Fehlermeldungen für jede Erwartung zu definieren.
expect(button, 'button is still in document').not.toBeInTheDocument();
Manche Entwickler bevorzugen diesen Ansatz, aber ich finde ihn in den meisten Situationen ein wenig zu granular, da ein einzelnes it oft mehrere Erwartungen beinhaltet.
Nächste Schritte für Teams
Manchmal wünschte ich, wir könnten Linter-Regeln für Menschen machen. Wenn ja, könnten wir für unsere Teams eine Regel „bevorzuge Integrationstests“ einrichten und es dabei belassen.
Aber leider brauchen wir eine analogere Lösung, um Entwickler zu ermutigen, sich in Situationen wie dem früher behandelten LoginModule-Beispiel für Integrationstests zu entscheiden. Wie bei den meisten Dingen kommt es darauf an, Ihre Teststrategie im Team zu besprechen, sich auf etwas zu einigen, das für das Projekt sinnvoll ist, und es hoffentlich in einem ADR zu dokumentieren.
Bei der Erstellung eines Testplans sollten wir eine Kultur vermeiden, die Entwickler unter Druck setzt, für jede Datei einen Test zu schreiben. Entwickler müssen sich befugt fühlen, kluge Testentscheidungen zu treffen, ohne sich Sorgen machen zu müssen, dass sie „nicht genug testen“. Jest-Coverage-Berichte können dabei helfen, indem sie eine Überprüfung bieten, dass Sie eine gute Abdeckung erzielen, auch wenn die Tests auf der Integrationsebene konsolidiert sind.
Ich betrachte mich immer noch nicht als Experte für Integrationstests, aber die Durchführung dieser Übung hat mir geholfen, einen Anwendungsfall zu zerlegen, bei dem Integrationstests mehr Wert lieferten als Unit-Tests. Ich hoffe, dass das Teilen dieser Erkenntnisse mit Ihrem Team oder die Durchführung einer ähnlichen Übung mit Ihrer Codebasis Ihnen dabei hilft, Integrationstests in Ihren Workflow zu integrieren.
Hallo,
sehr schöner Artikel!
Ich versuche auch, Integrationstests für meine Anwendung zu schreiben! Aber ich habe ein Problem, dass sich die Mocks ständig ändern, sodass ich meine JSON-Mocks ständig aktualisieren muss und die Integrationstests dann nutzlos werden.
Wie gehen Sie mit API-Mocks um?
Danke, Mariam!
Ich schätze, es würde davon abhängen, warum sich die Mocks ständig ändern. Wenn Ihre API noch nicht stabil ist und Sie das Gefühl haben, Zeit durch die Aktualisierung Ihrer Mocks und Tests zu verlieren, möchten Sie die Tests möglicherweise deaktivieren, bis Sie einen stabilen Punkt erreichen. Wenn die Mocks doppelte Aufgaben als Mocks für Tests und Mocks für die UI erfüllen (vielleicht während die API entwickelt wird), sollten Sie die Mocks aufteilen, damit eine kleine Änderung wie das Ändern eines Titels Ihre Tests nicht bricht. Jests allgemeine Übereinstimmungen wie „expect.any“ und „expect.objectContaining“ können ebenfalls helfen, flexiblere Tests zu erstellen. Hoffentlich hilft das!
Toller Artikel, ich benutze die gleichen Bibliotheken in meinen Projekten und glaube auch, dass das Testen des Verhaltens besser ist als das Testen der Implementierung, um refactoring-sichere Tests zu haben ;-).
Es gab jedoch eine Sache: Anstatt einen Test mit dem Schlüsselwort „test“ und jedem „it“ als Kommentar zu schreiben. Sie könnten Ihre Tests in einen „describe“-Block einschließen und sie weiterhin mit dem Schlüsselwort „it“ deklarieren. Auf diese Weise haben Sie winzige Tests, die in einem „describe“-Block gruppiert sind und das gesamte Szenario darstellen. So zeigt die Konsolenausgabe genau, welcher Schritt des Szenarios fehlerhaft ist, ohne dass Kommentare geschrieben und gepflegt werden müssen.
Danke fürs Lesen!
Ich denke, die Verwendung von describe/it würde in diesem Fall technisch funktionieren und sicherlich eine schöne Konsolenausgabe liefern, aber ich wäre zögerlich, dies projektweit zu tun, da jeder „it“/„test“-Block als Test in Jest gilt und beforeEach/afterEach-Flags auslöst oder etwas wie die clearMocks-Konfigurationsflag (die ich tendenziell verwende: https://jestjs.io/docs/en/configuration.html#clearmocks-boolean).
Interessanter Artikel, aber ich muss sagen, ich bin immer weniger begeistert von Integrationstests. Ich meine, was ist ihr Zweck eigentlich? Sie testen einen Teil der UI, aber isoliert. Sie hängen also vom Willen und Wissen des Entwicklers ab. Ich denke, das Testen könnte groß angelegter sein. Mit Unit-Tests kann ich reine Logik testen, die im Allgemeinen einfacher mit reinen Funktionen zu testen ist. Und es ermutigt Sie, Ansichten mit sehr wenigen Logikelementen zu haben. Ihre dummen Komponenten sollten also nur die Reflexion Ihrer komplexeren Logik sein. Außerdem sind Unit-Tests viel schneller auszuführen und zu beheben. Auf der anderen Seite denke ich, dass End-to-End-Tests besser für Ihre Anwendungsfälle geeignet sind und in echten Browsern ausgeführt werden! Natürlich sind sie auch viel langsamer auszuführen. Aber wenn Sie kritische Pfade wünschen, denke ich, sind sie am besten. Mein Punkt ist nicht zu sagen, dass Sie falsch liegen, ich meine, wenn es für Sie funktioniert, ist es großartig. Aber ich denke, Tests sollten Teil eines größeren Umfangs sein, der viel größer ist als die Entwicklungszeit.
Interessanter Punkt! Ich denke, es hängt von der Form Ihrer App ab. Oftmals habe ich in meinen Apps komplexe Komponenten oder Module, deren Testen auf Integrationsebene sinnvoll ist, da sie auf mehreren Seiten erscheinen können, aber ich muss immer noch mehrere Teile zusammen testen.
Es ist kein echter Integrationstest, da er nicht im Browser ausgeführt wird und Sie nicht das volle Ausmaß vieler möglicher Szenarien testen können, zum Beispiel eine Komponente, die eine andere Komponente in einem Portal öffnet, und diese Komponente dynamische Messungen hat, die von DOM-APIs oder Ähnlichem abhängen.
Für diese Entwickler, wie mich, verwenden wir in den letzten Jahren Jest+Puppeteer.
Ich denke, „Integrationstests“ haben je nach Person ein paar Definitionen, aber ich neige dazu, das, was Sie ansprechen, als End-to-End- oder Funktionstests zu bezeichnen. Ich halte mich weitgehend an die Aufschlüsselung in diesem Blogbeitrag: https://kentcdodds.com/blog/unit-vs-integration-vs-e2e-tests
Puppeteer ist aber wirklich cool!
Toller Artikel, Mann!