Zum Inhalt
wannacode

Tests als Sicherheitsnetz

Kurz gesagt: Charakterisierungstests halten fest, was alter Code heute tut, und fangen jede versehentliche Verhaltensänderung auf, während du ihn aufräumst.

Problem

Die harmlose Änderung

Der Shop berechnet den Versand mit der Funktion deliveryFee. Sie wurde vor drei Jahren geschrieben, Tests gibt es keine. Der Versand kostet 4,50 €, außerhalb von Sofia 2 € mehr, nach Vidin noch einmal 3 €, und über 10 kg zahlst du 0,50 € für jedes weitere Kilogramm. Ab 50 € ist er kostenlos.

export function deliveryFee(total, kg, city) {
let fee = 4.5;
if (city !== "Sofia") {
fee = fee + 2;
}
if (city === "Vidin") {
fee = fee + 3;
}
if (kg > 10) {
fee = fee + (kg - 10) * 0.5;
}
if (total >= 50) {
fee = 0;
}
return fee;
}

Letzten Monat hat ein Kollege „aufgeräumt“: Wenn die Bestellung kostenlos ist, warum noch etwas ausrechnen? Also wanderte die Prüfung nach oben. Er schrieb total > 50 statt total >= 50. Eine ganze Woche lang zahlten Bestellungen von genau 50 € Versand. Niemand hat es bemerkt, bis die Beschwerden kamen.

Jetzt musst du eine neue Stadt ergänzen. Fasst du diese Funktion an? Wenn nicht, wird jede neue Regel oben draufgeklebt, und der Code wird immer unheimlicher. Wenn ja, zerstörst du vielleicht etwas, das funktioniert, und erfährst es nie.

Die Idee

Ein Netz unter dem Seil

Ein Seiltänzer geht ruhig, weil unter ihm ein Netz hängt. Wenn er fällt, fängt ihn das Netz auf, bevor etwas Schlimmes passiert.

Beim Code ist das Netz die Menge der Tests. Bevor du änderst, wie der Code geschrieben ist, schreibst du Tests, die sagen, was er heute tut. Dann änderst du ihn und führst die Tests aus. Grün: Das Verhalten ist gleich geblieben. Rot: Etwas hat sich geändert, und du weißt genau, was.

Solche Tests heißen Charakterisierungstests: Sie prüfen nicht, ob der Code richtig ist, sie beschreiben, wie er sich verhält. Wenn der Code heute etwas Seltsames tut, hält der Test auch das Seltsame fest. Das ist das Sicherheitsnetz: Kein Code wird angefasst, bevor Tests darunter liegen.

Sieh es dir an

Aufschreiben, was sie heute tut

Du rätst nicht, was die Funktion zurückgeben sollte. Du fragst sie. Ein kurzes Programm ruft sie mit ein paar Bestellungen auf und gibt die Antworten aus:

console.log(`20 €, 2 kg, Sofia → ${deliveryFee(20, 2, "Sofia")}`);
console.log(`20 €, 2 kg, Plovdiv → ${deliveryFee(20, 2, "Plovdiv")}`);
console.log(`20 €, 16 kg, Sofia → ${deliveryFee(20, 16, "Sofia")}`);
console.log(`50 €, 2 kg, Plovdiv → ${deliveryFee(50, 2, "Plovdiv")}`);
console.log(`60 €, 25 kg, Plovdiv → ${deliveryFee(60, 25, "Plovdiv")}`);
Ausgabe
20 €, 2 kg, Sofia → 4.5
20 €, 2 kg, Plovdiv → 6.5
20 €, 16 kg, Sofia → 7.5
50 €, 2 kg, Plovdiv → 0
60 €, 25 kg, Plovdiv → 0

Die letzte Zeile ist eine Überraschung. Das Paket wiegt 25 kg, und der Versand ist kostenlos: Kostenloser Versand lässt auch den Gewichtszuschlag entfallen. Ist das ein Fehler? Vielleicht. Aber so funktioniert es heute, Kunden verlassen sich darauf, und ob man es ändert, entscheidest nicht du, während du den Code aufräumst. Du schreibst es auf, wie es ist.

Jede Zeile des Probelaufs wird zu einem Test:

import { test } from "node:test";
import assert from "node:assert/strict";
import { deliveryFee } from "./delivery.js";
test("Sofia", () => {
assert.equal(deliveryFee(20, 2, "Sofia"), 4.5);
});
test("außerhalb von Sofia", () => {
assert.equal(deliveryFee(20, 2, "Plovdiv"), 6.5);
});
test("über 10 kg", () => {
assert.equal(deliveryFee(20, 16, "Sofia"), 7.5);
});
test("ab 50 € kostenlos", () => {
assert.equal(deliveryFee(50, 2, "Plovdiv"), 0);
});
test("kostenlos auch bei 25 kg (so ist es heute)", () => {
// So ist es heute: Bei kostenlosem Versand entfällt auch der Gewichtszuschlag.
assert.equal(deliveryFee(60, 25, "Plovdiv"), 0);
});
Ergebnis
OK Sofia
OK ab 50 € kostenlos
OK außerhalb von Sofia
OK kostenlos auch bei 25 kg (so ist es heute)
OK über 10 kg
Tests: 5, bestanden: 5, fehlgeschlagen: 0

