Ein Node.js-Leitfaden zur tatsächlichen Durchführung von Integrationstests
Veröffentlicht: 2022-03-11Integrationstests sind nichts, wovor man sich fürchten sollte. Sie sind ein wesentlicher Bestandteil des vollständigen Tests Ihrer Anwendung.
Wenn wir über Tests sprechen, denken wir normalerweise an Unit-Tests, bei denen wir einen kleinen Teil des Codes isoliert testen. Ihre Anwendung ist jedoch größer als dieser kleine Codeblock, und fast kein Teil Ihrer Anwendung funktioniert isoliert. Hier bewähren sich Integrationstests. Integrationstests setzen dort an, wo Unit-Tests zu kurz kommen, und schließen die Lücke zwischen Unit-Tests und End-to-End-Tests.
In diesem Artikel erfahren Sie, wie Sie lesbare und zusammensetzbare Integrationstests mit Beispielen in API-basierten Anwendungen schreiben.
Während wir JavaScript/Node.js für alle Codebeispiele in diesem Artikel verwenden, können die meisten besprochenen Ideen problemlos an Integrationstests auf jeder Plattform angepasst werden.
Unit-Tests vs. Integrationstests: Sie brauchen beides
Unit-Tests konzentrieren sich auf eine bestimmte Codeeinheit. Oft ist dies eine bestimmte Methode oder eine Funktion einer größeren Komponente.
Diese Tests werden isoliert durchgeführt, wobei alle externen Abhängigkeiten normalerweise unterdrückt oder verspottet werden.
Mit anderen Worten, Abhängigkeiten werden durch vorprogrammiertes Verhalten ersetzt, wodurch sichergestellt wird, dass das Ergebnis des Tests nur von der Korrektheit der zu testenden Einheit bestimmt wird.
Hier erfahren Sie mehr über Unit-Tests.
Komponententests werden verwendet, um qualitativ hochwertigen Code mit gutem Design zu erhalten. Sie ermöglichen es uns auch, Eckfälle einfach abzudecken.
Der Nachteil ist jedoch, dass Komponententests die Interaktion zwischen Komponenten nicht abdecken können. Hier werden Integrationstests nützlich.
Integrationstests
Wenn Unit-Tests definiert werden, indem die kleinsten Codeeinheiten isoliert getestet werden, dann sind Integrationstests genau das Gegenteil.
Integrationstests werden verwendet, um mehrere größere Einheiten (Komponenten) im Zusammenspiel zu testen, und können manchmal sogar mehrere Systeme umfassen.
Der Zweck von Integrationstests besteht darin, Fehler in den Verbindungen und Abhängigkeiten zwischen verschiedenen Komponenten zu finden, wie zum Beispiel:
- Übergeben ungültiger oder falsch geordneter Argumente
- Defektes Datenbankschema
- Ungültige Cache-Integration
- Fehler in der Geschäftslogik oder Fehler im Datenfluss (da das Testen jetzt aus einer breiteren Sicht erfolgt).
Wenn die von uns getesteten Komponenten keine komplizierte Logik haben (z. B. Komponenten mit minimaler zyklomatischer Komplexität), werden Integrationstests weitaus wichtiger als Unit-Tests.
In diesem Fall werden Unit-Tests hauptsächlich verwendet, um ein gutes Code-Design zu erzwingen.
Während Unit-Tests dabei helfen sicherzustellen, dass Funktionen richtig geschrieben sind, helfen Integrationstests sicherzustellen, dass das System als Ganzes richtig funktioniert. Sowohl Unit-Tests als auch Integrationstests dienen also jeweils ihrem eigenen komplementären Zweck, und beide sind für einen umfassenden Testansatz unerlässlich.
Unit-Tests und Integrationstests sind wie zwei Seiten derselben Medaille. Ohne beides ist die Münze nicht gültig.
Daher ist das Testen erst abgeschlossen, wenn Sie sowohl die Integrations- als auch die Komponententests abgeschlossen haben.
Richten Sie die Suite für Integrationstests ein
Während das Einrichten einer Testsuite für Unit-Tests ziemlich einfach ist, ist das Einrichten einer Testsuite für Integrationstests oft schwieriger.
Beispielsweise können Komponenten in Integrationstests Abhängigkeiten haben, die außerhalb des Projekts liegen, wie Datenbanken, Dateisysteme, E-Mail-Anbieter, externe Zahlungsdienste und so weiter.
Gelegentlich müssen Integrationstests diese externen Dienste und Komponenten verwenden, und manchmal können sie abgestumpft werden.
Wenn sie benötigt werden, kann dies zu mehreren Herausforderungen führen.
- Fragile Testausführung: Externe Dienste können nicht verfügbar sein, eine ungültige Antwort zurückgeben oder sich in einem ungültigen Zustand befinden. In einigen Fällen kann dies zu einem falsch positiven Ergebnis führen, in anderen Fällen zu einem falsch negativen Ergebnis.
- Langsame Ausführung: Das Vorbereiten und Verbinden mit externen Diensten kann langsam sein. Normalerweise werden Tests als Teil von CI auf einem externen Server ausgeführt.
- Komplexer Testaufbau: Externe Dienste müssen zum Testen im gewünschten Zustand sein. Beispielsweise sollte die Datenbank mit den erforderlichen Testdaten usw. vorgeladen werden.
Anweisungen zum Schreiben von Integrationstests
Integrationstests haben keine strengen Regeln wie Einheitentests. Trotzdem sind beim Schreiben von Integrationstests einige allgemeine Anweisungen zu beachten.
Wiederholbare Tests
Testreihenfolge oder Abhängigkeiten sollten das Testergebnis nicht verändern. Das mehrmalige Ausführen desselben Tests sollte immer dasselbe Ergebnis liefern. Dies kann schwierig sein, wenn der Test das Internet verwendet, um eine Verbindung zu Diensten von Drittanbietern herzustellen. Dieses Problem kann jedoch durch Stubbing und Mocking umgangen werden.
Bei externen Abhängigkeiten, über die Sie mehr Kontrolle haben, hilft das Einrichten von Schritten vor und nach einem Integrationstest sicherzustellen, dass der Test immer ausgehend von einem identischen Status ausgeführt wird.
Relevante Aktionen testen
Um alle möglichen Fälle zu testen, sind Unit-Tests eine weitaus bessere Option.
Integrationstests orientieren sich mehr an der Verbindung zwischen Modulen, daher ist das Testen glücklicher Szenarien normalerweise der richtige Weg, da es die wichtigen Verbindungen zwischen Modulen abdeckt.
Verständlicher Test und Behauptung
Eine schnelle Ansicht des Tests sollte den Leser darüber informieren, was getestet wird, wie die Umgebung eingerichtet ist, was gestubbt wird, wann der Test ausgeführt wird und was behauptet wird. Zusicherungen sollten einfach sein und Helfer zum besseren Vergleichen und Protokollieren verwenden.
Einfacher Testaufbau
Den Test in den Ausgangszustand zu bringen, sollte so einfach und verständlich wie möglich sein.
Vermeiden Sie es, Code von Drittanbietern zu testen
Obwohl Dienste von Drittanbietern in Tests verwendet werden können, besteht keine Notwendigkeit, sie zu testen. Und wenn Sie ihnen nicht vertrauen, sollten Sie sie wahrscheinlich nicht verwenden.
Lassen Sie den Produktionscode frei von Testcode
Der Produktionscode sollte sauber und unkompliziert sein. Das Mischen von Testcode mit Produktionscode führt dazu, dass zwei nicht verbindbare Domänen miteinander gekoppelt werden.
Relevante Protokollierung
Fehlgeschlagene Tests sind ohne gute Protokollierung nicht sehr wertvoll.
Wenn die Tests bestanden werden, ist keine zusätzliche Protokollierung erforderlich. Aber wenn sie ausfallen, ist eine umfassende Protokollierung unerlässlich.
Die Protokollierung sollte alle Datenbankabfragen, API-Anforderungen und -Antworten sowie einen vollständigen Vergleich dessen enthalten, was behauptet wird. Dies kann das Debuggen erheblich erleichtern.
Gute Tests sehen sauber und nachvollziehbar aus
Ein einfacher Test, der den hierin enthaltenen Richtlinien folgt, könnte wie folgt aussehen:
const co = require('co'); const test = require('blue-tape'); const factory = require('factory'); const superTest = require('../utils/super_test'); const testEnvironment = require('../utils/test_environment_preparer'); const path = '/v1/admin/recipes'; test(`API GET ${path}`, co.wrap(function* (t) { yield testEnvironment.prepare(); const recipe1 = yield factory.create('recipe'); const recipe2 = yield factory.create('recipe'); const serverResponse = yield superTest.get(path); t.deepEqual(serverResponse.body, [recipe1, recipe2]); }));
Der obige Code testet eine API ( GET /v1/admin/recipes
), die erwartet, dass sie als Antwort ein Array gespeicherter Rezepte zurückgibt.
Sie können sehen, dass der Test, so einfach er auch sein mag, auf viele Dienstprogramme angewiesen ist. Dies ist bei jeder guten Integrationstestsuite üblich.
Hilfskomponenten erleichtern das Schreiben verständlicher Integrationstests.
Sehen wir uns an, welche Komponenten für Integrationstests benötigt werden.
Hilfskomponenten
Eine umfassende Test-Suite hat einige grundlegende Bestandteile, darunter: Flusskontrolle, Test-Framework, Datenbank-Handler und eine Möglichkeit, eine Verbindung zu Backend-APIs herzustellen.
Ablaufsteuerung
Eine der größten Herausforderungen beim JavaScript-Testen ist der asynchrone Fluss.
Rückrufe können Chaos im Code anrichten und Versprechungen sind einfach nicht genug. Hier werden Strömungshelfer nützlich.
Während Sie darauf warten, dass async/await vollständig unterstützt wird, können Bibliotheken mit ähnlichem Verhalten verwendet werden. Das Ziel ist es, lesbaren, aussagekräftigen und robusten Code mit der Möglichkeit eines asynchronen Flusses zu schreiben.
Co ermöglicht es, Code auf nette Weise zu schreiben, während er nicht blockiert. Dies erfolgt durch Definieren einer Co-Generatorfunktion und anschließendem Erzielen von Ergebnissen.
Eine andere Lösung ist die Verwendung von Bluebird. Bluebird ist eine Promise-Bibliothek mit sehr nützlichen Funktionen wie der Behandlung von Arrays, Fehlern, Zeit usw.
Co- und Bluebird-Coroutine verhalten sich ähnlich wie async/await in ES7 (Warten auf Auflösung, bevor fortgefahren wird), der einzige Unterschied besteht darin, dass immer ein Promise zurückgegeben wird, was für die Behandlung von Fehlern nützlich ist.

