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 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.
def delivery_fee(total, kg, city): fee = 4.50 if city != "Sofia": fee = fee + 2.00 if city == "Vidin": fee = fee + 3.00 if kg > 10: fee = fee + (kg - 10) * 0.50 if total >= 50: fee = 0 return feeLetzten 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:
print("20 €, 2 kg, Sofia → ", delivery_fee(20, 2, "Sofia"), sep="")print("20 €, 2 kg, Plovdiv → ", delivery_fee(20, 2, "Plovdiv"), sep="")print("20 €, 16 kg, Sofia → ", delivery_fee(20, 16, "Sofia"), sep="")print("50 €, 2 kg, Plovdiv → ", delivery_fee(50, 2, "Plovdiv"), sep="")print("60 €, 25 kg, Plovdiv → ", delivery_fee(60, 25, "Plovdiv"), sep="")20 €, 2 kg, Sofia → 4.520 €, 2 kg, Plovdiv → 6.520 €, 16 kg, Sofia → 7.550 €, 2 kg, Plovdiv → 060 €, 25 kg, Plovdiv → 0Die 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 unittest
from delivery import delivery_fee
class DeliveryTest(unittest.TestCase): def test_sofia(self): self.assertEqual(delivery_fee(20, 2, "Sofia"), 4.5)
def test_outside_sofia(self): self.assertEqual(delivery_fee(20, 2, "Plovdiv"), 6.5)
def test_heavy(self): self.assertEqual(delivery_fee(20, 16, "Sofia"), 7.5)
def test_free_from_50(self): self.assertEqual(delivery_fee(50, 2, "Plovdiv"), 0)
def test_free_even_when_heavy(self): # So ist es heute: Bei kostenlosem Versand entfällt auch der Gewichtszuschlag. self.assertEqual(delivery_fee(60, 25, "Plovdiv"), 0)OK test_free_even_when_heavyOK test_free_from_50OK test_heavyOK test_outside_sofiaOK test_sofiaTests: 5, bestanden: 5, fehlgeschlagen: 0Fü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 Funktion und den Test in eine Datei (ohne die Zeile from delivery import ...) und führt sie mit unittest aus. Die Ergebnisse werden nach Namen sortiert. So ist die Ausgabe bei jedem Lauf gleich.
Die Werte im Test sind das, was Python ausgegeben hat. Die letzte 0 ist eine ganze Zahl, weil der alte Code fee = 0 schreibt. Für assertEqual gilt 0 == 0.0, aber der Probelauf zeigt genau, was die Funktion zurückgibt.
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:
def delivery_fee(total, kg, city): if total > 50: return 0 fee = 4.50 if city != "Sofia": fee = fee + 2.00 if city == "Vidin": fee = fee + 3.00 if kg > 10: fee = fee + (kg - 10) * 0.50 return feeFEHLERtest_free_from_50: 6.5 != 0OK test_free_even_when_heavyOK test_heavyOK test_outside_sofiaOK test_sofiaTests: 5, bestanden: 4, fehlgeschlagen: 1Ein 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(unittest.TestCase): def test_sofia(self): self.assertEqual(delivery_fee(20, 2, "Sofia"), 4.5)
def test_outside_sofia(self): self.assertEqual(delivery_fee(20, 2, "Plovdiv"), 6.5)
def test_heavy(self): self.assertEqual(delivery_fee(20, 16, "Sofia"), 7.5)
def test_free_from_50(self): self.assertEqual(delivery_fee(50, 2, "Plovdiv"), 0)
def test_free_even_when_heavy(self): # So ist es heute: Bei kostenlosem Versand entfällt auch der Gewichtszuschlag. self.assertEqual(delivery_fee(60, 25, "Plovdiv"), 0)Schritt für Schritt, in Worten
- 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.
- delivery_fee(20, 2, "Sofia") gibt 4.5 zurück. Der Test hält es fest.
- delivery_fee(20, 2, "Plovdiv") gibt 6.5 zurück. Der Test hält es fest.
- delivery_fee(20, 16, "Sofia") gibt 7.5 zurück. Der Test hält es fest.
- delivery_fee(50, 2, "Plovdiv") gibt 0 zurück. Der Test hält es fest.
- delivery_fee(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.
- Ein Kollege schiebt die Prüfung auf kostenlosen Versand nach ganz oben und schreibt > statt >=. Das ganze Netz läuft erneut.
- test_free_from_50 schlägt fehl: erwartet 0, aber 6.5. Eine Bestellung von genau 50 € zahlt jetzt Versand.
- Mit >= sind alle fünf Tests grün. Die Änderung ist sicher: Das Netz hat sie geprüft.
Mit >= ist alles grün:
def delivery_fee(total, kg, city): if total >= 50: return 0 fee = 4.50 if city != "Sofia": fee = fee + 2.00 if city == "Vidin": fee = fee + 3.00 if kg > 10: fee = fee + (kg - 10) * 0.50 return feeOK test_free_even_when_heavyOK test_free_from_50OK test_heavyOK test_outside_sofiaOK test_sofiaTests: 5, bestanden: 5, fehlgeschlagen: 0Die Ä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.50Die Tests aus der Karte „Aufschreiben, was sie heute tut“ laufen noch einmal. Was passiert?
def delivery_fee(total, kg, city): fee = 4.50 if city != "Sofia": fee = fee + 2.00 if city == "Vidin": fee = fee + 3.00 if kg > 10: fee = fee + kg * 0.50 if total >= 50: fee = 0 return feeProbier 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?
def test_free_even_when_heavy(self):
self.assertEqual(delivery_fee(60, 25, "Plovdiv"), )Du schreibst, was die Funktion heute zurückgibt. Der Test schützt das Verhalten, während du die Form änderst. Wenn der Versand wirklich 14 € kosten soll, ist das eine eigene Änderung: Du fragst den Shop, änderst die Regel und erst dann den Test, mit Absicht und in einem eigenen Commit.
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:
Grün: behalten (Commit). Rot: nur die letzte Änderung rückgängig machenTests für den Weg ergänzen, den du gleich anfasstTests noch einmal ausführenTests ausführen: alle sind grünEine 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(unittest.TestCase): def test_sofia(self): self.assertEqual(delivery_fee(20, 2, "Sofia"), 4.5)
def test_outside_sofia(self): self.assertEqual(delivery_fee(20, 2, "Plovdiv"), 6.5)
def test_heavy(self): self.assertEqual(delivery_fee(20, 16, "Sofia"), 7.5)
def test_free_from_50(self): print(delivery_fee(50, 2, "Plovdiv"))
def test_free_even_when_heavy(self): # So ist es heute: Bei kostenlosem Versand entfällt auch der Gewichtszuschlag. self.assertEqual(delivery_fee(60, 25, "Plovdiv"), 0)6.5OK test_free_even_when_heavyOK test_free_from_50OK test_heavyOK test_outside_sofiaOK test_sofiaTests: 5, bestanden: 5, fehlgeschlagen: 0Der falsche Betrag wird ausgegeben, und die Tests sind grün. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Der Test gibt das Ergebnis aus, vergleicht es aber mit nichts. Ein Test schlägt nur fehl, wenn eine Prüfung in ihm nicht erfüllt ist, also ist dieser bei jedem Ergebnis grün. Setze dort, wo die Ausgabe steht, eine Prüfung hin: self.assertEqual(..., 0).
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“:
def delivery_fee(total, kg, city): fee = 4.50 if city == "Vidin": fee = fee + 3.00 elif city != "Sofia": fee = fee + 2.00 if kg > 10: fee = fee + (kg - 10) * 0.50 if total >= 50: fee = 0 return feeOK test_free_even_when_heavyOK test_free_from_50OK test_heavyOK test_outside_sofiaOK test_sofiaTests: 5, bestanden: 5, fehlgeschlagen: 0Vidin vorher: 9.5Vidin nachher: 7.5Die 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.
Das Paket coverage.py:coverage run -m unittest, danach zeigt coverage report -m die Zeilen ohne Test. In PyCharm heißt es „Run with Coverage“.In Python 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:
def delivery_fee(total, kg, city): price = 4.50 if city != "Sofia": price = price + 2.00 if city == "Vidin": price = fee + 3.00 if kg > 10: price = price + (kg - 10) * 0.50 if total >= 50: price = 0 return priceDie fünf Tests sind weiterhin grün: Keiner erreicht diese Zeile. Der Fehler zeigt sich erst, wenn jemand nach Vidin bestellt:
try: delivery_fee(20, 2, "Vidin")except NameError as error: print("Vidin:", error)Vidin: name 'fee' is not definedDeshalb 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.
OK test_free_even_when_heavyOK test_free_from_50OK test_heavyOK test_outside_sofiaOK test_sofiaOK test_vidinOK test_vidin_free_from_50Tests: 7, bestanden: 7, fehlgeschlagen: 0Mach dann die Änderung mit else if aus der Karte „Was das Netz nicht auffängt“. Jetzt muss er fehlschlagen:
FEHLERtest_vidin: 7.5 != 9.5OK test_free_even_when_heavyOK test_free_from_50OK test_heavyOK test_outside_sofiaOK test_sofiaOK test_vidin_free_from_50Tests: 7, bestanden: 6, fehlgeschlagen: 1Weitere Aufgaben
- Die Gewichtsgrenze. Ergänze einen Test für genau 10 kg und einen für 11 kg. Ändere dann
kg > 10inkg >= 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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Tests mit unittest · Namen im Code · Funktionen: eine Aufgabe, wenige Parameter, keine versteckten Seiteneffekte · Code-Smells: lange Methode, große Klasse, Duplikate, Primitive statt Typen, lange if-Ketten
- Geht weiter in: Refactoring in kleinen Schritten · Übung: eine chaotische Klasse, Schritt für Schritt aufgeräumt · Abhängigkeiten: fremder Code, den dein Code ausführt
- Aufgaben zum Thema: Sauberer Code und Refactoring