# Node.js testen: die Testpyramide mit Mocha

URL: https://www.mikebild.dev/de/blog/nodejs-testing-mit-mocha/

Ein Test beweist nicht, dass Code funktioniert. Er hält fest, welches Verhalten jemand einmal von diesem Code erwartet hat – und schlägt Alarm, wenn diese Erwartung bricht. Eine Testsuite ist damit weniger ein Beweis als ein Gedächtnis, und derjenige, der die Erwartung bricht, ist erstaunlich oft man selbst, ein halbes Jahr später. 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 deshalb nie "Testen wir?", sondern "Wie verteilen wir unseren Testaufwand?" Genau dafür gibt es ein Bild, das seit Jahren trägt: die Testpyramide. Sie ist eine Heuristik, kein Mengenverhältnis, das für jedes System gleich aussieht. Ich schaue mir in diesem Artikel an, wie sie funktioniert, warum Suiten mit der Zeit kippen, 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. Node.js bringt inzwischen mit `node:test` selbst einen stabilen Test-Runner mit; Mocha bleibt hier die bewusst gewählte, nicht die einzig zeitgemäße Variante.

## 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 häufig eine Verteilung mit vielen schnellen Tests an der Basis, gezielten Integrationstests in der Mitte und wenigen durchgängigen Szenarien an der Spitze. Wie breit die Schichten ausfallen, hängt von den Risiken und den Systemgrenzen ab.

```mermaid
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:assert` liefert 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.

```js
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 wandelt nichts stillschweigend um. `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:

```js
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 Code-Reviews die meisten Diskussionen entstehen. Ein Kollege fragte einmal, ob er nicht prüfen solle, dass seine Funktion intern `Array.map` aufruft. Besser nicht: Interessieren sollte den Test 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 Schulden 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:

```js
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.

Aber 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. Ersetzen sollte man 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:

```js
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

```js
it('waits for the promise', async () => {
  const value = await loadValue();
  assert.strictEqual(value, 42);
});
```

oder, wenn man mit einer Callback-basierten API arbeitet:

```js
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 häufigsten Fallstrick. Ein Team startet mit den besten Absichten, schreibt aber vor allem E2E-Tests, weil die sich so schön "echt" anfühlen. Nach einem Jahr sieht die Verteilung so aus:

```mermaid
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](https://www.mikebild.dev/de/blog/nodejs-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](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) 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`.

```js
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 () => {
    // ...
  });
});
```

Teure Einmal-Arbeit wie der Verbindungsaufbau gehört in `before`/`after`, das Zurücksetzen von Zustand 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 eine Richtung vor, aber keine feste Stückzahl: schnelle Tests tragen die Basis, Integrationstests sichern wichtige Grenzen ab, wenige durchgängige Szenarien prüfen das Zusammenspiel. In diesem Beispiel ist der Werkzeugkasten klein und austauschbar – Mocha strukturiert, `node:assert` prüft, sinon isoliert externe Abhängigkeiten, supertest testet HTTP-Endpunkte.

Was sich für mich über die Jahre als Kern herausgestellt hat: beobachtbares Verhalten testen, nicht die Implementierung, und Fehler auf der billigsten Ebene festhalten, 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](https://mochajs.org/) und [mochajs.org/features/hooks](https://mochajs.org/features/hooks/)
- node:assert – Strict Mode: [nodejs.org/api/assert.html](https://nodejs.org/api/assert.html)
- node:test – eingebauter Test-Runner: [nodejs.org/api/test.html](https://nodejs.org/api/test.html)
- sinon – Test-Doubles und Stubs: [sinonjs.org](https://sinonjs.org/) und [sinonjs.org/concepts/stubs](https://sinonjs.org/concepts/stubs/)
- supertest: [npmjs.com/package/supertest](https://www.npmjs.com/package/supertest)
- Chai (Alternative zu assert): [chaijs.com](https://www.chaijs.com/)
- Begleitendes Repository mit Testing-Kapitel: [github.com/mikebild/introduction-nodejs](https://github.com/mikebild/introduction-nodejs)