Test-Framework
Die Wahl eines Test-Frameworks hängt nur von den persönlichen Vorlieben ab. Ich bevorzuge ein Framework, das einfach zu verwenden ist, keine Nebenwirkungen hat und dessen Ausgabe leicht lesbar und leitbar ist.
Es gibt eine breite Palette von Test-Frameworks in JavaScript. In unseren Beispielen verwenden wir Tape. Tape erfüllt meiner Meinung nach nicht nur diese Anforderungen, sondern ist auch sauberer und einfacher als andere Testframeworks wie Mocha oder Jasmin.
Tape basiert auf dem Test Anything Protocol (TAP).
TAP hat Variationen für die meisten Programmiersprachen.
Tape nimmt Tests als Eingabe, führt sie aus und gibt die Ergebnisse dann als TAP aus. Das TAP-Ergebnis kann dann an den Testreporter geleitet oder in einem Rohformat an die Konsole ausgegeben werden. Tape wird über die Befehlszeile ausgeführt.
Tape hat einige nette Funktionen, wie das Definieren eines Moduls, das geladen werden soll, bevor die gesamte Testsuite ausgeführt wird, das Bereitstellen einer kleinen und einfachen Assertion-Bibliothek und das Definieren der Anzahl von Assertionen, die in einem Test aufgerufen werden sollen. Die Verwendung eines vorab zu ladenden Moduls kann die Vorbereitung einer Testumgebung vereinfachen und unnötigen Code entfernen.
Fabrikbibliothek
Mit einer Werksbibliothek können Sie Ihre statischen Vorrichtungsdateien durch eine viel flexiblere Methode zum Generieren von Daten für einen Test ersetzen. Mit einer solchen Bibliothek können Sie Modelle definieren und Entitäten für diese Modelle erstellen, ohne unordentlichen, komplexen Code zu schreiben.
JavaScript hat dafür factory_girl - eine Bibliothek, die von einem Edelstein mit ähnlichem Namen inspiriert wurde, der ursprünglich für Ruby on Rails entwickelt wurde.
const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, { username: 'Bob', number_of_recipes: 50 }); const user = factory.build('user');
Zu Beginn muss ein neues Modell in factory_girl definiert werden.
Es wird mit einem Namen, einem Modell aus Ihrem Projekt und einem Objekt angegeben, aus dem eine neue Instanz generiert wird.
Anstatt das Objekt zu definieren, aus dem eine neue Instanz generiert wird, kann alternativ eine Funktion bereitgestellt werden, die ein Objekt oder eine Zusage zurückgibt.
Beim Erstellen einer neuen Instanz eines Modells können wir:
- Überschreiben Sie jeden Wert in der neu generierten Instanz
- Übergeben Sie zusätzliche Werte an die Build-Funktionsoption
Sehen wir uns ein Beispiel an.
const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, (buildOptions) => { return { name: 'Mike', surname: 'Dow', email: buildOptions.email || '[email protected]' } }); const user1 = factory.build('user'); // {"name": "Mike", "surname": "Dow", "email": "[email protected]"} const user2 = factory.build('user', {name: 'John'}, {email: '[email protected]'}); // {"name": "John", "surname": "Dow", "email": "[email protected]"}
Verbindung zu APIs
Das Starten eines vollständigen HTTP-Servers und das Stellen einer tatsächlichen HTTP-Anfrage, nur um ihn einige Sekunden später herunterzufahren – insbesondere wenn mehrere Tests durchgeführt werden – ist völlig ineffizient und kann dazu führen, dass Integrationstests erheblich länger als nötig dauern.
SuperTest ist eine JavaScript-Bibliothek zum Aufrufen von APIs, ohne einen neuen aktiven Server zu erstellen. Es basiert auf SuperAgent, einer Bibliothek zum Erstellen von TCP-Anfragen. Mit dieser Bibliothek müssen keine neuen TCP-Verbindungen erstellt werden. APIs werden fast sofort aufgerufen.
SuperTest, mit Unterstützung für Versprechungen, ist Supertest-wie-versprochen. Wenn eine solche Anfrage ein Promise zurückgibt, können Sie mehrere verschachtelte Callback-Funktionen vermeiden, was die Handhabung des Flusses erheblich vereinfacht.
const express = require('express') const request = require('supertest-as-promised'); const app = express(); request(app).get("/recipes").then(res => assert(....));
SuperTest wurde für das Express.js-Framework entwickelt, kann aber mit kleinen Änderungen auch mit anderen Frameworks verwendet werden.
Andere Dienstprogramme
In einigen Fällen ist es notwendig, Abhängigkeiten in unserem Code zu simulieren, die Logik um Funktionen herum mit Spionen zu testen oder an bestimmten Stellen Stubs zu verwenden. Hier sind einige dieser Hilfspakete nützlich.
SinonJS ist eine großartige Bibliothek, die Spies, Stubs und Mocks für Tests unterstützt. Es unterstützt auch andere nützliche Testfunktionen wie Biegezeit, Test-Sandbox und erweiterte Assertion sowie gefälschte Server und Anfragen.
In einigen Fällen ist es notwendig, eine gewisse Abhängigkeit in unserem Code zu simulieren. Verweise auf Dienste, die wir verspotten möchten, werden von anderen Teilen des Systems verwendet.
Um dieses Problem zu lösen, können wir Dependency Injection verwenden oder, falls dies nicht möglich ist, einen Spottdienst wie Mockery verwenden.
Spott hilft beim Spotten von Code, der externe Abhängigkeiten hat. Um es richtig zu verwenden, sollte Mockery vor dem Laden von Tests oder Code aufgerufen werden.
const mockery = require('mockery'); mockery.enable({ warnOnReplace: false, warnOnUnregistered: false }); const mockingStripe = require('lib/services/internal/stripe'); mockery.registerMock('lib/services/internal/stripe', mockingStripe);
Mit dieser neuen Referenz (in diesem Beispiel mockingStripe
) ist es einfacher, später in unseren Tests Dienste zu simulieren.
const stubStripeTransfer = sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null));
Mit Hilfe der Sinon-Bibliothek ist es einfach zu verspotten. Das einzige Problem hierbei ist, dass dieser Stub an andere Tests weitergegeben wird. Um es zu sandboxen, kann die Sinon-Sandbox verwendet werden. Damit können spätere Tests das System wieder in den Ausgangszustand bringen.
const sandbox = require('sinon').sandbox.create(); const stubStripeTransfer = sandbox.sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null)); // after the test, or better when starting a new test sandbox.restore();
Es werden weitere Komponenten benötigt für Funktionen wie:
- Leeren der Datenbank (kann mit einer vorgefertigten Hierarchieabfrage durchgeführt werden)
- In den Arbeitszustand versetzen (Sequelize-Fixtures)
- Verspotten von TCP-Anfragen an Dienste von Drittanbietern (nock)
- Reichere Behauptungen verwenden (chai)
- Gespeicherte Antworten von Drittanbietern (easy-fix)
Nicht so einfache Tests
Abstraktion und Erweiterbarkeit sind Schlüsselelemente beim Aufbau einer effektiven Integrationstestsuite. Alles, was den Fokus vom Kern des Tests (Vorbereitung seiner Daten, Aktion und Behauptung) entfernt, sollte in Nutzenfunktionen gruppiert und abstrahiert werden.
Obwohl es hier keinen richtigen oder falschen Weg gibt, da alles vom Projekt und seinen Anforderungen abhängt, sind einige Schlüsselqualitäten dennoch jeder guten Integrationstestsuite gemeinsam.
Der folgende Code zeigt, wie eine API getestet wird, die ein Rezept erstellt und als Nebeneffekt eine E-Mail sendet.
Es stoppt den externen E-Mail-Anbieter, sodass Sie testen können, ob eine E-Mail gesendet worden wäre, ohne tatsächlich eine zu senden. Der Test überprüft auch, ob die API mit dem entsprechenden Statuscode geantwortet hat.
const co = require('co'); const factory = require('factory'); const superTest = require('../utils/super_test'); const basicEnv = require('../utils/basic_test_enivornment'); const path = '/v1/admin/recipes'; basicEnv.test(`API POST ${path}`, co.wrap(function* (t, assert, sandbox) { const chef = yield factory.create('chef'); const body = { chef_id: chef.id, recipe_name: 'cake', Ingredients: ['carrot', 'chocolate', 'biscuit'] }; const stub = sandbox.stub(mockery.emailProvider, 'sendNewEmail').returnsPromise(null); const serverResponse = yield superTest.get(path, body); assert.spies(stub).called(1); assert.statusCode(serverResponse, 201); }));
Der obige Test ist wiederholbar, da er jedes Mal mit einer sauberen Umgebung beginnt.
Es hat einen einfachen Einrichtungsprozess, bei dem alles, was mit der Einrichtung zu tun hat, in der Funktion basicEnv.test
konsolidiert wird.
Es testet nur eine Aktion – eine einzelne API. Und es gibt die Erwartungen des Tests durch einfache Assert-Aussagen klar wieder. Außerdem beinhaltet der Test keinen Code von Drittanbietern durch Stubbing/Mocking.
Beginnen Sie mit dem Schreiben von Integrationstests
Beim Pushen von neuem Code in die Produktion möchten Entwickler (und alle anderen Projektbeteiligten) sicher sein, dass neue Funktionen funktionieren und alte nicht kaputt gehen.
Dies ist ohne Tests sehr schwer zu erreichen, und wenn es schlecht gemacht wird, kann es zu Frustration, Projektermüdung und schließlich zum Scheitern des Projekts führen.
Integrationstests in Kombination mit Unit-Tests sind die erste Verteidigungslinie.
Die Verwendung von nur einem der beiden reicht nicht aus und lässt viel Platz für unentdeckte Fehler. Immer beides zu nutzen, macht neue Commits robust und schafft Vertrauen und weckt Vertrauen bei allen Projektbeteiligten.