Robuste Node.js APIs mit Jest und Supertest erstellen
Daniel Hayes
Full-Stack Engineer · Leapcell

Einführung
In der schnelllebigen Welt der Webentwicklung ist der Aufbau robuster und zuverlässiger Node.js-APIs von größter Bedeutung. Wenn Anwendungen komplexer werden, wird die Sicherstellung ihrer Korrektheit und Stabilität immer kritischer. Manuelles Testen ist zwar manchmal notwendig, aber zeitaufwändig, fehleranfällig und auf Dauer nicht aufrechtzuerhalten. Hier kommen automatisierte Tests ins Spiel, die einen systematischen und wiederholbaren Ansatz zur Überprüfung des Anwendungsverhaltens bieten. Insbesondere für Node.js-APIs bietet eine Kombination aus Unit- und Integrationstests ein umfassendes Sicherheitsnetz, das Fehler frühzeitig im Entwicklungszyklus erkennt und ein selbstbewusstes Refactoring und Deployment ermöglicht. Dieser Artikel führt Sie durch den Prozess des Schreibens effektiver Unit- und Integrationstests für Ihre Node.js-API unter Verwendung zweier leistungsstarker und weit verbreiteter Werkzeuge: Jest für sein vielseitiges Test-Framework und Supertest für seine elegante Handhabung von HTTP-Assertions.
Die Grundlage für API-Tests
Bevor wir uns mit der praktischen Implementierung befassen, definieren wir einige Kernkonzepte im Zusammenhang mit dem Testen unserer Node.js-API.
Schlüsselbegriffe
- Unit-Tests (Komponententests): Diese Testebene konzentriert sich auf einzelne Komponenten oder "Units" von Code in Isolation. Für eine API kann eine Unit eine einzelne Funktion, ein Modul oder eine Klasse sein. Ziel ist es, zu überprüfen, ob jede Unit ihr beabsichtigtes Verhalten korrekt und unabhängig von anderen Teilen des Systems ausführt. Dies beinhaltet oft das Mocken externer Abhängigkeiten, um eine echte Isolation zu gewährleisten.
- Integrationstests: Diese testen die Interaktionen zwischen verschiedenen Units oder Komponenten der Anwendung. Für eine API beinhaltet ein Integrationstest typischerweise das Senden tatsächlicher HTTP-Anfragen an einen Endpunkt und die Überprüfung der Antwort. Es stellt sicher, dass verschiedene Teile Ihrer API, wie z. B. Controller, Dienste und Datenbanken, wie erwartet zusammenarbeiten.
- Jest: Ein beliebtes JavaScript-Test-Framework, das von Facebook entwickelt wurde. Jest ist bekannt für seine Geschwindigkeit, Einfachheit und umfassenden Funktionen, einschließlich integrierter Assertionsbibliotheken, Mocking-Fähigkeiten und eines hervorragenden Test-Runners. Es ist eine All-in-One-Lösung für JavaScript-Tests.
- Supertest: Eine auf super-agent basierende Bibliothek zum Testen von Node.js-HTTP-Servern. Supertest macht es unglaublich einfach, HTTP-Anfragen an Ihre API zu senden und die Antworten zu überprüfen. Es abstrahiert die Komplexität des Aufbaus und Abbaus von HTTP-Servern für Tests.
Warum Jest und Supertest verwenden?
Jest bietet eine leistungsstarke und meinungsstarke Umgebung für das Schreiben von Tests und bietet alles von Assertionsmethoden bis hin zu Test-Runnern und Reporting. Seine Snapshot-Tests, leistungsstarken Mocking-Tools und exzellente Leistung machen es zu einer bevorzugten Wahl für JavaScript-Entwickler. Supertest hingegen ergänzt Jest perfekt, indem es den Prozess der Sendung von HTTP-Anfragen an Ihre Node.js-API vereinfacht und Assertions über die Antworten macht. Zusammen bilden sie ein robustes Toolkit zur Gewährleistung der Qualität Ihrer API.
Projekt einrichten
Beginnen wir mit der Einrichtung eines grundlegenden Node.js-Projekts und der Installation der notwendigen Pakete.
Initialisieren Sie zunächst ein neues Node.js-Projekt:
mkdir my-api-tests cd my-api-tests npm init -y
Installieren Sie jetzt Jest und Supertest:
npm install --save-dev jest supertest
Und für ein einfaches API-Beispiel installieren wir auch Express:
npm install express
Aufbau einer einfachen API
Erstellen Sie eine app.js
-Datei für unsere Express-API:
// app.js const express = require('express'); const app = express(); const port = 3000; app.use(express.json()); // JSON-Body-Parsing aktivieren let items = [ { id: 1, name: 'Item A' }, { id: 2, name: 'Item B' } ]; // Alle Artikel abrufen app.get('/items', (req, res) => { res.json(items); }); // Artikel nach ID abrufen app.get('/items/:id', (req, res) => { const id = parseInt(req.params.id); const item = items.find(item => item.id === id); if (item) { res.json(item); } else { res.status(404).send('Item not found'); } }); // Einen neuen Artikel hinzufügen app.post('/items', (req, res) => { const newItem = { id: items.length > 0 ? Math.max(...items.map(item => item.id)) + 1 : 1, name: req.body.name }; if (!newItem.name) { return res.status(400).send('Name is required'); } items.push(newItem); res.status(201).json(newItem); }); // Die App für Tests exportieren module.exports = app; // Optional den Server starten, wenn das Modul direkt ausgeführt wird if (require.main === module) { app.listen(port, () => { console.log(`API läuft auf http://localhost:${port}`); }); }
Konfigurieren Sie Jest in Ihrer package.json
, indem Sie ein test
-Skript hinzufügen:
{ "name": "my-api-tests", "version": "1.0.0", "description": "", "main": "app.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "express": "^4.19.2" }, "devDependencies": { "jest": "^29.7.0", "supertest": "^6.3.4" } }
Unit-Tests mit Jest implementieren
Stellen Sie sich vor, unsere app.js
enthielte eine separate Hilfsfunktion zur Abwicklung der Artikel-Logik. Für Unit-Tests würden wir diese Funktion isoliert testen. Da unsere Beispiel-app.js
die Logik direkt einbettet, erstellen wir eine hypothetische itemService.js
zur Veranschaulichung eines Unit-Tests:
// services/itemService.js let itemsData = [ { id: 1, name: 'Initial Item A' }, { id: 2, name: 'Initial Item B' } ]; const getAllItems = () => { return itemsData; }; const getItemById = (id) => { return itemsData.find(item => item.id === id); }; const addItem = (name) => { if (!name) { throw new Error('Name cannot be empty'); } const newItem = { id: itemsData.length > 0 ? Math.max(...itemsData.map(item => item.id)) + 1 : 1, name: name }; itemsData.push(newItem); return newItem; }; // Für Testzwecke das Zurücksetzen der Daten ermöglichen const resetItems = () => { itemsData = [ { id: 1, name: 'Initial Item A' }, { id: 2, name: 'Initial Item B' } ]; }; module.exports = { getAllItems, getItemById, addItem, resetItems // Für Test-Setup/Teardown exportieren };
Erstellen Sie nun eine __tests__/unit/itemService.test.js
-Datei:
// __tests__/unit/itemService.test.js const itemService = require('../../services/itemService'); describe('itemService', () => { beforeEach(() => { // Daten vor jedem Test zurücksetzen, um die Isolation zu gewährleisten itemService.resetItems(); }); test('sollte alle Artikel zurückgeben', () => { const items = itemService.getAllItems(); expect(items).toHaveLength(2); expect(items[0]).toHaveProperty('name', 'Initial Item A'); }); test('sollte einen Artikel nach ID zurückgeben', () => { const item = itemService.getItemById(1); expect(item).toHaveProperty('name', 'Initial Item A'); }); test('sollte undefined zurückgeben, wenn die Artikel-ID nicht existiert', () => { const item = itemService.getItemById(99); expect(item).toBeUndefined(); }); test('sollte einen neuen Artikel hinzufügen', () => { const newItem = itemService.addItem('New Test Item'); expect(newItem).toHaveProperty('name', 'New Test Item'); expect(newItem.id).toBeGreaterThan(0); expect(itemService.getAllItems()).toHaveLength(3); }); test('sollte einen Fehler auslösen, wenn der Name beim Hinzufügen eines Artikels leer ist', () => { expect(() => itemService.addItem('')).toThrow('Name cannot be empty'); }); test('sollte eine korrekte ID für neue Artikel generieren', () => { itemService.addItem('One'); const item2 = itemService.addItem('Two'); expect(item2.id).toBe(4); // Angenommen, die ursprünglichen Artikel sind 1, 2, dann ist One 3, Two ist 4 }); });
Um diese Tests auszuführen, führen Sie einfach aus:
npm test
Jest findet und führt alle Testdateien aus.
Integrationstests mit Jest und Supertest implementieren
Integrationstests konzentrieren sich auf die Validierung der API-Endpunkte selbst. Hier glänzt Supertest. Erstellen Sie eine __tests__/integration/items.test.js
-Datei:
// __tests__/integration/items.test.js const request = require('supertest'); const app = require('../../app'); // Importieren Sie Ihre Express-App // Um sicherzustellen, dass die Tests isoliert sind, müssen wir oft den Zustand der Daten verwalten. // Für eine echte App würde dies das Verbinden mit einer Testdatenbank und das Leeren/Befüllen von Daten beinhalten. // Für dieses einfache Beispiel verlassen wir uns möglicherweise auf das Zurücksetzen des In-Memory-Arrays // oder starten die App für jede Testsuite/jeden Test neu. // Da unsere app.js ein In-Memory-Array hat, müssen wir sicherstellen, dass es vor jedem Testlauf frisch ist. // Ein üblicher Ansatz ist der Export einer Funktion aus app.js, die die Artikel initialisiert oder zurücksetzt. // Modifizieren wir app.js leicht, um dies zu unterstützen, oder wir erkennen diese Einschränkung fürs Erste an. // Zur Vereinfachung in diesem Beispiel gehen wir davon aus, dass für jeden Testlauf ein frischer Zustand vorliegt, // indem wir die App erneut anfordern (was das In-Memory-'items'-Array neu erstellt). // Für eine Produktionsanwendung verwenden Sie ordnungsgemäße Testdatenbankstrategien. describe('Items API Integration Tests', () => { let server; // Vor allen Tests den Server starten beforeAll((done) => { // Wir müssen die App nur einmal für alle Integrationstests starten, // vorausgesetzt, es gibt keinen gemeinsam genutzten ver änderlichen Zustand, der zwischen einzelnen Tests zurückgesetzt werden muss. // Wenn die App jedoch globalen Zustand modifiziert (wie unser `items`-Array), // müssen Sie möglicherweise `app` neu anfordern oder seinen Zustand vor jedem Test zurücksetzen. // Für dieses Beispiel verlassen wir uns auf die Fähigkeit von Supertest, die Serververbindung zu simulieren. done(); // Supertest kümmert sich intern um den Start/Stopp der Express-App. }); // Test GET /items it('sollte alle Artikel zurückgeben', async () => { const res = await request(app).get('/items'); expect(res.statusCode).toEqual(200); expect(res.body).toBeInstanceOf(Array); expect(res.body.length).toBeGreaterThan(0); expect(res.body[0]).toHaveProperty('name', 'Item A'); }); // Test GET /items/:id it('sollte einen bestimmten Artikel nach ID zurückgeben', async () => { const res = await request(app).get('/items/1'); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('id', 1); expect(res.body).toHaveProperty('name', 'Item A'); }); it('sollte 404 für einen nicht existierenden Artikel zurückgeben', async () => { const res = await request(app).get('/items/999'); expect(res.statusCode).toEqual(404); expect(res.text).toBe('Item not found'); }); // Test POST /items it('sollte einen neuen Artikel hinzufügen', async () => { // Vor diesem Test kann das 'items'-Array in app.js bereits frühere Artikel enthalten // aus vorherigen Tests im selben Testlauf. // Um dies robust zu machen, müssen Sie für Integrationstests mit veränderlichem Zustand in der Regel // eine Möglichkeit haben, die Daten zurückzusetzen oder die Datenbank zu simulieren. // Für dieses einfache Beispiel stellen wir sicher, dass der Name für Testzwecke eindeutig ist. const newItemName = `New Item ${Date.now()}`; const res = await request(app) .post('/items') .send({ name: newItemName }) .set('Accept', 'application/json'); expect(res.statusCode).toEqual(201); expect(res.body).toHaveProperty('id'); expect(res.body).toHaveProperty('name', newItemName); // Optional überprüfen, ob er jetzt über GET zugänglich ist const getRes = await request(app).get(`/items/${res.body.id}`); expect(getRes.statusCode).toEqual(200); expect(getRes.body).toHaveProperty('name', newItemName); }); it('sollte 400 zurückgeben, wenn beim Hinzufügen eines Artikels der Name fehlt', async () => { const res = await request(app) .post('/items') .send({}) // Leerer Body .set('Accept', 'application/json'); expect(res.statusCode).toEqual(400); expect(res.text).toBe('Name is required'); }); });
Führen Sie diese Integrationstests mit npm test
aus.
Praktische Anwendung von Tests
- Continuous Integration: Integrieren Sie Ihre Tests in eine CI/CD-Pipeline (z. B. GitHub Actions, GitLab CI, Jenkins). Dies stellt sicher, dass Tests bei jedem Code-Push automatisch ausgeführt werden und somit Regressionen verhindert werden.
- Test-Driven Development (TDD): Erwägen Sie die Übernahme eines TDD-Ansatzes, bei dem Sie Tests schreiben, bevor Sie den eigentlichen Code schreiben. Dies hilft beim Entwurf besserer APIs, stellt die Testbarkeit sicher und verringert die Wahrscheinlichkeit von Fehlern.
- Mocking von Datenbanken und externen Diensten: Für komplexere Integrationstests müssen Sie oft Datenbankinteraktionen oder Aufrufe externer APIs simulieren. Die leistungsstarken Mocking-Fähigkeiten von Jest können hierfür genutzt werden, sodass Sie die von diesen Abhängigkeiten zurückgegebenen Daten steuern können, ohne tatsächliche externe Dienste zu nutzen. Tools wie
jest-mock-extended
können das Mocken von Schnittstellen vereinfachen. Für Datenbanken sollten Sie In-Memory-Datenbanken wiesqlite
für Tests oder dedizierte Testdatenbanken in Betracht ziehen, die vor jedem Testlauf zurückgesetzt werden.
Fazit
Der Aufbau robuster Node.js-APIs besteht nicht nur aus dem Schreiben von funktionalem Code; es geht auch darum, seine Zuverlässigkeit und Wartbarkeit durch umfassende Tests sicherzustellen. Durch die Nutzung von Jest für sein leistungsstarkes Test-Framework und Supertest für seine elegante HTTP-Assertions-Fähigkeiten können Entwickler effektive Unit- und Integrationstests erstellen, die ein starkes Sicherheitsnetz bieten. Dies garantiert nicht nur die Korrektheit einzelner Komponenten, sondern auch die nahtlose Interaktion der verschiedenen Teile Ihrer API, was letztendlich zu stabileren, skalierbareren und vertrauenswürdigeren Anwendungen führt. Die Übernahme von automatisierten Tests mit Jest und Supertest ist ein entscheidender Schritt, um qualitativ hochwertige Node.js-APIs mit Vertrauen zu liefern.