Gewährleistung der API-Stabilität durchgängig mit Jest und Supertest
Lukas Schneider
DevOps Engineer · Leapcell

Einleitung: Die Notwendigkeit der API-Zuverlässigkeit
In der heutigen vernetzten Welt dienen Node.js REST-APIs als Rückgrat für unzählige Anwendungen, erleichtern den Datenaustausch und treiben Benutzererlebnisse an. Da diese APIs in Komplexität und Umfang wachsen, wird die Gewährleistung ihrer Stabilität, Korrektheit und Einhaltung von Spezifikationen von größter Bedeutung. Manuelle Tests sind zwar manchmal notwendig, aber oft ineffizient, fehleranfällig und in schnelllebigen Entwicklungszyklen nicht nachhaltig. Hier kommen automatisierte End-to-End-Tests (E2E) ins Spiel und bieten ein kritisches Sicherheitsnetz, das den gesamten Anwendungsfluss vom Request bis zur Response überprüft, genau so, wie ein Benutzer oder eine Client-Anwendung damit interagieren würde. Durch die Simulation realer Szenarien fangen E2E-Tests Regressionen frühzeitig ab, stärken das Vertrauen der Entwickler und tragen letztendlich zu einer robusteren und zuverlässigeren API bei. Dieser Artikel befasst sich damit, wie End-to-End-Tests für Node.js REST-APIs mit der leistungsstarken Kombination aus Jest für das Testen und Supertest für HTTP-Assertions effektiv implementiert werden können.
Verständnis der Kernwerkzeuge für robuste API-Tests
Bevor wir uns mit der Implementierung befassen, wollen wir die beteiligten grundlegenden Werkzeuge klar definieren.
Jest: Jest ist ein hervorragendes JavaScript-Testframework, das von Facebook entwickelt wurde und sich aufgrund seiner Einfachheit, Geschwindigkeit und umfassenden Funktionen weit verbreitet hat. Es ist eine All-in-One-Lösung, die einen Test-Runner, eine Assertion-Bibliothek und Mocking-Funktionen enthält. Für E2E-Tests bietet Jest die Struktur zum Definieren, Ausführen und Berichten über unsere Test-Suiten und bietet eine vertraute und leistungsstarke Umgebung.
Supertest: Supertest ist eine High-Level-Abstraktion, die auf Superagent (einer HTTP-Request-Bibliothek) aufbaut und speziell für das Testen von HTTP-Servern entwickelt wurde. Es ermöglicht Ihnen, HTTP-Anfragen an Ihre API-Endpunkte zu stellen und die Antworten auf flüssige und lesbare Weise zu beweisen. Supertest kümmert sich elegant um das Starten und Stoppen Ihres Servers für Tests und macht den E2E-Testprozess nahtlos.
End-to-End (E2E) Testing: Im Gegensatz zu Unit-Tests (die sich auf isolierte Komponenten konzentrieren) oder Integrationstests (die Interaktionen zwischen mehreren Einheiten überprüfen), validieren E2E-Tests den vollständigen Ablauf einer Anwendung aus Benutzersicht. Für eine API bedeutet dies, tatsächliche HTTP-Anfragen an den laufenden Server zu stellen, mit Datenbanken zu interagieren und die erwarteten Antworten zu überprüfen, einschließlich Statuscodes, Datenformate und Nebeneffekte.
Einrichten Ihrer Testumgebung
Nehmen wir an, wir haben eine grundlegende Node.js Express REST API. Hier ist ein vereinfachtes Beispiel einer app.js
-Datei für unsere API:
// app.js const express = require('express'); const bodyParser = require('body-parser'); const app = express(); const port = 3000; app.use(bodyParser.json()); let items = [ { id: '1', name: 'Item One', description: 'This is item one.' }, { id: '2', name: 'Item Two', description: 'This is item two.' }, ]; // Alle Elemente abrufen app.get('/items', (req, res) => { res.status(200).json(items); }); // Element nach ID abrufen app.get('/items/:id', (req, res) => { const item = items.find(i => i.id === req.params.id); if (item) { res.status(200).json(item); } else { res.status(404).send('Item not found'); } }); // Neues Element erstellen app.post('/items', (req, res) => { const { name, description } = req.body; if (!name || !description) { return res.status(400).send('Name and description are required'); } const newItem = { id: String(items.length + 1), name, description }; items.push(newItem); res.status(201).json(newItem); }); // Element aktualisieren app.put('/items/:id', (req, res) => { const { name, description } = req.body; const itemIndex = items.findIndex(i => i.id === req.params.id); if (itemIndex > -1) { items[itemIndex] = { ...items[itemIndex], name: name || items[itemIndex].name, description: description || items[itemIndex].description }; res.status(200).json(items[itemIndex]); } else { res.status(404).send('Item not found'); } }); // Element löschen app.delete('/items/:id', (req, res) => { const initialLength = items.length; items = items.filter(i => i.id !== req.params.id); if (items.length < initialLength) { res.status(204).send(); // No Content } else { res.status(404).send('Item not found'); } }); if (process.env.NODE_ENV !== 'test') { app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); } module.exports = app; // Export the app for testing
Installieren Sie zuerst die notwendigen Pakete:
npm install jest supertest express body-parser
Oder mit yarn:
yarn add jest supertest express body-parser
Fügen Sie ein Test-Skript zu Ihrer package.json
hinzu:
"scripts": { "test": "jest" }
Schreiben von End-to-End-Tests mit Jest und Supertest
Erstellen wir nun eine Testdatei, z. B. __tests__/items.e2e.test.js
.
// __tests__/items.e2e.test.js const request = require('supertest'); const app = require('../app'); // Importieren Sie Ihre Express-App let server; // Vor allen Tests den Server starten beforeAll(() => { server = app.listen(0); // Auf einem zufälligen, ungenutzten Port lauschen }); // Nach allen Tests den Server schließen afterAll((done) => { server.close(done); }); describe('Items API E2E Tests', () => { let initialItems; // Vor jedem Test die Daten zurücksetzen (wichtig für die Testisolierung) beforeEach(() => { // Eine einfache Methode, Daten zurückzusetzen, zur Demonstration. // In einer realen Anwendung würden Sie wahrscheinlich eine Testdatenbank befüllen. initialItems = [ { id: '1', name: 'Initial Item One', description: 'This is the first initial item.' }, { id: '2', name: 'Initial Item Two', description: 'This is the second initial item.' }, ]; // Dies ist ein vereinfachtes Zurücksetzen zur Demonstration. In einer realen App // würden Sie normalerweise Ihre Datenbank vor jedem Test zurücksetzen. // Für dieses Beispiel manipulieren wir direkt das `items`-Array aus `app.js` //, was nicht ideal ist, aber zur Veranschaulichung der E2E-Konzepte funktioniert. // In einer ordnungsgemäßen Einrichtung hätten Sie eine Testdatenbank oder eine Mocking-Strategie. // Um die Daten in einem Modul mit einer 'let'-Variable wie `items` in `app.js` ordnungsgemäß zurückzusetzen, // bräuchten Sie eine Möglichkeit, eine Reset-Funktion bereitzustellen oder das Modul neu zu importieren, // wenn 'items' als benannter Export exportiert würde. Für dieses Beispiel verlassen wir uns // einfach darauf, dass die Tests einigermaßen isoliert sind, erkennen aber diese Einschränkung an. // Ein besserer Ansatz wäre: // const { resetItems, getItems } = require('../app'); // resetItems(initialItems); // Da `app.js` derzeit steht, ist die direkte Manipulation für das Zurücksetzen schwierig. // Für dieses Beispiel gehen wir davon aus, dass die Tests einigermaßen isoliert sind oder wir sie sequenziell ausführen. // Eine robustere Lösung beinhaltet eine Testdatenbank oder die Bereitstellung einer Reset-Funktion aus app.js. }); it('sollte alle Elemente abrufen', async () => { const res = await request(app).get('/items'); expect(res.statusCode).toEqual(200); expect(Array.isArray(res.body)).toBeTruthy(); expect(res.body.length).toBeGreaterThanOrEqual(1); // Unter Annahme einiger anfänglicher Daten expect(res.body[0]).toHaveProperty('id'); expect(res.body[0]).toHaveProperty('name'); }); it('sollte ein Element nach ID abrufen', async () => { const itemId = '1'; const res = await request(app).get(`/items/${itemId}`); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('id', itemId); expect(res.body).toHaveProperty('name', 'Item One'); // Unter Annahme des aktuellen Zustands der Elemente }); it('sollte 404 für ein nicht vorhandenes Element zurückgeben', async () => { const res = await request(app).get('/items/999'); expect(res.statusCode).toEqual(404); expect(res.text).toBe('Item not found'); }); it('sollte ein neues Element erstellen', async () => { const newItem = { name: 'New Item', description: 'This is a brand new item.' }; const res = await request(app) .post('/items') .send(newItem); expect(res.statusCode).toEqual(201); expect(res.body).toHaveProperty('id'); expect(res.body).toHaveProperty('name', newItem.name); expect(res.body).toHaveProperty('description', newItem.description); // Durch Abrufen verifizieren, dass es existiert const getRes = await request(app).get(`/items/${res.body.id}`); expect(getRes.statusCode).toEqual(200); expect(getRes.body).toEqual(expect.objectContaining(newItem)); }); it('sollte 400 zurückgeben, wenn Name oder Beschreibung beim Erstellen eines Elements fehlen', async () => { const invalidItem = { description: 'Missing name' }; const res = await request(app) .post('/items') .send(invalidItem); expect(res.statusCode).toEqual(400); expect(res.text).toBe('Name and description are required'); }); it('sollte ein vorhandenes Element aktualisieren', async () => { const itemIdToUpdate = '1'; const updatedData = { name: 'Updated Item One', description: 'Description has changed.' }; const res = await request(app) .put(`/items/${itemIdToUpdate}`) .send(updatedData); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('id', itemIdToUpdate); expect(res.body).toHaveProperty('name', updatedData.name); expect(res.body).toHaveProperty('description', updatedData.description); // Die Aktualisierung durch Abrufen verifizieren const getRes = await request(app).get(`/items/${itemIdToUpdate}`); expect(getRes.statusCode).toEqual(200); expect(getRes.body).toEqual(expect.objectContaining(updatedData)); }); it('sollte ein Element löschen', async () => { const itemIdToDelete = '2'; const res = await request(app).delete(`/items/${itemIdToDelete}`); expect(res.statusCode).toEqual(204); // Durch den Versuch, es abzurufen, die Löschung verifizieren const getRes = await request(app).get(`/items/${itemIdToDelete}`); expect(getRes.statusCode).toEqual(404); }); it('sollte 404 zurückgeben, wenn versucht wird, ein nicht vorhandenes Element zu löschen', async () => { const res = await request(app).delete('/items/999'); expect(res.statusCode).toEqual(404); }); });
Erklärung des Setups und der Tests
require('supertest')
undrequire('../app')
: Wir importierensupertest
und unsereExpress
-Anwendung. Supertest verwendet dieseapp
-Instanz, um HTTP-Anfragen zu stellen, anstatt einen separaten Serverprozess ausführen zu müssen.beforeAll
undafterAll
: Diese Jest-Lifecycle-Hooks sind entscheidend.beforeAll
startet unsere Expressapp
, bevor Tests ausgeführt werden. Wir verwendenapp.listen(0)
, damit der Server auf einem zufälligen verfügbaren Port lauscht, um Konflikte zu vermeiden.afterAll
schließt den Server, sobald alle Tests abgeschlossen sind. Dies gewährleistet ein sauberes Herunterfahren und verhindert Ressourcenlecks.
describe('Items API E2E Tests', ...)
: Dies ist eine Test-Suite, die zusammengehörige Tests gruppiert.beforeEach
(Datenzurücksetzung): Dieser Hook wird vor jedem Test ausgeführt. Bei E2E-Tests, insbesondere bei der Arbeit mit persistenten Speichern wie Datenbanken, ist es entscheidend, den Zustand vor jedem Test zurückzusetzen. Dies garantiert, dass jeder Test in einer vorhersehbaren und isolierten Umgebung ausgeführt wird und verhindert, dass frühere Tests spätere beeinflussen. Für unser einfaches In-Memory-Array-Beispiel würde eine ordnungsgemäßebeforeEach
-Methode das direkte Manipulieren desitems
-Arrays beinhalten oder, realistischer, das Leeren und erneute Befüllen einer Testdatenbank. Das aktuelle Beispiel verdeutlicht die Notwendigkeit, räumt aber die Vereinfachung ein.it('sollte alle Elemente abrufen', async () => { ... });
: Jederit
-Block definiert einen einzelnen Testfall.await request(app).get('/items')
: Hier glänzt Supertest. Wir rufenrequest(app)
auf, um unsere Express-Anwendung anzusprechen, und verketten dann HTTP-Methoden wie.get()
,.post()
,.put()
,.delete()
..send(data)
: Wird fürPOST
- undPUT
-Anfragen verwendet, um den Request Body zu senden..expect(status)
oderexpect(res.statusCode).toEqual(status)
: Supertest ermöglicht das Verketten von.expect()
-Assertions, oder Sie können Jestsexpect
direkt auf demres
-Objekt verwenden, das vonawait request(...)
zurückgegeben wird.expect(Array.isArray(res.body)).toBeTruthy()
: Jest-Assertions werden verwendet, um die Struktur und den Inhalt des Response Body zu überprüfen.- Asynchronität: HTTP-Anfragen sind asynchron. Wir verwenden
async/await
, um sie elegant zu behandeln, wodurch unser Testcode synchron und leicht lesbar aussieht.
Ausführen der Tests
Führen Sie einfach npm test
oder yarn test
in Ihrem Terminal aus. Jest erkennt und führt Ihre E2E-Tests aus und liefert einen klaren Bericht über Erfolge und Misserfolge.
Best Practices und weitere Überlegungen
- Testdatenbank: Verwenden Sie für reale Anwendungen immer eine dedizierte Testdatenbank (z. B. SQLite im Speicher, eine separate PostgreSQL/MongoDB-Instanz). Das Zurücksetzen von Daten für jeden Test in dieser Datenbank ist entscheidend für die Isolierung. Tools wie
jest-mongodb
oder benutzerdefinierte Skripte können beim Befüllen und Leeren helfen. - Umgebungsvariablen: Verwenden Sie Umgebungsvariablen (z. B.
process.env.NODE_ENV = 'test'
) , um während des Tests bedingt unterschiedliche Konfigurationen zu laden oder externe Dienste zu simulieren. - Authentifizierung: Wenn Ihre API über eine Authentifizierung verfügt, müssen Ihre E2E-Tests Anmeldeverfahren simulieren, um Token (JWTs, Sitzungs-IDs) zu erhalten und diese in nachfolgende Anfragen einzubinden (z. B.
request(app).get('/secure').set('Authorization', 'Bearer <token>')
). - Mocken externer Dienste: Während E2E-Tests idealerweise alle Schichten durchlaufen, sollten manchmal externe Dienste (Drittanbieter-APIs, Zahlungs-Gateways) gemockt werden, um sicherzustellen, dass die Tests schnell, zuverlässig und deterministisch sind. Jests leistungsstarke Mocking-Funktionen oder Bibliotheken wie
nock
können hierfür verwendet werden. - Testorganisation: Organisieren Sie Ihre Testdateien logisch (z. B.
__tests__/users.e2e.test.js
,__tests__/products.e2e.test.js
), um Ihren API-Routen oder Funktionen zu entsprechen. - Bereinigung: Stellen Sie immer sicher, dass Ihre
afterAll
- oderafterEach
-Hooks alle Ressourcen (Datenbankverbindungen, geöffnete Dateien, laufende Server) ordnungsgemäß bereinigen. - Protokollierung: Halten Sie die API-Protokollierung während der Tests minimal, um übermäßige Konsolenausgaben zu vermeiden.
Fazit: Eine Grundlage für API-Vertrauen
Das Beherrschen von End-to-End-Tests mit Jest und Supertest bietet ein leistungsstarkes Sicherheitsnetz für Ihre Node.js REST-APIs. Durch die Überprüfung des vollständigen Benutzerflows und der Interaktion mit Ihrer API gewinnen Sie immenses Vertrauen, dass Ihre Anwendung wie erwartet funktioniert, auch wenn sie sich weiterentwickelt. Dies führt zu weniger Bugs in der Produktion, schnelleren Entwicklungszyklen und letztendlich zu einer zuverlässigeren und wartbareren Codebasis. Die Übernahme dieser Testmethodik ist eine Investition, die sich in Anwendungsstabilität und Entwicklertransquillität auszahlt.