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 Methode Delivery.fee. 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.

class Delivery {
static double fee(double total, int kg, String city) {
double fee = 4.50;
if (!city.equals("Sofia")) {
fee = fee + 2.00;
}
if (city.equals("Vidin")) {
fee = fee + 3.00;
}
if (kg > 10) {
fee = fee + (kg - 10) * 0.50;
}
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 Methode 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 Methode zurückgeben sollte. Du fragst sie. Ein kurzes Programm ruft sie mit ein paar Bestellungen auf und gibt die Antworten aus:

System.out.println("20 €, 2 kg, Sofia → " + Delivery.fee(20, 2, "Sofia"));
System.out.println("20 €, 2 kg, Plovdiv → " + Delivery.fee(20, 2, "Plovdiv"));
System.out.println("20 €, 16 kg, Sofia → " + Delivery.fee(20, 16, "Sofia"));
System.out.println("50 €, 2 kg, Plovdiv → " + Delivery.fee(50, 2, "Plovdiv"));
System.out.println("60 €, 25 kg, Plovdiv → " + Delivery.fee(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.0
60 €, 25 kg, Plovdiv → 0.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 org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DeliveryTest {
@Test
void sofia() {
assertEquals(4.5, Delivery.fee(20, 2, "Sofia"));
}
@Test
void outsideSofia() {
assertEquals(6.5, Delivery.fee(20, 2, "Plovdiv"));
}
@Test
void heavy() {
assertEquals(7.5, Delivery.fee(20, 16, "Sofia"));
}
@Test
void freeFrom50() {
assertEquals(0.0, Delivery.fee(50, 2, "Plovdiv"));
}
@Test
void freeEvenWhenHeavy() {
// So ist es heute: Bei kostenlosem Versand entfällt auch der Gewichtszuschlag.
assertEquals(0.0, Delivery.fee(60, 25, "Plovdiv"));
}
}
Ergebnis
OK freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
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 legt die Klasse und den Test in eine Datei und startet JUnit 5 über die Launcher-API. Die Ergebnisse werden nach Namen sortiert. So ist die Ausgabe bei jedem Lauf gleich.

Die Werte im Test sind das, was Java ausgegeben hat. 4.5 und 0.0 sind double, und die Beträge hier sind Vielfache von 0,50 und werden exakt gespeichert, deshalb reicht assertEquals ohne Toleranz.

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:

class Delivery {
static double fee(double total, int kg, String city) {
if (total > 50) {
return 0;
}
double fee = 4.50;
if (!city.equals("Sofia")) {
fee = fee + 2.00;
}
if (city.equals("Vidin")) {
fee = fee + 3.00;
}
if (kg > 10) {
fee = fee + (kg - 10) * 0.50;
}
return fee;
}
}
Ergebnis
FEHLERfreeFrom50(): expected: <0.0> but was: <6.5>
OK freeEvenWhenHeavy()
OK heavy()
OK outsideSofia()
OK sofia()
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.

class DeliveryTest {
@Test
void sofia() {
assertEquals(4.5, Delivery.fee(20, 2, "Sofia"));
}
@Test
void outsideSofia() {
assertEquals(6.5, Delivery.fee(20, 2, "Plovdiv"));
}
@Test
void heavy() {
assertEquals(7.5, Delivery.fee(20, 16, "Sofia"));
}
@Test
void freeFrom50() {
assertEquals(0.0, Delivery.fee(50, 2, "Plovdiv"));
}
@Test
void freeEvenWhenHeavy() {
// So ist es heute: Bei kostenlosem Versand entfällt auch der Gewichtszuschlag.
assertEquals(0.0, Delivery.fee(60, 25, "Plovdiv"));
}
}
Schritt für Schritt, in Worten
  1. Fünf Tests, einer für jede Zeile des Probelaufs. Jeder ruft Delivery.fee auf und vergleicht mit der Antwort, die die Funktion heute gibt.
  2. Delivery.fee(20, 2, "Sofia") gibt 4.5 zurück. Der Test hält es fest.
  3. Delivery.fee(20, 2, "Plovdiv") gibt 6.5 zurück. Der Test hält es fest.
  4. Delivery.fee(20, 16, "Sofia") gibt 7.5 zurück. Der Test hält es fest.
  5. Delivery.fee(50, 2, "Plovdiv") gibt 0.0 zurück. Der Test hält es fest.
  6. Delivery.fee(60, 25, "Plovdiv") gibt 0.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. freeFrom50() schlägt fehl: erwartet 0.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:

class Delivery {
static double fee(double total, int kg, String city) {
if (total >= 50) {
return 0;
}
double fee = 4.50;
if (!city.equals("Sofia")) {
fee = fee + 2.00;
}
if (city.equals("Vidin")) {
fee = fee + 3.00;
}
if (kg > 10) {
fee = fee + (kg - 10) * 0.50;
}
return fee;
}
}
Ergebnis
OK freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
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.50;

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

class Delivery {
static double fee(double total, int kg, String city) {
double fee = 4.50;
if (!city.equals("Sofia")) {
fee = fee + 2.00;
}
if (city.equals("Vidin")) {
fee = fee + 3.00;
}
if (kg > 10) {
fee = fee + kg * 0.50;
}
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.0. Du bist sicher, dass der richtige Versand 14 € kostet. Was schreibst du vor der Änderung in den Test?

@Test
void freeEvenWhenHeavy() {
    assertEquals(, Delivery.fee(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“:

class DeliveryTest {
@Test
void sofia() {
assertEquals(4.5, Delivery.fee(20, 2, "Sofia"));
}
@Test
void outsideSofia() {
assertEquals(6.5, Delivery.fee(20, 2, "Plovdiv"));
}
@Test
void heavy() {
assertEquals(7.5, Delivery.fee(20, 16, "Sofia"));
}
@Test
void freeFrom50() {
System.out.println(Delivery.fee(50, 2, "Plovdiv"));
}
@Test
void freeEvenWhenHeavy() {
// So ist es heute: Bei kostenlosem Versand entfällt auch der Gewichtszuschlag.
assertEquals(0.0, Delivery.fee(60, 25, "Plovdiv"));
}
}
Ergebnis
6.5
OK freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
Tests: 5, bestanden: 5, fehlgeschlagen: 0

Der falsche Betrag wird ausgegeben, 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“:

class Delivery {
static double fee(double total, int kg, String city) {
double fee = 4.50;
if (city.equals("Vidin")) {
fee = fee + 3.00;
} else if (!city.equals("Sofia")) {
fee = fee + 2.00;
}
if (kg > 10) {
fee = fee + (kg - 10) * 0.50;
}
if (total >= 50) {
fee = 0;
}
return fee;
}
}
Die Tests
OK freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
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.

In IntelliJ IDEA startest du die Tests mit „Run with Coverage“, und Zeilen ohne Test leuchten rot. In einem Maven- oder Gradle-Projekt liefert JaCoCo dasselbe.Eines schützt der Compiler aber auch ohne Test. Wenn du fee von Hand umbenennst und im Zweig für Vidin vergisst, lässt sich das Programm nicht kompilieren: fee gibt es nicht mehr. Der Fehler wird gefunden, bevor es einen Test gibt.
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 freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
OK vidin()
OK vidinFreeFrom50()
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(): expected: <9.5> but was: <7.5>
OK freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
OK vidinFreeFrom50()
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 ·