Fünf Tests, fünfmal grün. Das Netz hängt.

Genauer, für Neugierige

Die Tests laufen hier genauso wie in der Testlektion des Moduls „Programme in der echten Welt“. Das geprüfte Programm schreibt delivery.js und delivery.test.js in einen temporären Ordner und führt sie mit run() aus node:test aus. Die Ergebnisse werden nach Namen sortiert. So ist die Ausgabe bei jedem Lauf gleich.

Die Werte im Test sind das, was JavaScript ausgegeben hat. JavaScript hat nur eine Art Zahl (number), und die Beträge hier sind Vielfache von 0,50 und werden exakt gespeichert, deshalb reicht assert.equal.

Sieh es dir an

Die harmlose Änderung unter dem Netz

Jetzt macht der Kollege dieselbe Änderung, aber unter einem Netz. Die Prüfung wandert nach oben:

export function deliveryFee(total, kg, city) {
if (total > 50) {
return 0;
}
let fee = 4.5;
if (city !== "Sofia") {
fee = fee + 2;
}
if (city === "Vidin") {
fee = fee + 3;
}
if (kg > 10) {
fee = fee + (kg - 10) * 0.5;
}
return fee;
}
Ergebnis
FEHLERab 50 € kostenlos: 6.5 !== 0
OK Sofia
OK außerhalb von Sofia
OK kostenlos auch bei 25 kg (so ist es heute)
OK über 10 kg
Tests: 5, bestanden: 4, fehlgeschlagen: 1

Ein Test ist rot, und er sagt genau, welcher Fall: eine Bestellung über 50 € außerhalb von Sofia. Erwartet 0, bekommen 6,50 €. Der Fehler wird am Schreibtisch gefunden, nicht von einem Kunden.

import { test } from "node:test";
import assert from "node:assert/strict";
import { deliveryFee } from "./delivery.js";
test("Sofia", () => {
assert.equal(deliveryFee(20, 2, "Sofia"), 4.5);
});
test("außerhalb von Sofia", () => {
assert.equal(deliveryFee(20, 2, "Plovdiv"), 6.5);
});
test("über 10 kg", () => {
assert.equal(deliveryFee(20, 16, "Sofia"), 7.5);
});
test("ab 50 € kostenlos", () => {
assert.equal(deliveryFee(50, 2, "Plovdiv"), 0);
});
test("kostenlos auch bei 25 kg (so ist es heute)", () => {
// So ist es heute: Bei kostenlosem Versand entfällt auch der Gewichtszuschlag.
assert.equal(deliveryFee(60, 25, "Plovdiv"), 0);
});
Schritt für Schritt, in Worten
  1. Fünf Tests, einer für jede Zeile des Probelaufs. Jeder ruft deliveryFee auf und vergleicht mit der Antwort, die die Funktion heute gibt.
  2. deliveryFee(20, 2, "Sofia") gibt 4.5 zurück. Der Test hält es fest.
  3. deliveryFee(20, 2, "Plovdiv") gibt 6.5 zurück. Der Test hält es fest.
  4. deliveryFee(20, 16, "Sofia") gibt 7.5 zurück. Der Test hält es fest.
  5. deliveryFee(50, 2, "Plovdiv") gibt 0 zurück. Der Test hält es fest.
  6. deliveryFee(60, 25, "Plovdiv") gibt 0 zurück, obwohl das Paket 25 kg wiegt. Seltsam, aber heute ist es so: Der Test hält es fest, er korrigiert es nicht.
  7. Ein Kollege schiebt die Prüfung auf kostenlosen Versand nach ganz oben und schreibt > statt >=. Das ganze Netz läuft erneut.
  8. „ab 50 € kostenlos“ schlägt fehl: erwartet 0, aber 6.5. Eine Bestellung von genau 50 € zahlt jetzt Versand.
  9. Mit >= sind alle fünf Tests grün. Die Änderung ist sicher: Das Netz hat sie geprüft.

Mit >= ist alles grün:

export function deliveryFee(total, kg, city) {
if (total >= 50) {
return 0;
}
let fee = 4.5;
if (city !== "Sofia") {
fee = fee + 2;
}
if (city === "Vidin") {
fee = fee + 3;
}
if (kg > 10) {
fee = fee + (kg - 10) * 0.5;
}
return fee;
}
Ergebnis
OK Sofia
OK ab 50 € kostenlos
OK außerhalb von Sofia
OK kostenlos auch bei 25 kg (so ist es heute)
OK über 10 kg
Tests: 5, bestanden: 5, fehlgeschlagen: 0

