Node.js testen: die Testpyramide mit Mocha
Warum viele schnelle Unit-Tests, wenige langsame E2E-Tests und die richtigen Werkzeuge – Mocha, node:assert, sinon, supertest – eine Node.js-Suite tragfähig halten.
Wenn ich in einer Schulung frage, wofür Tests eigentlich da sind, kommt fast immer die gleiche Antwort: "Damit der Code funktioniert." Das stimmt, greift aber zu kurz. Tests sind vor allem ein Gedächtnis. Sie halten fest, welches Verhalten ich von einem Stück Code erwarte – und sie schlagen Alarm, wenn jemand (oft ich selbst, ein halbes Jahr später) diese Erwartung bricht. Im Backend, wo eine falsche Berechnung still durch eine API rutscht und erst in der Buchhaltung auffällt, ist dieses Gedächtnis Gold wert.
Die entscheidende Frage ist nie "Testen wir?", sondern "Wie verteilen wir unseren Testaufwand?" Genau dafür gibt es ein Bild, das seit Jahren trägt: die Testpyramide. Ich schaue mir in diesem Artikel an, wie sie funktioniert, warum die meisten Teams sie auf den Kopf stellen, und welche Werkzeuge im Node.js-Alltag dazu passen – Mocha als Runner, node:assert für Assertions, sinon für die Isolation externer Abhängigkeiten und supertest für HTTP-Endpunkte.
Die Pyramide: breite Basis, schmale Spitze
Die Idee ist einfach. Nicht alle Tests sind gleich teuer, und nicht alle geben gleich schnell Auskunft. Ein Unit-Test läuft in Millisekunden, isoliert eine einzelne Funktion und sagt mir bei einem Fehlschlag ziemlich genau, wo das Problem sitzt. Ein End-to-End-Test fährt die ganze Anwendung hoch, klickt sich durch mehrere Systeme und braucht Sekunden bis Minuten – und wenn er rot wird, weiß ich erst mal nur, dass irgendwo auf dem langen Weg etwas kaputt ist.
Daraus folgt eine Verteilung: viele Unit-Tests als breite Basis, weniger Integrationstests in der Mitte, ganz wenige E2E-Tests an der Spitze.
graph TD
E2E["E2E<br/>wenige, langsam, brüchig"]
INT["Integration<br/>weniger, mittelschnell"]
UNIT["Unit<br/>viele, schnell, isoliert"]
E2E --> INT
INT --> UNIT
style UNIT fill:#2d6a4f,color:#fff
style INT fill:#40916c,color:#fff
style E2E fill:#74c69d,color:#000
Die Basis ist breit, weil Unit-Tests billig sind – schnell geschrieben, schnell ausgeführt, stabil. Die Spitze ist schmal, weil E2E-Tests teuer im Unterhalt sind. Sie brechen bei jeder Umgebungsänderung, sie werden flaky, sobald Timing ins Spiel kommt, und sie kosten in jeder CI-Runde Zeit. Ein paar davon braucht man – sie sind die einzigen, die belegen, dass die Teile auch zusammen funktionieren. Aber sie sind das Salz, nicht die Suppe.
Welches Werkzeug wofür
Bevor ich zu Code komme, eine kurze Einordnung, denn in Node.js verteilt sich die Arbeit auf ein kleines Ökosystem statt auf ein einzelnes Framework:
- Mocha ist der Runner. Er gibt die Struktur (
describe/it), führt die Tests aus, verwaltet Hooks und meldet Ergebnisse. Mocha selbst bringt bewusst keine Assertions mit. node:assertliefert genau diese Assertions – eingebaut, ohne zusätzliche Abhängigkeit. Wer eine ausdrucksstärkere BDD-Syntax will, greift alternativ zu Chai (in der CommonJS-Welt 2024 die v4-Linie).- sinon stellt Test-Doubles bereit: Spies beobachten Aufrufe, Stubs ersetzen Verhalten. Damit isoliere ich externe Abhängigkeiten wie Datenbank, HTTP oder Zeit.
- supertest testet HTTP-Endpunkte auf App-Ebene, ohne einen echten Netzwerk-Port oder Browser zu brauchen.
Diese Trennung irritiert Umsteiger aus anderen Sprachen anfangs. Sie ist aber eine Stärke: Man kombiniert austauschbare Bausteine, statt sich an ein Monolith-Framework zu binden.
Unit-Tests: das Fundament
Fangen wir unten an. Ein Unit-Test nimmt eine Funktion, gibt ihr Eingaben und prüft die Ausgabe. Nichts sonst – keine Datenbank, kein Netzwerk, kein Dateisystem.
Mocha strukturiert das über describe (ein Block, der zusammengehörige Tests gruppiert) und it (ein einzelner Testfall). Für die Assertion nehme ich node:assert im Strict-Modus. Der node:-Präfix macht klar, dass es ein Built-in ist, und /strict sorgt dafür, dass keine stillschweigende Typ-Umwandlung passiert.
const assert = require('node:assert/strict');
const { sum } = require('../src/math');
describe('sum()', () => {
it('adds two positive numbers', () => {
assert.strictEqual(sum(2, 3), 5);
});
it('handles negative operands', () => {
assert.strictEqual(sum(-4, 1), -3);
});
});
Zwei Details lohnen einen zweiten Blick. Erstens die Argument-Reihenfolge: assert.strictEqual(actual, expected) – immer erst der tatsächliche Wert, dann der erwartete. Wer das vertauscht, bekommt bei einem Fehlschlag eine Diff-Ausgabe, die genau falsch herum liest. Zweitens strictEqual statt equal: Der Strict-Modus vergleicht mit Object.is/=== und coerct nichts. 1 ist nicht '1'. Das klingt pedantisch, verhindert aber genau die Klasse von Bugs, die man mit Tests eigentlich fangen will.
Für Objekte und Arrays nehme ich deepStrictEqual, das tief und typgenau vergleicht:
const assert = require('node:assert/strict');
it('builds the expected payload', () => {
const result = buildUser({ id: 1, name: 'Ada' });
assert.deepStrictEqual(result, {
id: 1,
name: 'Ada',
role: 'guest',
});
});
Verhalten testen, nicht Implementierung
Hier kommt der Punkt, an dem in Schulungen die meisten Diskussionen entstehen. Ein Teilnehmer fragte einmal, ob er nicht prüfen solle, dass seine Funktion intern Array.map aufruft. Meine Antwort: auf keinen Fall. Interessieren sollte mich das Ergebnis – die transformierte Liste –, nicht der Weg dorthin.
Der Unterschied ist praktisch und nicht akademisch. Ein Test, der auf beobachtbares Verhalten zielt, überlebt ein Refactoring: Solange die Funktion bei gleicher Eingabe die gleiche Ausgabe liefert, bleibt er grün, egal ob innen map, reduce oder eine for-Schleife steht. Ein Test, der Implementierungsdetails festnagelt, bricht dagegen bei jeder Umbauaktion – obwohl fachlich nichts kaputt ist. Solche Tests erziehen ein Team dazu, Refactorings zu scheuen. Und ein Team, das sich nicht mehr traut umzubauen, sammelt technische Schuld schneller an, als jeder Test sie je einfangen könnte.
Externe Abhängigkeiten isolieren mit sinon
Sobald eine Funktion die reine Berechnung verlässt und mit einer Datenbank, einem HTTP-Client oder der Uhr redet, wird es interessant. Diese Abhängigkeiten will ich im Unit-Test nicht echt ansprechen – sie sind langsam, unzuverlässig und schwer in einen definierten Zustand zu bringen. Stattdessen ersetze ich sie durch Test-Doubles.
sinon unterscheidet dabei vor allem zwischen Spy und Stub. Ein Spy lässt die echte Funktion laufen und beobachtet nur, wie oft und womit sie aufgerufen wurde. Ein Stub ersetzt das Verhalten komplett – ich sage ihm, was er zurückgeben oder werfen soll.
Nehmen wir einen Service, der einen Nutzer über ein Repository lädt. Ich will testen, dass er sich bei einem DB-Fehler korrekt verhält, ohne je eine echte Datenbank anzufassen:
const assert = require('node:assert/strict');
const sinon = require('sinon');
const userRepo = require('../src/user-repo');
const service = require('../src/user-service');
describe('userService.getUser()', () => {
afterEach(() => {
sinon.restore();
});
it('propagates a repository failure', async () => {
const stub = sinon
.stub(userRepo, 'findById')
.rejects(new Error('db down'));
await assert.rejects(() => service.getUser(1), /db down/);
assert.ok(stub.calledOnceWith(1));
});
it('returns the user on success', async () => {
sinon.stub(userRepo, 'findById').resolves({ id: 1, name: 'Ada' });
const user = await service.getUser(1);
assert.strictEqual(user.name, 'Ada');
});
});
Der wichtigste Baustein hier ist nicht der Stub selbst, sondern das afterEach(() => sinon.restore()). Seit sinon v5 ist das Default-Objekt sinon selbst eine Sandbox; restore() setzt alle ersetzten Methoden zurück. Vergisst man das, leben die Stubs über den Testfall hinaus weiter. Der nächste Test bekommt dann unbemerkt einen manipulierten userRepo untergeschoben – und je nach Ausführungsreihenfolge geht er mal durch, mal nicht. Genau das meint man mit "flaky": ein Test, dessen Ergebnis von der Reihenfolge oder der Parallelität abhängt, nicht vom Code. Wer eine eigene Sandbox bevorzugt, nutzt const sandbox = sinon.createSandbox() und ruft sandbox.restore() auf – das Prinzip bleibt.
Ein Wort zur Vorsicht: Test-Doubles sind ein scharfes Messer. Wer alles wegmockt, testet am Ende nur noch, dass die Mocks so reagieren, wie er sie konfiguriert hat. Solche Tests spiegeln die eigene Erwartung zurück und brechen beim ersten echten Refactoring. Stubbe die Grenze zum externen System – nicht die eigene Logik gleich mit.
Integrationstests: HTTP-Endpunkte mit supertest
Eine Ebene höher liegen Integrationstests. Hier will ich nicht mehr eine einzelne Funktion prüfen, sondern das Zusammenspiel – etwa dass eine Express-Route den richtigen Statuscode und Body liefert. Dafür gibt es supertest.
supertest nimmt eine Express-App oder einen http.Server, bindet sie an einen ephemeren Port und schickt echte HTTP-Requests dagegen. Kein Browser, kein manuell gestarteter Server, keine feste Portnummer. Die API ist fluent und liest sich fast wie eine Beschreibung:
const assert = require('node:assert/strict');
const request = require('supertest');
const app = require('../src/app');
describe('GET /users', () => {
it('returns 200 and a JSON list', async () => {
const res = await request(app)
.get('/users')
.expect(200)
.expect('Content-Type', /json/);
assert.ok(Array.isArray(res.body));
assert.strictEqual(res.body.length, 2);
});
it('returns 404 for an unknown user', async () => {
await request(app).get('/users/999').expect(404);
});
});
Das await vorne ist entscheidend. supertest gibt ein Promise zurück; ohne await (oder ein return des Promises) endet der Test, bevor die Assertions gelaufen sind – und Mocha meldet ihn fälschlich als grün. Diese Art von false positive ist besonders tückisch, weil die Suite wochenlang beruhigend grün bleibt, obwohl sie nichts prüft.
Damit ist auch klar, warum ich Async konsequent auf einem Weg abwickle. Mocha erlaubt zwei Stile: entweder async/Promise-returning oder den klassischen done-Callback. Mischen darf man sie nicht. Entweder
it('waits for the promise', async () => {
const value = await loadValue();
assert.strictEqual(value, 42);
});
oder, wenn man mit einem Callback-basierten API arbeitet:
it('waits for the callback', function (done) {
loadValue((err, value) => {
if (err) return done(err);
assert.strictEqual(value, 42);
done();
});
});
Beides gleichzeitig – ein done-Parameter und async – lehnt Mocha mit einem klaren Fehler ab ("Resolution method is overspecified"). Ein Detail am Rande: Mochas Default-Timeout pro Test liegt bei 2000 ms. Wenn ein asynchroner Test nie sein done aufruft oder ein Promise nie auflöst, läuft er in diesen Timeout und wird rot – oft der erste Hinweis auf ein vergessenes await oder done.
Der häufigste Fehler: die Pyramide auf dem Kopf
Jetzt zum Fallstrick, der mir in der Praxis am häufigsten begegnet. Teams starten mit besten Absichten, schreiben aber vor allem E2E-Tests, weil die sich so schön "echt" anfühlen. Nach einem Jahr sieht die Verteilung so aus:
graph TD
UNIT["Unit<br/>kaum vorhanden"]
INT["Integration<br/>ein paar"]
E2E["E2E<br/>viele, langsam, flaky"]
UNIT --> INT
INT --> E2E
style E2E fill:#9d0208,color:#fff
style INT fill:#dc2f02,color:#fff
style UNIT fill:#f48c06,color:#000
Man nennt das die "Ice-Cream-Cone" – die umgedrehte Pyramide mit der breiten, teuren Spitze oben. Die Symptome sind immer gleich: Die Suite läuft zehn Minuten statt zehn Sekunden. Ein einzelner roter Test sagt nicht, wo der Bug sitzt, weil er ein Dutzend Komponenten gleichzeitig durchläuft. Und weil E2E-Tests von Timing und Umgebung abhängen, werden sie flaky – irgendwann lässt das Team die Pipeline einfach so lange neu laufen, bis sie grün ist. Das ist der Moment, in dem Tests aufhören, ein Gedächtnis zu sein, und zu einem Ritual werden, das niemand mehr ernst nimmt.
Der Weg heraus ist unspektakulär: Für jeden Bug, der durch einen langsamen E2E-Test auffällt, schreibt man den eigentlichen Regressionstest eine Ebene tiefer – als Unit- oder Integrationstest, der dieselbe Ursache in Millisekunden fängt. Die Pyramide richtet sich nicht durch einen großen Umbau wieder auf, sondern durch die Gewohnheit, Fehler auf der billigsten Ebene festzuhalten, auf der sie reproduzierbar sind.
Diese Haltung passt zu einem Gedanken, den ich schon beim Thema Fehler als Architektur beschrieben habe: Ein Fehler ist kein Ärgernis, das man wegdrückt, sondern eine Information über das System. Ein Test ist die Konserve dieser Information. Und wer verstehen will, warum asynchrone Tests im Backend überhaupt so eine große Rolle spielen, findet die Grundlagen dazu im Node.js-Training rund um Event Loop und Architektur.
Ein Wort zu Hooks
Damit Tests unabhängig bleiben, braucht jeder einen sauberen Ausgangszustand. Mocha bietet dafür vier Hooks: before und after laufen einmal pro describe-Block, beforeEach und afterEach vor beziehungsweise nach jedem einzelnen Test. Die Reihenfolge ist verlässlich: erst alle before, dann pro Test beforeEach → Test → afterEach, am Ende after.
describe('orderService', () => {
let db;
before(async () => {
db = await connectTestDb();
});
beforeEach(async () => {
await db.reset();
});
afterEach(() => {
sinon.restore();
});
after(async () => {
await db.close();
});
it('creates an order', async () => {
// ...
});
});
Die Faustregel: teure Einmal-Arbeit (Verbindung aufbauen) gehört in before/after, Zustand-Zurücksetzen in beforeEach/afterEach. Ein beforeEach innerhalb eines verschachtelten describe gilt nur für dessen Tests – so hält man Setup lokal und vermeidet, dass ein globaler Hook die halbe Suite unnötig verlangsamt.
Fazit
Testen im Backend ist keine Fleißarbeit, die man am Ende noch draufsattelt, sondern die Art, wie ein Team seine Architektur-Erwartungen festhält und über Refactorings hinweg absichert. Die Testpyramide gibt dabei die Richtung vor: viele schnelle Unit-Tests als Basis, eine Schicht Integrationstests darüber, ein paar E2E-Tests an der Spitze. In Node.js setzt man das mit einem kleinen, austauschbaren Werkzeugkasten um – Mocha strukturiert, node:assert prüft, sinon isoliert externe Abhängigkeiten, supertest testet HTTP-Endpunkte.
Wenn Sie nur eine Sache mitnehmen: Testen Sie beobachtbares Verhalten, nicht die Implementierung, und halten Sie Fehler auf der billigsten Ebene fest, auf der sie reproduzierbar sind. Dann bleibt die Suite schnell, aussagekräftig und – das Wichtigste – vertrauenswürdig. Eine Testsuite, der niemand mehr glaubt, ist teurer als gar keine.
Weiterführende Quellen
- Mocha – Dokumentation und Hooks: mochajs.org und mochajs.org/features/hooks
- node – Strict Mode: nodejs.org/api/assert.html
- sinon – Test-Doubles und Stubs: sinonjs.org und sinonjs.org/releases/latest/stubs
- supertest: npmjs.com/package/supertest
- Chai (Alternative zu assert): chaijs.com
- Begleitendes Repository mit Testing-Kapitel: github.com/mikebild/introduction-nodejs
Kommentare
Kommentar schreiben