mitmario.dev

Fehlerfälle testen

Node.js Sandbox 3 Min Lesezeit 3 BeispieleLektion 3 von 6

Der interessanteste Teil einer Funktion ist oft der, in dem sie sich weigert. In Lektion 16.1 hast du gelernt, welche Fehler behandelt gehören; jetzt schreibst du die Tests, die belegen, dass es passiert.

assert.throws braucht eine zweite Angabe

assert.throws(fn) prüft, dass fn wirft. Mehr nicht. Und „mehr nicht” ist hier das Problem.

Was assert.throws ohne zweite Angabe durchgehen lässt
import test from "node:test";
import assert from "node:assert/strict";

const PREISE = { 1: 4.99, 2: 12.5 };

function ladePreis(id) {
  if (!Number.isInteger(id)) {
    throw new TypeError("id muss eine ganze Zahl sein");
  }
  // Tippfehler: die Tabelle heisst PREISE. Das wirft einen ReferenceError.
  return preise[id];
}

test("ohne zweite Angabe zaehlt sogar der Tippfehler als Erfolg", () => {
  assert.throws(() => ladePreis(1));
});

test("ohne zweite Angabe ist auch der echte Fall gruen", () => {
  assert.throws(() => ladePreis("x"));
});

test("mit TypeError faellt der Tippfehler auf", () => {
  assert.throws(() => ladePreis(1), TypeError);
});

test("mit TypeError bleibt der echte Fall gruen", () => {
  assert.throws(() => ladePreis("x"), TypeError);
});

In ladePreis steckt ein Tippfehler: Die Tabelle heißt PREISE, im Code steht preise. Jeder Aufruf mit gültiger Kennung wirft deshalb einen ReferenceError.

Der erste Test heißt sinngemäß „mit einer gültigen Kennung geht etwas schief” und ist grün. Das ist absurd, und der Test hat es nicht gemerkt, weil er nur nach „irgendein Fehler” gefragt hat.

Erst mit TypeError als zweiter Angabe wird der Unterschied sichtbar: Der ReferenceError ist keiner, der Test wird rot, und der Tippfehler kommt ans Licht.

Drei Formen für die zweite Angabe, alle drei nützlich:

  • Eine Fehlerklasse, etwa TypeError oder deine eigene aus 16.1. Das ist der häufigste Fall.
  • Ein Objekt mit Eigenschaften, etwa { code: "keine_deckung" }. Geprüft wird jede Eigenschaft, die du hinschreibst, und nur die.
  • Eine Prüffunktion, die true liefern muss. Der Ausweg für alles, was komplizierter ist.

Was nicht dazugehört, ist der Meldungstext. Ein Test auf "Die Deckung reicht nicht" bricht, sobald jemand das Komma ändert, und er ist nach einer Übersetzung wertlos. Prüf auf die Klasse oder auf code, aus demselben Grund wie in Lektion 3.5 und 16.1: Der Code ist für das Programm, die Meldung für den Menschen.

Der asynchrone Fall, und die Falle darin

Für abgelehnte Zusagen gibt es assert.rejects. Es sieht aus wie assert.throws und ist an einer Stelle grundverschieden: Es liefert selbst eine Zusage.

assert.rejects und das fehlende await
import test from "node:test";
import assert from "node:assert/strict";

async function ladeKonto(id) {
  if (id !== 1) throw new Error("gibt es nicht");
  return { id, stand: 100 };
}

test("ohne await besteht der Test, obwohl nichts geprueft wurde", () => {
  // ladeKonto(1) lehnt gar nicht ab, und trotzdem ist der Test gruen.
  assert.rejects(() => ladeKonto(1), Error);
});

test("mit await faellt genau das auf", async () => {
  await assert.rejects(() => ladeKonto(1), Error);
});

Beide Tests behaupten dasselbe, nämlich dass ladeKonto(1) ablehnt. Das tut es nicht, es liefert ein Konto. Der erste Test ist trotzdem grün.

Der Grund ist derselbe wie in Lektion 16.2: Ohne await ist die Testfunktion fertig, bevor assert.rejects zu einem Ergebnis kommt. Der Läufer sieht eine Funktion, die ohne Fehler zurückgekehrt ist, und schreibt einen Haken daneben.

Das ist die gefährlichste Sorte Test, gefährlicher als gar keiner. Ein fehlender Test fällt irgendwann auf. Ein grüner Test, der nichts prüft, gibt dir Sicherheit, die du nicht hast, und niemand schaut sich einen grünen Test noch einmal an.

Die Regel dagegen ist kurz: Bei assert.rejects gehört ein await davor, und die Testfunktion wird async. Dasselbe gilt für assert.doesNotReject und für jeden Aufruf im Test, der eine Zusage liefert.

Attrappen mit mock

Manchmal will man einen Fehlerfall prüfen, den man nicht herstellen kann: Der Zahlungsdienst antwortet nicht, die Platte ist voll, die Mail geht nicht raus. Dafür bringt node:test mock mit.

mock aus node:test
import { test, mock } from "node:test";
import assert from "node:assert/strict";

// Eine Abhaengigkeit, die im Test nicht wirklich laufen soll.
const dienst = {
  schickeMail(an, text) {
    throw new Error("wuerde wirklich eine Mail verschicken");
  },
};

function bestellen(dienst, kunde) {
  dienst.schickeMail(kunde, "Danke fuer deine Bestellung");
  return { status: "angelegt" };
}

test("verschickt genau eine Mail an den Kunden", () => {
  const attrappe = mock.method(dienst, "schickeMail", () => undefined);

  const ergebnis = bestellen(dienst, "kundin@beispiel.de");

  assert.equal(ergebnis.status, "angelegt");
  assert.equal(attrappe.mock.callCount(), 1);
  assert.deepEqual(attrappe.mock.calls[0].arguments, [
    "kundin@beispiel.de",
    "Danke fuer deine Bestellung",
  ]);

  attrappe.mock.restore();
});

mock.method(objekt, "name", ersatz) tauscht eine Methode aus und zeichnet dabei auf, wie sie aufgerufen wurde. Die Attrappe weiß danach, wie oft sie dran war (callCount()) und womit (calls[0].arguments), und beides lässt sich prüfen.

Für einen Fehlerfall setzt du statt () => undefined einfach einen Ersatz, der wirft, und schon lässt sich testen, was deine Funktion tut, wenn der Dienst nicht mitspielt.

restore() gibt das Original zurück. Wer es vergisst, verändert das Verhalten der folgenden Tests, und dann ist man wieder bei dem geteilten Zustand aus Lektion 17.2. mock.reset() am Ende räumt alles auf einmal auf.

Ein Wort der Vorsicht. Jede Attrappe ist eine Annahme darüber, wie sich das Original verhält, und diese Annahme prüft niemand. Wer zu viel ersetzt, testet am Ende sein eigenes Mockwerk. Die Faustregel: Attrappen für das, was nach draußen geht (Netz, Mail, Zahlung, Uhrzeit), und für alles andere die echte Sache.

Zum Mitnehmen

Ein Test, der nur prüft, dass irgendetwas schiefgeht, besteht auch bei einem Tippfehler. Geprüft wird deshalb, welcher Fehler kommt, und bei asynchronem Code entscheidet ein einziges await darüber, ob überhaupt etwas geprüft wird.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.