Die Änderung ist dieselbe, die eine ganze Woche unbemerkt blieb. Der einzige Unterschied ist das Netz.

Probier es aus

Sage die Ausgabe voraus

Jemand ändert die Zeile mit dem Gewichtszuschlag und vergisst, die ersten 10 kg abzuziehen:

fee = fee + kg * 0.5;

Die Tests aus der Karte „Aufschreiben, was sie heute tut“ laufen noch einmal. Was passiert?

export function deliveryFee(total, kg, city) {
let fee = 4.5;
if (city !== "Sofia") {
fee = fee + 2;
}
if (city === "Vidin") {
fee = fee + 3;
}
if (kg > 10) {
fee = fee + kg * 0.5;
}
if (total >= 50) {
fee = 0;
}
return fee;
}
Antwort

Probier es aus

Was du als erwarteten Wert schreibst

Für 60 €, 25 kg und Plovdiv hat der Probelauf ausgegeben: 0. Du bist sicher, dass der richtige Versand 14 € kostet. Was schreibst du vor der Änderung in den Test?

test("kostenlos auch bei 25 kg (so ist es heute)", () => {
  assert.equal(deliveryFee(60, 25, "Plovdiv"), );
});

Die Idee

Kleine Schritte

Das Netz hilft am meisten, wenn du aus geringer Höhe fällst. Wenn du zwanzig Dinge auf einmal änderst und ein Test fehlschlägt, weißt du nicht, welches der zwanzig schuld ist. Wenn du eine Sache änderst und der Test fehlschlägt, ist der Schuldige klar.

Die Arbeit hat also einen Rhythmus: eine kleine Änderung, Tests ausführen, grün, die nächste. Rot heißt: die letzte Änderung rückgängig machen (Strg+Z oder git checkout auf die Datei) und in noch kleineren Stücken wiederholen. So ist der Code fast immer in einem funktionierenden Zustand.

Bring die Schritte in die richtige Reihenfolge:

  1. Grün: behalten (Commit). Rot: nur die letzte Änderung rückgängig machen
  2. Tests für den Weg ergänzen, den du gleich anfasst
  3. Tests noch einmal ausführen
  4. Tests ausführen: alle sind grün
  5. Eine kleine Änderung machen

Probier es aus

Finde den Fehler

Jemand hat den Test für 50 € so geschrieben. Dann macht der Kollege die Änderung mit total > 50 aus der Karte „Die harmlose Änderung unter dem Netz“:

import { test } from "node:test";
import assert from "node:assert/strict";
import { deliveryFee } from "./delivery.js";
test("Sofia", () => {
assert.equal(deliveryFee(20, 2, "Sofia"), 4.5);
});
test("außerhalb von Sofia", () => {
assert.equal(deliveryFee(20, 2, "Plovdiv"), 6.5);
});
test("über 10 kg", () => {
assert.equal(deliveryFee(20, 16, "Sofia"), 7.5);
});
test("ab 50 € kostenlos", () => {
console.log(deliveryFee(50, 2, "Plovdiv"));
});
test("kostenlos auch bei 25 kg (so ist es heute)", () => {
// So ist es heute: Bei kostenlosem Versand entfällt auch der Gewichtszuschlag.
assert.equal(deliveryFee(60, 25, "Plovdiv"), 0);
});
Ergebnis
OK Sofia
OK ab 50 € kostenlos
OK außerhalb von Sofia
OK kostenlos auch bei 25 kg (so ist es heute)
OK über 10 kg
Tests: 5, bestanden: 5, fehlgeschlagen: 0

Der falsche Betrag wird zurückgegeben, und die Tests sind grün. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Was das Netz nicht auffängt

Das Netz fängt nur dort auf, wo es gespannt ist. Für Vidin gibt es keinen Test. Jemand „räumt“ die Städte mit else if „auf“:

export function deliveryFee(total, kg, city) {
let fee = 4.5;
if (city === "Vidin") {
fee = fee + 3;
} else if (city !== "Sofia") {
fee = fee + 2;
}
if (kg > 10) {
fee = fee + (kg - 10) * 0.5;
}
if (total >= 50) {
fee = 0;
}
return fee;
}
Die Tests
OK Sofia
OK ab 50 € kostenlos
OK außerhalb von Sofia
OK kostenlos auch bei 25 kg (so ist es heute)
OK über 10 kg
Tests: 5, bestanden: 5, fehlgeschlagen: 0
Vidin vorher und nachher
Vidin vorher: 9.5
Vidin nachher: 7.5

