Abschnitt 17 · Lektion 5
Synthese: die getestete API
Deine Bücher-API ist über sechs Abschnitte gewachsen. Sie hat Routen, eine Datenbank, eine Eingabeprüfung, eine Absicherung und einen Fehler-Handler. Was ihr fehlt, ist der Nachweis, dass sie das morgen noch tut.
Was ein sinnvoller Bestand abdeckt
Für eine API dieser Größe ist die Liste kurz und lässt sich abhaken:
- Je Route der Erfolgsfall. Kommt heraus, was herauskommen soll, mit dem richtigen Statuscode.
- Je Route der wichtigste Fehlerfall. Meistens ist das der 404 oder der 400, und geprüft wird
nicht nur die Zahl, sondern die Form der Antwort: derselbe
code, dieselbe Struktur wie überall. - Die Prüfung. Ein Fall, der abgewiesen werden muss, und einer, der genau noch durchgeht. Die Ränder aus Lektion 17.2, angewandt auf eine Route.
- Jede Absicherung, die du eingebaut hast. Die Einschleusung darf nicht funktionieren, die Fehlerantwort darf keine Interna enthalten, der geschützte Bereich muss ohne Sitzung zu bleiben.
Was nicht dazugehört: ein Test je Zeile Code, ein Test für Express selbst, ein Test dafür, dass SQLite Zeilen speichern kann. Fremder Code ist nicht dein Testgebiet.
Ein Helfer hält den Bestand lesbar
Acht Tests, die alle dasselbe Gerüst aus fetch, await und json() wiederholen, liest niemand
gern.
import express from "express";
/** Eine winzige Fassung der API, nur fuer dieses Beispiel. */
export function baueApp() {
const app = express();
app.use(express.json());
app.get("/api/buecher", (req, res) => res.json({ anzahl: 3 }));
app.get("/api/buecher/:id", (req, res) =>
Number(req.params.id) === 1
? res.json({ id: 1, titel: "Node in der Praxis" })
: res.status(404).json({ fehler: { code: "nicht_gefunden" } })
);
app.post("/api/buecher", (req, res) =>
req.body?.titel
? res.status(201).json({ id: 4, titel: req.body.titel })
: res.status(400).json({ fehler: { code: "ungueltige_eingabe" } })
);
return app;
} import { test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { baueApp } from "./mini.js";
let server;
let adresse;
beforeEach(async () => {
server = baueApp().listen(0);
await new Promise((fertig) => server.once("listening", fertig));
adresse = `http://127.0.0.1:${server.address().port}`;
});
afterEach(async () => {
await new Promise((fertig) => server.close(fertig));
});
/** Ein Helfer, der die immer gleichen vier Zeilen je Test einspart. */
async function hole(pfad, einstellungen = {}) {
const antwort = await fetch(`${adresse}${pfad}`, {
...einstellungen,
headers: einstellungen.body ? { "Content-Type": "application/json" } : undefined,
body: einstellungen.body ? JSON.stringify(einstellungen.body) : undefined,
});
return { status: antwort.status, koerper: await antwort.json() };
}
test("liefert die Sammlung", async () => {
const { status, koerper } = await hole("/api/buecher");
assert.equal(status, 200);
assert.equal(koerper.anzahl, 3);
});
test("antwortet auf ein unbekanntes Buch mit 404", async () => {
const { status, koerper } = await hole("/api/buecher/999");
assert.equal(status, 404);
assert.equal(koerper.fehler.code, "nicht_gefunden");
});
test("legt ein Buch an", async () => {
const { status, koerper } = await hole("/api/buecher", {
method: "POST",
body: { titel: "Testen ohne Angst", jahr: 2021 },
});
assert.equal(status, 201);
assert.equal(koerper.titel, "Testen ohne Angst");
});
test("weist ein Buch ohne Titel ab", async () => {
const { status, koerper } = await hole("/api/buecher", { method: "POST", body: { jahr: 2021 } });
assert.equal(status, 400);
assert.equal(koerper.fehler.code, "ungueltige_eingabe");
}); Der Helfer hole nimmt Pfad und Einstellungen, kümmert sich um Adresse, Kopfzeile und das Auspacken
der Antwort und gibt Status und Körper zurück. Jeder Test schrumpft damit auf das, was ihn
ausmacht: eine Anfrage und zwei Behauptungen.
Ein Wort der Vorsicht: Ein Testhelfer ist auch Code, und für ihn gibt es keinen Test. Halte ihn so einfach, dass man ihn beim Lesen versteht. Sobald er selbst Verzweigungen bekommt, verlagert er Logik dorthin, wo sie niemand prüft.
Der Test, der die Absicherung festhält
import test from "node:test";
import assert from "node:assert/strict";
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE buecher (id INTEGER PRIMARY KEY, titel TEXT NOT NULL)");
for (const titel of ["Node in der Praxis", "JavaScript von vorn", "HTTP verstehen"]) {
db.prepare("INSERT INTO buecher (titel) VALUES (?)").run(titel);
}
// So war die Suche vor Abschnitt 15: der Suchbegriff wird in die Abfrage geklebt.
function sucheVerkettet(q) {
return db.prepare(`SELECT titel FROM buecher WHERE titel LIKE '%${q}%'`).all();
}
// Und so danach: der Suchbegriff geht als Parameter mit.
function sucheMitParameter(q) {
return db.prepare("SELECT titel FROM buecher WHERE titel LIKE '%' || ? || '%'").all(q);
}
const angriff = "' OR '1'='1";
test("die verkettete Suche gibt bei der Einschleusung alles heraus", () => {
assert.equal(sucheVerkettet(angriff).length, 0);
});
test("die Suche mit Parameter findet schlicht nichts", () => {
assert.equal(sucheMitParameter(angriff).length, 0);
}); Das ist der Test, den man wirklich schreibt, und er ist der beste im ganzen Bestand.
Dieselbe Eingabe gegen zwei Fassungen der Suche. Die verkettete gibt bei ' OR '1'='1 den ganzen
Bestand heraus, die mit Parameter findet nichts, weil sie den Text als Text behandelt. Der rote Test
zeigt genau den Zustand, den du in Abschnitt 15 abgestellt hast.
Der Wert liegt in der Zukunft. In einem halben Jahr baut jemand die Suche um, weil sie schneller werden soll, und klebt dabei aus Versehen wieder zusammen. Ohne diesen Test fällt das niemandem auf, bis es jemandem auffällt, der es ausnutzt. Mit ihm wird die Pipeline rot, bevor es überhaupt eingecheckt ist.
Dasselbe gilt für jede andere Entscheidung aus Abschnitt 15 und 16: Die Fehlerantwort ohne Interna, der Deckel auf der Körpergröße, die Begrenzung der Anmeldeversuche. Jede davon ist ein Satz im Test und danach dauerhaft festgehalten.
Was eine Abdeckungszahl sagt und was nicht
node --test --experimental-test-coverage sagt dir, wie viel Prozent deiner Zeilen beim Testlauf
ausgeführt wurden. Das ist nützlich und wird trotzdem regelmäßig falsch verstanden.
import test from "node:test";
import assert from "node:assert/strict";
// Die richtige Fassung.
function stufeRichtig(betrag) {
if (betrag > 100) return 0.2;
if (betrag > 50) return 0.1;
return 0;
}
// Die kaputte Fassung: sie gibt immer null zurueck.
function stufeKaputt(betrag) {
if (betrag > 100) return 0;
if (betrag > 50) return 0;
return 0;
}
for (const [name, stufe] of [["richtig", stufeRichtig], ["kaputt", stufeKaputt]]) {
test(`${name}: der Test faehrt jede Zeile an und prueft trotzdem nichts`, () => {
for (const betrag of [10, 60, 200]) {
assert.equal(typeof stufe(betrag), "number");
}
});
test(`${name}: der Test prueft die Werte`, () => {
assert.equal(stufe(10), 0);
assert.equal(stufe(60), 0.1);
assert.equal(stufe(200), 0.2);
});
} Der erste Test fährt jede Zeile beider Fassungen an. Nach der Abdeckungszahl ist alles geprüft. Er ist aber auch bei der kaputten Fassung grün, weil er nur nachschaut, ob eine Zahl herauskommt, und nicht welche.
Abdeckung misst, was ausgeführt wurde, nicht was geprüft wurde. Die Zahl kann dir also nur eines verlässlich sagen: Wo sie niedrig ist, ist sicher nichts getestet. Wo sie hoch ist, weißt du gar nichts, und wer sie zur Zielvorgabe macht, bekommt Tests, die Zeilen anfahren und nichts behaupten.
Die Aufgabe dieser Lektion lässt dich den Bericht selbst erzeugen, über deinen eigenen acht Tests. Er zeigt dort beide Seiten auf einem Blatt: eine Datei, deren niedrige Zahl eine ehrliche Auskunft ist, und die Erinnerung an Lektion 17.2, wo hundert Prozent bei einer kaputten Funktion herauskamen.
npm test und was danach kommt
Zum Schluss die Verabredung aus Lektion 5.1: npm test ist der Befehl, den jeder erwartet. Wer
dein Projekt zum ersten Mal öffnet, probiert ihn, ohne die package.json zu lesen. Eine Zeile
"test": "node --test" im scripts-Block reicht dafür.
Und der Schritt danach liegt schon außerhalb dieses Kurses und ist trotzdem klein: Genau dieser Befehl läuft in einer Pipeline bei jedem Push. Sie braucht dafür nichts weiter als den Exit-Code aus Lektion 4.3. Null heißt weiter, alles andere heißt anhalten. Mehr Verbindung zwischen deinen Tests und dem Rest der Welt gibt es nicht, und mehr braucht es auch nicht.
Zum Mitnehmen
Je Route der Erfolgsfall, je Route der wichtigste Fehlerfall, die Prüfung, und ein Test für jede Absicherung, die du eingebaut hast. Das ist kein großer Bestand, und er schlägt bei fast jeder Änderung Alarm, die etwas kaputt macht.
Jetzt du
Basis Konto, kostenlosZu 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.