Die Tests sind grün, und die Lieferung nach Vidin wurde um 2 € billiger: Vorher zahlte Vidin die 2 € für „außerhalb von Sofia“ und obendrein die 3 €. Kein Test geht diesen Weg, also schlägt auch keiner fehl.

Prüfe, bevor du Code anfasst, welche Wege Tests haben, und ergänze welche für die, die du anfasst. Genau das zeigt die Testabdeckung: welche Zeilen liefen, während die Tests liefen.

Node hat es eingebaut: node --test --experimental-test-coverage gibt für jede Datei aus, welche Zeilen nicht gelaufen sind.

In JavaScript kommt noch etwas dazu: Es gibt keinen Compiler, der die ganze Datei liest. Jemand benennt fee von Hand in price um und vergisst es im Zweig für Vidin:

export function deliveryFee(total, kg, city) {
let price = 4.5;
if (city !== "Sofia") {
price = price + 2;
}
if (city === "Vidin") {
price = fee + 3;
}
if (kg > 10) {
price = price + (kg - 10) * 0.5;
}
if (total >= 50) {
price = 0;
}
return price;
}

Die fünf Tests sind weiterhin grün: Keiner erreicht diese Zeile. Der Fehler zeigt sich erst, wenn jemand nach Vidin bestellt:

try {
deliveryFee(20, 2, "Vidin");
} catch (error) {
console.log(`Vidin: ${error.message}`);
}
Ausgabe
Vidin: fee is not defined

Deshalb schützen Tests hier noch mehr als in Java und C#.

Genauer, für Neugierige

100 % Abdeckung heißt nicht, dass jeder Fall geprüft ist. Die Zeile mit total >= 50 ist auch durch einen Test mit 60 € „abgedeckt“, aber genau 50 € ist ein eigener Fall, und nur der Test mit 50 € fängt > auf. Die Abdeckung zeigt dir, wo das Netz Löcher hat. Wie dicht es dort ist, wo es existiert, hängt von den Fällen ab, die du gewählt hast: die Grenzen (50 €, 10 kg), jeder Zweig eines if und die seltsamen Werte aus dem Probelauf.

Die Befehle für die Abdeckung werden hier nicht ausgeführt: Die Prüfungen der Seite laufen ohne Netzwerk und ohne externe Pakete.

Übung

Ein Netz für Vidin

Löse die Aufgabe in deinem Editor.

Ein Netz für Vidin. Ergänze die Tests aus der Karte „Aufschreiben, was sie heute tut“ um zwei Tests: eine Bestellung über 20 €, 2 kg nach Vidin und eine Bestellung über 50 €, 2 kg nach Vidin. Führe zuerst den Probelauf aus, um zu sehen, was der Code heute zurückgibt, und halte das fest.

Ergebnis
OK Sofia
OK Vidin
OK Vidin ab 50 € kostenlos
OK ab 50 € kostenlos
OK außerhalb von Sofia
OK kostenlos auch bei 25 kg (so ist es heute)
OK über 10 kg
Tests: 7, bestanden: 7, fehlgeschlagen: 0

Mach dann die Änderung mit else if aus der Karte „Was das Netz nicht auffängt“. Jetzt muss er fehlschlagen:

Ergebnis
FEHLERVidin: 7.5 !== 9.5
OK Sofia
OK Vidin ab 50 € kostenlos
OK ab 50 € kostenlos
OK außerhalb von Sofia
OK kostenlos auch bei 25 kg (so ist es heute)
OK über 10 kg
Tests: 7, bestanden: 6, fehlgeschlagen: 1

Weitere Aufgaben

  • Die Gewichtsgrenze. Ergänze einen Test für genau 10 kg und einen für 11 kg. Ändere dann kg > 10 in kg >= 10. Schlägt etwas fehl? Warum?
  • Die neue Stadt. Ergänze Varna mit 1 € Aufschlag. Zuerst ein Test für Varna, der fehlschlägt, dann die Regel, dann wieder alle Tests.
  • Ein Probelauf für alles. Schreibe den Probelauf als Schleife über eine Tabelle von Fällen (Betrag, Gewicht, Stadt), sodass ein neuer Fall eine neue Zeile in der Tabelle ist.

Test

Teste dich

1. Der Probelauf zeigt, dass eine Bestellung über 60 € mit einem 25-kg-Paket kostenlos geliefert wird. Du hältst das für einen Fehler. Was schreibst du vor der Änderung in den Test?
2. Fünf Tests laufen durch. Du baust die Funktion um, und einer schlägt fehl. Was tust du?
3. Warum sind die Schritte klein, mit Tests nach jedem einzelnen?
4. Alle Tests sind grün. Heißt das, dass die Änderung nichts verändert hat?
5. Ein Test ruft die Funktion auf und gibt das Ergebnis aus, ohne es zu vergleichen. Was schützt er?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·