Bevor du einen Test schreibst – was du wirklich brauchst
Das Mitdenken vor der ersten Zeile Testcode: Wie du eine vage Anforderung in einen präzisen Vertrag verwandelst, Code testbar machst, Testfälle mit Grenzwerten planst, das erwartete Ergebnis ohne den Code ermittelst und entscheidest, was du nicht testest – ganz ohne pytest, nur mit reinem Python und assert.
Wie möchtest du lernen?
Das Problem, das wir lösen
Hier ist eine Funktion, die den Durchschnitt einer Liste von Prüfungsergebnissen berechnet, und so überprüfen die meisten von uns eine solche Funktion – ein paar Mal aufrufen und nachsehen:
def average(scores):
return sum(scores) / len(scores)
print(average([80, 90, 70]))
print(average([100]))80.0
100.0Beide Ergebnisse stimmen. Die Funktion „funktioniert“, und sie wandert ins Programm. Eine Woche später erreicht eine Schulklasse, für die es noch keine Ergebnisse gibt, dieselbe Zeile:
def average(scores):
return sum(scores) / len(scores)
print(average([]))Traceback (most recent call last):
File "/home/you/school/report.py", line 5, in <module>
print(average([]))
^^^^^^^^^^^
File "/home/you/school/report.py", line 2, in average
return sum(scores) / len(scores)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero„Als ich es ausprobiert habe, hat es funktioniert“ stimmte schon. Es bedeutete allerdings immer nur: „Es funktioniert für die zwei Eingaben, die ich zufällig ausprobiert habe.“ Manuelles Prüfen hat zwei Schwachstellen, die hier deutlich werden.
Die erste ist, dass es nur das abdeckt, woran du in diesem Moment gedacht hast – und eine leere Liste ist genau die Art von Fall, an die im Moment niemand denkt.
Die zweite ist, dass es sich nicht wiederholt. Die beiden print-Aufrufe wurden einmal ausgeführt, einmal gelesen und wieder gelöscht. Wenn sich die Funktion nächsten Monat ändert, führt sie niemand noch einmal aus.
Automatisierte Tests beheben die zweite Schwachstelle, und im Rest dieses Kurses geht es darum, sie mit pytest zu schreiben. Sieh dir aber die erste Schwachstelle genauer an, denn kein Werkzeug kann sie für dich lösen. Angenommen, du wolltest genau jetzt eine Prüfung für die leere Liste schreiben. Was sollte sie prüfen? Sollte average([]) den Wert 0 zurückgeben? None zurückgeben? Einen Fehler auslösen? Niemand hat das entschieden. Du kannst keinen Test für ein Verhalten schreiben, das niemand festgelegt hat, und du kannst kein Ergebnis prüfen, das du nicht bereits kennst.
Darum geht es in diesem Kapitel: um die Gedanken, die du dir vor der ersten Zeile Testcode machen musst. Wenn du das richtig machst, schreiben sich die Tests fast wie von selbst. Überspringst du diesen Schritt, landest du bei Tests, die zwar bestehen, den Fehler aber trotzdem übersehen.
Am Ende dieses Kapitels kannst du
- In einem Satz sagen, was ein Test ist: eine ausführbare Behauptung über ein Verhalten
- Eine vage Anforderung in einen präzisen Vertrag verwandeln – Eingaben, Ausgabe, Fehler, Seiteneffekte
- Code erkennen, der schwer zu testen ist, und ihn in einen reinen Kern mit einer dünnen Hülle umgestalten
- Testfälle anhand von Äquivalenzklassen und Grenzwerten als Tabelle planen
- Erwartete Ergebnisse unabhängig vom Code ermitteln – das Orakel
- Entscheiden, was du nicht testest und wann du genug Tests hast
- Den Plan als einfache
assert-Prüfungen aufschreiben, ausführen und begründen, warum sie allein noch nicht ausreichen
Voraussetzungen: keine – hier beginnt der Kurs. Du brauchst Python 3 und musst wissen, wie man eine Funktion schreibt. Du brauchst pytest noch nicht; alles in diesem Kapitel läuft mit reinem Python.
Bevor du den Test schreibst
Jedes Kapitel dieses Kurses enthält einen Abschnitt mit diesem Namen, und jeder davon beantwortet dieselben sechs Fragen für sein jeweiliges Thema. Aus diesem Kapitel stammen diese Fragen, hier findest du sie also gesammelt an einem Ort. Behalte sie im Hinterkopf; der Rest des Kurses nutzt sie als Checkliste.
- Was genau wird versprochen? Der Vertrag: Für diese Eingaben diese Ausgabe; für jene Eingaben dieser Fehler; und diese Seiteneffekte oder keine.
- Kann der Code von einem Test aufgerufen werden? Nimmt er seine Eingaben als Argumente entgegen und liefert ein Ergebnis zurück – oder liest er von der Tastatur, gibt Text aus und schaut auf die Uhr?
- Welche Fälle? Ein Fall für jede Art von Eingabe, beide Seiten jeder Grenze, die ungültigen Eingaben, das Leere und das sehr Große.
- Woher kenne ich das richtige Ergebnis? Von Hand oder aus der Spezifikation hergeleitet – niemals durch Ausführen des zu testenden Codes.
- Was muss vorhanden sein? Die Python-Version, eine Umgebung, der Ort für die Testdateien – und in späteren Kapiteln die Dateien, Einstellungen und Werkzeuge, die das Thema erfordert.
- Was werde ich nicht testen? Python selbst, Bibliotheken anderer Entwickler, Code, der zu einfach ist, um falsch zu sein, und Verhalten, das niemand versprochen hat.
Hier wenden wir sie auf average von oben an – die Funktion ist klein genug, um das vollständig durchzugehen.
Der Vertrag hat eine Lücke, also besteht der erste Schritt darin, sie zu schließen. Angenommen, du fragst nach, und die Antwort lautet: „Ein Durchschnitt ohne Ergebnisse ergibt keinen Sinn; löse einen ValueError aus.“ Die Funktion ist bereits testbar – sie nimmt eine Liste entgegen und gibt eine Zahl zurück. Für dieses Kapitel muss lediglich Python 3 vorhanden sein. Und hier sind die Fälle:
| Fall | Eingabe | Erwartet | Warum | | --- | --- | --- | --- | | Typisch | [80, 90, 70] | 80.0 | die normale Nutzung: (80 + 90 + 70) / 3 | | Ein einzelnes Ergebnis | [100] | 100.0 | kleinste Liste, die einen Durchschnitt hat | | Leer | [] | ValueError | der Fall, der fehlschlug; jetzt geklärt |
Was nicht auf der Liste steht: ob sum richtig addiert oder ob / richtig dividiert – das ist Pythons Aufgabe und wird weitaus gründlicher getestet als alles, was du oder ich je schreiben werden.
Der Rest des Kapitels geht jede der sechs Fragen nacheinander durch und schließt damit ab, den gesamten Prozess an einer realistischen Funktion durchzuspielen.
Was ein Test eigentlich ist
Ein Test ist eine ausführbare Behauptung über ein Verhalten: Gegeben diese Eingabe, erwarte dieses Ergebnis. „Ergebnis“ gibt es in genau drei Formen:
- ein Rückgabewert –
average([80, 90, 70])liefert80.0 - ein Fehler –
average([])löst einenValueErroraus - ein Seiteneffekt – nach
add_score(scores, 70)hat die Listescoresam Ende die70
Python hat bereits eine Anweisung, um eine solche Behauptung aufzuschreiben: assert. Sie tut nichts, wenn die Bedingung wahr ist, und löst einen AssertionError aus, wenn sie falsch ist. Hier ist jeweils eine Behauptung jeder Art, wobei average nun dem festgelegten Vertrag folgt:
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
def add_score(scores, new_score):
scores.append(new_score)
# 1. A return value: given this input, expect this output.
assert average([80, 90, 70]) == 80.0
# 2. An error: given this input, expect this exception.
try:
average([])
except ValueError:
pass
else:
raise AssertionError("average([]) should raise ValueError")
# 3. A side effect: given this call, expect this change to the world.
scores = [80, 90]
add_score(scores, 70)
assert scores == [80, 90, 70]
print("3 claims checked")3 claims checkedBeachte, was jede Behauptung benötigt: eine von dir gewählte Eingabe, ein bereits bekanntes Ergebnis und eine Möglichkeit, den Code aufzurufen und zu prüfen, was er getan hat. Die sechs Fragen stellen sicher, dass du alle drei Punkte parat hast, bevor du anfängst. Beachte auch, wie umständlich die Fehlerprüfung ist – fünf Zeilen try/except/else, nur um zu sagen: „Hier soll ein Fehler ausgelöst werden“. pytest macht daraus in Kapitel vier eine einzige Zeile.
1. Der Vertrag: von einer vagen Anforderung zu einem präzisen Versprechen
Anforderungen treffen meist als ein einziger Satz ein: „Mitglieder erhalten 10 % Rabatt auf große Bestellungen.“ Das klingt vollständig. Gib diesen Satz zwei sorgfältigen Entwicklern und beobachte, was passiert:
# "Members get 10% off big orders." Two honest readings of the same sentence.
def discount_by_asha(total, is_member):
if is_member and total > 1000:
return total * 0.10
return 0
def discount_by_ravi(total, is_member):
if is_member and total >= 1000:
return round(total * 0.10)
return 0
for total in [500, 1000, 1234.56]:
print(total, discount_by_asha(total, True), discount_by_ravi(total, True))500 0 0
1000 0 100
1234.56 123.456 123Keiner von beiden hat einen Fehler gemacht. Der Satz sagte nicht, ob genau 1000 als „groß“ gilt oder wie gerundet werden soll. Beide haben die Lücke unterschiedlich interpretiert: Bei einer Bestellung von 1000 gibt es beim einen 0 Rabatt, beim anderen 100. Kein Test kann entscheiden, wer von ihnen recht hat, weil nie definiert wurde, was „richtig“ bedeutet.
Ein Vertrag ist die Anforderung, präzise genug formuliert, um sie testen zu können. Um vom Satz zum Vertrag zu gelangen, stellst du Fragen – jedes Mal dieselben:
- Eingaben. Welche Typen? Welcher Wertebereich? Gibt es eine Grenze und ist die Grenze selbst mit eingeschlossen? Was ist mit null, negativ, leer,
None? - Ausgabe. Was genau wird zurückgegeben – der Rabatt oder der neue Gesamtbetrag? Welcher Typ? Wie gerundet?
- Fehler. Welche Eingaben werden abgewiesen und wie – welche Exception (Ausnahme)?
- Seiteneffekte. Verändert die Funktion etwas – eine übergebene Liste, eine Datei, eine Datenbank – oder gibt sie etwas aus? Oder gar nichts davon?
Auf den Rabatt angewendet, liefern diese Fragen Antworten, und diese Antworten gehören dorthin, wo der Code steht – in den Docstring:
def member_discount(total, is_member):
"""Return the discount on an order, in currency units.
- total: the order total before discount, a number >= 0.
A negative total raises ValueError.
- is_member: True or False.
- Members get 10% of the total when the total is 1000 or more
(1000 itself qualifies). Everyone else gets 0.
- The result is rounded to 2 decimal places.
- Returns the discount amount, not the new total.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.10, 2)
return 0
print(member_discount(999.99, True))
print(member_discount(1000, True))
print(member_discount(1234.56, True))
print(member_discount(5000, False))
try:
member_discount(-5, True)
except ValueError as error:
print("ValueError:", error)0
100.0
123.46
0
ValueError: total must not be negative, got -5Jede Zeile des Docstrings ist nun etwas, das ein Test überprüfen kann, und jede Prüfung hat einen eindeutigen Erwartungswert. Das ist der Maßstab für einen guten Vertrag: Könnte eine fremde Person für jede Eingabe den Erwartungswert aufschreiben, ohne den Code zu lesen? Wenn nicht, ist noch eine Frage offen.
Wer beantwortet die Fragen? Die Person, von der die Anforderung stammt – ein Product Owner, eine Lehrkraft, ein Kunde oder du selbst. Wenn niemand da ist, den du fragen kannst: Entscheide selbst, schreibe die Entscheidung in den Vertrag und mach weiter. Eine explizite Entscheidung kann überprüft und geändert werden; eine stillschweigende bleibt einfach im Code, bis sie jemanden böse überrascht.
2. Testbarer Code: ein reiner Kern und eine dünne Hülle
Ein Test ruft eine Funktion mit ausgewählten Eingaben auf und vergleicht das Ergebnis. Manche Funktionen machen genau das unmöglich. Hier ist ein Beispiel für die Happy Hour eines Cafés – 20 % Rabatt auf Getränke zwischen 17:00 und 19:00 Uhr:
from datetime import datetime
def drink_price():
price = float(input("Price: "))
hour = datetime.now().hour
if 17 <= hour < 19:
price = price * 0.8
print(f"You pay {price:.2f}")Wenn du es ausführst und einen Preis eingibst, funktioniert es. Versuche nun, es aus einer anderen Datei, check_drinks.py, zu überprüfen:
from drinks import drink_price
result = drink_price()
print("returned:", result)Eine automatisierte Prüfung läuft ohne jemanden an der Tastatur ab. < /dev/null übergibt dem Programm genau das – eine leere Eingabe:
$ python check_drinks.py < /dev/null
Price: Traceback (most recent call last):
File "/home/you/cafe/check_drinks.py", line 3, in <module>
result = drink_price()
^^^^^^^^^^^^^
File "/home/you/cafe/drinks.py", line 5, in drink_price
price = float(input("Price: "))
^^^^^^^^^^^^^^^^
EOFError: EOF when reading a lineÜbergib ihm einen Preis, und es läuft – aber sieh dir an, was zurückkommt:
$ echo 10 | python check_drinks.py
Price: You pay 10.00
returned: NoneDrei separate Probleme, und jedes davon kommt häufig vor:
- Es liest seine Eingabe mit
input()ein, anstatt sie als Argument entgegenzunehmen – ein Test kann die Eingabe also nicht selbst bestimmen. - Es gibt sein Ergebnis aus (
print), anstatt es zurückzugeben – ein Test erhält alsoNoneund hat nichts zum Vergleichen. - Es liest die Uhrzeit selbst ab. Dieser Lauf fand kurz vor sieben Uhr morgens statt, daher war das Ergebnis
10.00; derselbe Befehl um halb sechs abends liefert8.00. Eine Prüfung würde je nach Ausführungszeitpunkt bestehen oder fehlschlagen – ein flaky Test, was schlimmer ist als gar kein Test, weil man sich daran gewöhnt, ihn zu ignorieren.
Die Lösung ist kein Test-Trick, sondern eine Frage der Struktur: Trenne die Entscheidung von der Ein- und Ausgabe. Die Entscheidung wird zu einer reinen Funktion – alles, was sie benötigt, erhält sie über Argumente, das Ergebnis geht als Rückgabewert hinaus und sie berührt sonst nichts. Tastatur, Bildschirm und Uhr wandern in eine dünne Hülle darum herum:
from datetime import datetime
def drink_price(price, hour):
"""Return the price to pay for a drink.
20% off from 17:00 up to, but not including, 19:00.
"""
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def main():
# The thin shell: all the input, output and clock reading lives here.
price = float(input("Price: "))
print(f"You pay {drink_price(price, datetime.now().hour):.2f}")
if __name__ == "__main__":
main()Jetzt können die Prüfungen zu jeder Tageszeit jede beliebige Stunde wählen:
from drinks import drink_price
assert drink_price(10, 16) == 10
assert drink_price(10, 17) == 8.0
assert drink_price(10, 18) == 8.0
assert drink_price(10, 19) == 10
print("all checks passed")all checks passedFür die Person an der Tastatur verhält sich das Programm genauso wie zuvor. Geändert hat sich, dass der Teil, den es sich zu testen lohnt – die Geschäftsregel –, nun isoliert getestet werden kann. Und die Hülle ist so dünn, dass man darin kaum etwas falsch machen kann.
Die Warnsignale für schwer testbaren Code, damit du sie erkennst, bevor du einen Test schreibst:
| Signal | Warum es einem Test schadet | Die übliche Lösung | | --- | --- | --- | | input() innerhalb der Logik | der Test kann die Eingabe nicht wählen | als Argument übergeben | | print() als einziges Ergebnis | es kommt nichts zurück, was man vergleichen könnte | Wert mit return zurückgeben; in der Hülle ausgeben | | datetime.now(), random im Code | das Ergebnis ändert sich von Lauf zu Lauf | Zeit oder Zufallswert als Parameter übergeben | | liest oder ändert eine globale Variable | Tests beeinflussen sich gegenseitig | als Parameter übergeben, neuen Wert zurückgeben | | öffnet eine feste Datei oder URL im Code | der Test benötigt diese Datei oder das Netzwerk | Daten oder Dateipfad als Parameter übergeben |
Man kann Code nicht immer umgestalten – manchmal gehört er jemand anderem, oder die Ein-/Ausgabe ist das eigentliche Verhalten. pytest hat Werkzeuge für solche Fälle: capsys fängt Ausgaben ab (Kapitel neun), tmp_path stellt temporäre Ordner bereit (Kapitel neun) und monkeypatch sowie Mocks ersetzen Uhrzeit, Netzwerk und Umgebung (Kapitel zehn und elf). Aber das Umgestalten steht immer an erster Stelle. Code, der leicht zu testen ist, ist meistens schlicht einfacher zu verstehen.
3. Die Fälle auswählen
Du kannst nicht jede Eingabe testen; allein drink_price akzeptiert jeden Preis zu jeder Stunde. Die Aufgabe besteht darin, eine kleine Auswahl zu treffen, bei der jeder Fall einen Fehler aufdecken könnte, den die anderen übersehen würden. Zwei Konzepte leisten dabei den Großteil der Arbeit.
Äquivalenzklassen. Fasse Eingaben zusammen, die der Vertrag gleich behandelt. Ein beliebiger Vertreter einer Gruppe steht stellvertretend für den Rest: Wenn drink_price(10, 12) stimmt, stimmt drink_price(10, 11) fast sicher auch, da dieselbe Codezeile beide Fälle verarbeitet. Angenommen, der Vertrag wurde präzisiert: Eine Stunde außerhalb von 0–23 oder ein negativer Preis löst einen ValueError aus. Dann teilen sich die Stunden in fünf Klassen ein:
hour: ... -2 -1 | 0 1 ... 15 16 | 17 18 | 19 20 ... 23 | 24 25 ...
invalid | full price | 20% off | full price | invalidGrenzwerte. Fehler verteilen sich nicht gleichmäßig über eine Klasse. Sie häufen sich an ihren Rändern, denn dort wird zwischen < und <= entschieden und dort wird „bis 19:00 Uhr“ in Code übersetzt. Teste daher für jede Grenze den Wert auf beiden Seiten: den letzten der einen Klasse und den ersten der nächsten. Bei einer Grenze L bedeutet das, L - 1, L und L + 1 zu prüfen; bei Anzahlen und Längen ebenso 0 und 1.
Hier ist der Grund, warum „ein typischer Wert pro Klasse“ nicht ausreicht. Unten ist der Happy-Hour-Code mit einem Flüchtigkeitsfehler geschrieben: 17 < hour statt 17 <= hour:
def drink_price(price, hour):
# A slip: 17 < hour instead of 17 <= hour.
if 17 < hour < 19:
return round(price * 0.8, 2)
return price
# Three "typical" cases, one from each valid class:
assert drink_price(10, 12) == 10
assert drink_price(10, 18) == 8.0
assert drink_price(10, 21) == 10
print("typical cases passed")
# The boundary:
assert drink_price(10, 17) == 8.0
print("boundary passed")typical cases passed
Traceback (most recent call last):
File "/home/you/cafe/boundary.py", line 15, in <module>
assert drink_price(10, 17) == 8.0
^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorEin Fall aus jeder Klasse: Alle bestehen, und Gäste, die um Punkt fünf Uhr ankommen, zahlen den vollen Preis. Der Grenzfall deckt das sofort auf.
Klassen und Grenzen decken die Struktur der Eingabe ab. Gehe danach diese Fragen durch, von denen jede schon echte Bugs aufgedeckt hat:
- Ungültige Eingaben – was der Vertrag abweisen soll: ein negativer Preis, Stunde
24. - Leer und null –
[],"",0, ein Preis von0. Verhält sich „nichts“ wie erwartet? None– nur dann, wenn der Vertrag es ausdrücklich erwähnt. Falls nicht, ist es kein Fall (siehe Frage sechs).- Große Werte – eine sehr lange Liste, eine sehr große Zahl. Alles mit einer Obergrenze: an der Grenze testen.
- Rundung – dort, wo eine Berechnung mehr Nachkommastellen liefert, als das Ergebnis behält.
Zusammengenommen ergibt das einen Testplan: eine Zeile pro Fall, aufgeschrieben vor der ersten Zeile Testcode. Die letzte Spalte ist die wichtigste – eine Zeile, die du nicht begründen kannst, brauchst du nicht.
| Fall | Eingabe (price, hour) | Erwartet | Warum | | --- | --- | --- | --- | | Niedrigste gültige Stunde | (10, 0) | 10 | Grenze des gültigen Bereichs | | Letzte Stunde zum Normalpreis | (10, 16) | 10 | unterhalb der 17:00-Grenze | | Erste rabattierte Stunde | (10, 17) | 8.0 | genau auf der 17:00-Grenze | | Letzte rabattierte Stunde | (10, 18) | 8.0 | unterhalb der 19:00-Grenze | | Erste Stunde wieder zum Normalpreis | (10, 19) | 10 | genau auf der 19:00-Grenze – „ausschließlich“ | | Höchste gültige Stunde | (10, 23) | 10 | Grenze des gültigen Bereichs | | Gratis-Getränk | (0, 17) | 0 | Preis null | | Rundung | (2.49, 17) | 1.99 | 2.49 × 0.8 = 1.992, gerundet auf 2 Stellen | | Stunde zu niedrig | (10, -1) | ValueError | knapp außerhalb des Bereichs | | Stunde zu hoch | (10, 24) | ValueError | knapp außerhalb des Bereichs | | Negativer Preis | (-1, 12) | ValueError | ungültige Eingabe |
Und so wird der Plan in Überprüfungen umgewandelt, angewendet auf die präzisierte Funktion:
def drink_price(price, hour):
"""Return the price to pay for a drink.
- price: a number >= 0. hour: a whole number 0-23.
- 20% off from 17:00 up to, but not including, 19:00.
- The result is rounded to 2 decimal places.
- An hour outside 0-23 or a negative price raises ValueError.
"""
if not 0 <= hour <= 23:
raise ValueError(f"hour must be 0-23, got {hour}")
if price < 0:
raise ValueError(f"price must not be negative, got {price}")
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def raises_value_error(price, hour):
try:
drink_price(price, hour)
except ValueError:
return True
return False
assert drink_price(10, 0) == 10 # lowest valid hour
assert drink_price(10, 16) == 10 # last full-price hour before
assert drink_price(10, 17) == 8.0 # first discounted hour
assert drink_price(10, 18) == 8.0 # last discounted hour
assert drink_price(10, 19) == 10 # first full-price hour after
assert drink_price(10, 23) == 10 # highest valid hour
assert drink_price(0, 17) == 0 # free drink stays free
assert drink_price(2.49, 17) == 1.99 # rounding: 1.992 -> 1.99
assert raises_value_error(10, -1) # just below the valid range
assert raises_value_error(10, 24) # just above the valid range
assert raises_value_error(-1, 12) # negative price
print("11 checks passed")11 checks passedElf Zeilen, und jede einzelne steht dort aus einem Grund, den du laut aussprechen kannst.
4. Das Orakel: das Ergebnis ohne den Code kennen
Jede Prüfung vergleicht das Ergebnis des Codes mit einem erwarteten Ergebnis. Die Quelle des erwarteten Ergebnisses nennt man Orakel, und es gilt eine feste Regel: Es darf niemals der zu testende Code selbst sein.
Das klingt fast zu selbstverständlich, um es überhaupt erwähnen zu müssen – bis man sieht, wie leicht dagegen verstoßen wird. Hier hat member_discount einen Fehler – 1 % statt 10 % – und zwei Prüfungen:
def member_discount(total, is_member):
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.01, 2) # bug: 1%, not 10%
return 0
# Wrong: the expected value comes from the code under test.
expected = member_discount(2000, True)
assert member_discount(2000, True) == expected
print("self-check passed, expected was", expected)
# Right: the expected value was worked out by hand. 10% of 2000 is 200.
assert member_discount(2000, True) == 200
print("hand-check passed")self-check passed, expected was 20.0
Traceback (most recent call last):
File "/home/you/shop/oracle.py", line 15, in <module>
assert member_discount(2000, True) == 200
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorDie erste Prüfung vergleicht die Funktion mit sich selbst. Sie besteht für die korrekte Funktion, für diese fehlerhafte Version und für jede andere denkbare Implementierung – sie kann gar nicht fehlschlagen und sagt dir daher absolut nichts.
Niemand schreibt das so unverhohlen auf. Derselbe Fehler schleicht sich meist in einer von zwei Verkleidungen ein:
- Die Ausgabe kopieren. Du führst die Funktion aus, sie gibt
20.0aus und du fügst20.0in die Prüfung ein. Wenn der Code bereits fehlerhaft war, sichert die Prüfung nun den Bug ab. - Die Formel wiederholen. Du schreibst als Erwartungswert
2000 * 0.01auf – dieselbe Rechnung, die auch der Code durchführt. Eine Kopie der Codelogik teilt auch die Fehler des Codes. (Im Abschnitt „Wenn es schiefgeht“ weiter unten siehst du genau das in Aktion.)
Gute Orakel, grob sortiert vom häufigsten zum seltensten:
- Von Hand ausrechnen anhand des Vertrags. Wähle Eingaben, die das leicht machen: lieber 2000 als 1873.41, damit du „10 % davon“ im Kopf berechnen kannst.
- Beispiele in der Spezifikation. Wenn die Anforderung sagt „eine Bestellung von 1500 erhält 150 Rabatt“, dann ist diese Zeile ein Testfall.
- Bekannte Fakten. Wasser kocht bei 100 °C und 212 °F; eine leere Liste hat die Länge 0.
- Eine andere, vertrauenswürdige Methode. Eine einfachere, langsamere Berechnung oder eine Funktion aus der Standardbibliothek, die dieselbe Aufgabe auf andere Weise löst:
import statistics
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
samples = [[80, 90, 70], [1, 2], [5], [10, 0, 0, 0]]
for scores in samples:
assert average(scores) == statistics.mean(scores), scores
print("agrees with statistics.mean on", len(samples), "samples")agrees with statistics.mean on 4 samplesstatistics.mean wurde von anderen Entwicklern auf andere Weise geschrieben; die Übereinstimmung damit hat also Aussagekraft. (Der Vergleich von Dezimalzahlen mit == kann tückisch sein – 0.1 + 0.2 == 0.3 ist in Python False. Die Werte hier sind unbedenklich; Kapitel fünf zeigt, wie man Floats sauber vergleicht.)
Wenn du das erwartete Ergebnis mit keinem dieser Mittel ermitteln kannst, ist das kein Testproblem. Es bedeutet schlicht, dass du noch nicht weißt, was der Code tun soll – geh zurück zum Vertrag.
5. Was vorhanden sein muss
Für dieses Kapitel nur Python 3. Prüfe, welche Version du hast:
$ python3 --version
Python 3.12.3Dieser Kurs verwendet Python 3.12, und ab Version 3.10 lässt sich fast alles davon ausführen. Unter Windows lautet der Befehl meist py --version.
Ab dem nächsten Kapitel müssen drei weitere Dinge vorhanden sein, bevor ein Test laufen kann, und Kapitel eins richtet jedes davon ein:
- Eine virtuelle Umgebung für das Projekt, damit das Testwerkzeug in demselben Python installiert ist, das deinen Code ausführt.
- Darin installiertes pytest.
- Ein Ort für die Tests. Testdateien liegen neben dem Code oder in einem
tests/-Ordner, mit Dateinamen, die mittest_beginnen. Kapitel eins beginnt mit der ersten Variante; Kapitel drei wechselt zur zweiten und erklärt die Gründe.
Spätere Kapitel bringen ihre eigenen Anforderungen mit – eine Konfigurationsdatei, ein Plugin, Beispieldaten auf der Festplatte – und jedes listet sie in seinem eigenen Abschnitt „Bevor du den Test schreibst“ auf.
6. Was du nicht testest und wann du aufhörst
Ein Test, der keinen Fehler in deinem Code aufdecken kann, kostet Zeit beim Schreiben, Zeit bei der Ausführung und Zeit bei der Wartung – und bringt nichts zurück. Lass Folgendes weg:
- Python selbst. Nicht, ob
sortedsortiert oderroundrundet. Teste, ob deine Funktion das Richtige in der richtigen Reihenfolge sortiert. - Bibliotheken anderer Entwickler. Nicht, ob
requestseine Anfrage sendet oderpandaseine CSV-Datei einliest. Teste, was dein Code mit dem Ergebnis macht – und wenn du dir über das Verhalten einer Bibliothek sicher sein musst, reicht eine einzelne kleine Prüfung, die deine Annahme dokumentiert. - Code, der zu einfach ist, um falsch zu sein. Eine Funktion, die eine Konstante zurückgibt, oder eine Klasse, die lediglich ihre Argumente speichert. Wenn du dir keinen denkbaren Fehler vorstellen kannst, kann der Test auch keinen finden.
- Verhalten, das niemand versprochen hat. Wenn der Vertrag nichts darüber sagt, was passiert, wenn man einen String an
drink_priceübergibt, würde ein Test nur das festschreiben, was der Code heute zufällig tut. Nimm es entweder in den Vertrag auf – und teste es dann – oder lass es weg. - Das Wie statt des Was. Nicht, welche Hilfsfunktion intern aufgerufen wurde oder in welcher Reihenfolge die Schritte abliefen. Teste, was hineingeht und was herauskommt; dann kann das Innere nach Belieben umgeschrieben werden, und die Tests beweisen, dass es immer noch funktioniert.
Und wie viele Tests sind genug? Es gibt keine feste Zahl, aber eine Regel. Du hast genug, wenn:
- jede Äquivalenzklasse mindestens einen Testfall hat,
- jede Grenze auf beiden Seiten einen Fall hat,
- jeder Fehler, den der Vertrag verspricht, einen Testfall hat,
- und jeder Fehler, den du jemals behoben hast, einen eigenen Testfall hat, damit er nicht unbemerkt zurückkehrt.
Dann hör auf. Frage dich bei jedem neuen Fall, den du hinzufügen möchtest: Welchen Fehler würde dieser Fall aufdecken, den die anderen übersehen würden? Wenn du keinen nennen kannst, ist es ein Duplikat. drink_price(10, 11) neben drink_price(10, 12) deckt nichts Neues auf.
Ein vollständiges Beispiel
Nun der gesamte Prozess von Anfang bis Ende an einer realistischen Funktion.
Die Anforderung, wie sie eintraf: „Der Versand kostet 60 für lokale Pakete und 120 für nationale. Schwerere Pakete kosten mehr – 20 pro zusätzlichem Kilo lokal, 30 national. Wir nehmen nichts über 30 kg an.“
Die Fragen und die Antworten darauf:
| Frage | Antwort | | --- | --- | | Was deckt der Grundpreis ab? | Das erste Kilogramm – genau 1 kg fällt noch unter den Grundpreis. | | Werden 1.2 kg als 1 kg oder als 2 kg berechnet? | Jedes angefangene Kilogramm zählt: 1.2 kg werden als 2 kg berechnet. | | Wird 30 kg selbst noch angenommen? | Ja. 30 ist erlaubt; alles darüber wird abgewiesen. | | Was ist mit 0 kg oder einem negativen Gewicht? | Abgewiesen – das ist ein Eingabefehler. | | Wie werden Zonen geschrieben? | Genau "local" oder "national". Alles andere wird abgewiesen, auch "Local". | | Was kommt zurück? | Die Kosten als ganze Zahl. Nichts wird ausgegeben oder gespeichert. |
Der Vertrag und die testbare Funktion, shipping.py. Sie nimmt alles als Argumente entgegen und gibt eine Zahl zurück; es gibt keine Hülle abzutrennen, weil darin überhaupt keine Ein- oder Ausgabe stattfindet:
import math
BASE = {"local": 60, "national": 120} # covers the first kilogram
PER_EXTRA_KG = {"local": 20, "national": 30}
MAX_WEIGHT_KG = 30
def shipping_cost(weight_kg, zone):
"""Return the shipping cost of one parcel, as a whole number.
- weight_kg: a number. At or below 0, or above 30: ValueError.
- zone: "local" or "national", exactly. Anything else: ValueError.
- The base price covers the first kilogram (1 kg included).
- Every started kilogram after that costs extra: 1.2 kg is charged as 2 kg.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if zone not in BASE:
raise ValueError(f"unknown zone: {zone!r}")
if not 0 < weight_kg <= MAX_WEIGHT_KG:
raise ValueError(f"weight must be more than 0 and at most 30 kg, got {weight_kg}")
extra_kg = math.ceil(weight_kg) - 1
return BASE[zone] + extra_kg * PER_EXTRA_KG[zone]math.ceil rundet auf die nächste ganze Zahl auf – math.ceil(1.2) ist 2 –, was genau „jedes angefangene Kilogramm zählt“ entspricht.
Die Testfalltabelle. Die Gewichtsklassen lauten: ungültig (0 und darunter), das Basis-Kilogramm (über 0, bis einschließlich 1), zusätzliche Kilogramm (über 1, bis einschließlich 30), ungültig (über 30). Die Zonen sind "local", "national" und alles andere. Jeder Erwartungswert wird von Hand anhand des Vertrags berechnet, nicht aus dem Code:
| Fall | Eingabe | Erwartet | Warum | | --- | --- | --- | --- | | Leichtes Paket | (0.5, "local") | 60 | innerhalb des Basis-Kilogramms | | Genau 1 kg | (1, "local") | 60 | obere Grenze der Basis: 1 kg ist inklusive | | Knapp über 1 kg | (1.2, "local") | 80 | angefangenes Kilogramm zählt: 60 + 1 × 20 | | Ganze Kilos, lokal | (3, "local") | 100 | 60 + 2 × 20 | | Ganze Kilos, national | (3, "national") | 180 | 120 + 2 × 30 – die andere Zone | | An der Obergrenze | (30, "national") | 990 | 120 + 29 × 30; 30 wird noch angenommen | | Null | (0, "local") | ValueError | untere Grenze: 0 wird abgewiesen | | Negativ | (-2, "local") | ValueError | ungültige Eingabe | | Über der Obergrenze | (30.5, "local") | ValueError | knapp über 30 | | Falsche Zone | (2, "Local") | ValueError | nicht exakt "local" |
Was bewusst nicht in der Tabelle steht: ein Gewicht von None oder "2". Der Vertrag besagt, dass das Gewicht eine Zahl ist, diese Werte liegen also außerhalb – Python löst beim Vergleich mit 0 seinen eigenen TypeError aus, und mehr versprechen wir nicht. Auch nicht math.ceil: Das gehört zu Python, nicht zu unserem Code. Und auch nicht (2, "local") neben (3, "local"): dieselbe Klasse, dieselbe Codezeile, kein neuer Fehler, den man aufdecken könnte.
Die Prüfungen, check_shipping.py – die Tabelle, Zeile für Zeile:
from shipping import shipping_cost
def raises_value_error(weight_kg, zone):
"""True if shipping_cost raises ValueError for these arguments."""
try:
shipping_cost(weight_kg, zone)
except ValueError:
return True
return False
# Valid weights: one case per class, and both sides of every edge.
assert shipping_cost(0.5, "local") == 60
assert shipping_cost(1, "local") == 60
assert shipping_cost(1.2, "local") == 80
assert shipping_cost(3, "local") == 100
assert shipping_cost(3, "national") == 180
assert shipping_cost(30, "national") == 990
# Invalid input: the contract promises a ValueError for each.
assert raises_value_error(0, "local")
assert raises_value_error(-2, "local")
assert raises_value_error(30.5, "local")
assert raises_value_error(2, "Local")
print("all 10 checks passed")$ python check_shipping.py
all 10 checks passedJetzt bauen wir einen Fehler ein. Einige Monate später räumt jemand in shipping.py auf, entfernt import math und schreibt int(weight_kg) statt math.ceil(weight_kg). Das liest sich gut und läuft ohne Fehler durch. Führe die Prüfungen erneut aus:
$ python check_shipping.py
Traceback (most recent call last):
File "/home/you/shop/check_shipping.py", line 14, in <module>
assert shipping_cost(0.5, "local") == 60
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorDer Plan hat funktioniert: Die Änderung flog in genau dem Moment auf, als die Prüfungen liefen. int schneidet die Nachkommastellen ab, anstatt aufzurunden, sodass aus 0.5 kg int(0.5) - 1 wurde – also -1 zusätzliches Kilogramm.
Aber lies den Bericht so, als wüsstest du das noch nicht, und beachte, wie wenig er aussagt:
- Wie lautete der tatsächliche Wert? Der Bericht zeigt die Zeile, nicht die Zahl. Du musst
shipping_cost(0.5, "local")selbst ausführen, um zu sehen, dass das Ergebnis40war. - Was ist noch kaputt? Das Skript hat beim ersten Fehlschlag abgebrochen. Der Fall mit 1.2 kg ist ebenfalls fehlerhaft – er liefert jetzt
60statt80–, aber diese Prüfung wurde gar nicht erst ausgeführt. - Welche Versprechen hielten stand? Nichts sagt dir, ob die Fehlerfälle immer noch bestehen.
- Und die Fehlerprüfungen benötigten eine Hilfsfunktion mit
try/except, nur um auszudrücken: „Hier soll ein Fehler ausgelöst werden“.
Das Mitdenken war der schwierige Teil, und der ist erledigt: der Vertrag, die Tabelle, das Orakel. Was noch fehlt, ist ein besseres Testwerkzeug – eines, das die Prüfungen selbstständig findet, nach einem Fehlschlag trotzdem jede weitere ausführt, den tatsächlichen Wert neben dem erwarteten anzeigt und Fehlerfälle in einer einzigen Zeile prüft. Das ist pytest, und die Installation ist das Thema des nächsten Kapitels.
Wenn es schiefgeht
Dies sind Planungsfehler und keine Python-Fehler, daher sind die Symptome unauffälliger: meist eine Prüfung, die besteht, obwohl sie fehlschlagen sollte.
Der Erwartungswert wiederholt die Formel des Codes Die Prüfung wird geschrieben, indem dieselbe Rechnung durchgeführt wird wie in der Funktion. Hier enthält die Funktion den int-Fehler von oben – und die Prüfung ebenfalls:
def shipping_cost(weight_kg, zone):
# Local zone only, to keep the example short. Bug: int() instead of math.ceil().
return 60 + (int(weight_kg) - 1) * 20
# The expected value repeats the code's own sum, so it repeats the code's own bug.
assert shipping_cost(1.2, "local") == 60 + (int(1.2) - 1) * 20
print("passed")
# Worked out by hand from the contract: 1.2 kg is charged as 2 kg, so 60 + 20.
assert shipping_cost(1.2, "local") == 80passed
Traceback (most recent call last):
File "/home/you/shop/rederive.py", line 11, in <module>
assert shipping_cost(1.2, "local") == 80
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorSo sieht es auch aus, wenn man „die Implementierung testet“: Die Prüfung bildet nach, wie der Code arbeitet, anstatt festzuhalten, was er verspricht. Schreibe den Erwartungswert als einfache Zahl aus dem Vertrag auf.
Der Erwartungswert wurde aus der Ausgabe kopiert Du hast die Funktion ausgeführt, 20.0 gesehen und das in die Prüfung eingefügt. Sie besteht – und falls 20.0 falsch war, schützt sie nun den Bug. Ermittle jeden Erwartungswert, bevor du den Code ausführst; wenn deine Zahl und die des Codes nicht übereinstimmen, finde heraus, welche davon falsch ist, bevor du etwas aufschreibst.
Der Plan enthält keine Grenzfälle Jede Prüfung besteht, und der Fehler sitzt genau an der Grenze: Punkt fünf Uhr, genau 1 kg, Alter 12. Eine Tabelle mit ausschließlich „typischen“ Zeilen ist die übliche Ursache. Für jedes <, <=, „bis“, „ab“ und „mindestens“ im Vertrag sollte es auf jeder Seite einen Testfall geben.
Die Funktion kann von einer Prüfung nicht aufgerufen werden EOFError: EOF when reading a line bedeutet, dass die Funktion auf die Tastatur wartet; returned: None bedeutet, dass sie ihr Ergebnis ausgegeben hat, statt es zurückzugeben; eine Prüfung, die morgens besteht und abends fehlschlägt, bedeutet, dass sie die Uhr abfragt. Alle drei sind Designprobleme, die durch die Umgestaltung in einen reinen Kern und eine dünne Hülle aus Abschnitt zwei gelöst werden.
SyntaxWarning: assertion is always true, perhaps remove parentheses? Klammern um ein assert und dessen Nachricht machen daraus ein einziges Tupel, und ein nicht-leeres Tupel ist immer wahr:
def drink_price(price, hour):
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
assert (drink_price(10, 17) == 999, "happy hour price")
print("passed?!")/home/you/cafe/tuple.py:7: SyntaxWarning: assertion is always true, perhaps remove parentheses?
assert (drink_price(10, 17) == 999, "happy hour price")
passed?!999 ist offensichtlich falsch, und die Prüfung hat bestanden. Schreibe assert drink_price(10, 17) == 999, "happy hour price" – ohne Klammern –, und sie schlägt wie erwartet fehl. Python warnt dich hier; lies die Warnung, anstatt einfach darüber hinwegzuscrollen.
Eine Prüfung, die Dezimalergebnisse mit == vergleicht, schlägt bei einem korrekten Ergebnis fehl 0.1 + 0.2 == 0.3 ist in Python False, weil 0.1 + 0.2 gleich 0.30000000000000004 ist. Die Lehre für die Planung lautet, im Vertrag festzulegen, wie Ergebnisse gerundet werden, so wie es member_discount und drink_price tun. Das pytest-Werkzeug zum Vergleichen von Floats folgt in Kapitel fünf.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Die Anforderung: „Bestellungen von 500 oder mehr werden versandkostenfrei geliefert.“ Was passiert, wenn dieses Skript ausgeführt wird?
def free_shipping(total):
return total > 500
assert free_shipping(800) is True
assert free_shipping(100) is False
print("checks passed")
assert free_shipping(500) is True
print("all done")- A`checks passed` wird ausgegeben, dann ein `AssertionError` beim letzten assert
- BEin `AssertionError` beim ersten assert, und es wird nichts ausgegeben
- CNichts passiert — asserts werden in einem Skript ignoriert
- D`checks passed` und danach `all done` werden ausgegeben
Welche Funktion lässt sich so, wie sie ist, mit einem einzeiligen assert zuverlässig testen?
- Adef is_weekend(weekday): return weekday >= 5
- Bdef greet(): print('Hi', input())
- Cdef log_visit(): VISITS.append(1)
- Ddef is_weekend(): return datetime.now().weekday() >= 5
Diese Prüfung soll die Regel „1.2 kg werden als 2 kg berechnet“ absichern. Was stimmt damit nicht?
from shipping import shipping_cost
expected = shipping_cost(1.2, "local")
assert shipping_cost(1.2, "local") == expected- AEs sollte `is` statt `==` verwendet werden
- BDie Funktion muss innerhalb von `try`/`except` aufgerufen werden
- CDer Erwartungswert stammt aus dem zu testenden Code, sodass die Prüfung niemals fehlschlagen kann
- D`1.2` ist kein Grenzwert, daher lohnt es sich nicht, ihn zu testen
Zum Antworten brauchst du ein Konto
Melde dich an, um deine Antworten zu prüfen
Die Fragen stehen oben, und sie im Kopf durchzuarbeiten ist der wichtige Teil. Melde dich an, um die Antworten, die Erklärungen und die dreistufigen Hinweise zu sehen.
Jetzt bist du dran
Ein Kino schickt dir diese Anforderung:
„Kinder zahlen weniger, Rentner erhalten einen Rabatt und Babys sind umsonst. Erwachsene zahlen den vollen Preis, 300.“
Führe alle Schritte aus diesem Kapitel der Reihe nach durch, bevor du eine einzige Prüfung schreibst:
- Stelle die Fragen. Schreibe mindestens fünf Punkte auf, die der Satz offen lässt – was „Kinder“ bedeutet, ab wann man als „Rentner“ gilt, ob die Grenzwerte eingeschlossen sind, wie hoch die Preise sind und was bei einem unmöglichen Alter passiert. Lege dann die Antworten fest, wie es das Kino tun würde: unter 3 Jahren kostenlos; 3 bis 12 Jahre zahlen 150; 13 bis 64 Jahre zahlen 300; ab 65 Jahren zahlt man 200; das Alter reicht von 0 bis 120, und alles außerhalb davon löst einen
ValueErroraus. - Schreibe den Vertrag als Docstring einer Funktion
ticket_price(age)incinema.py. - Mache die Funktion testbar: Argumente hinein, Rückgabewert heraus, kein
input()und keinprint(). - Schreibe die Testfalltabelle: Fall, Eingabe, Erwartet, Warum. Finde zuerst die Äquivalenzklassen und platziere dann einen Fall auf beiden Seiten jeder Grenze. Leite jeden Erwartungswert aus dem Vertrag ab.
- Entscheide, was du nicht testen wirst, und begründe, warum.
- Schreibe
check_cinema.pymit einem einfachenassertpro Zeile und führe die Datei aus.
Baue danach absichtlich einen Fehler in die Funktion ein: Ändere die Kinder-Regel von „12 und jünger“ in „unter 12“ – von <= zu <. Sage vor der Ausführung voraus, welche Prüfung fehlschlagen wird. Führe sie aus und vergleiche.
Lösung
1. Die Fragen und die festgelegten Antworten:
| Frage | Antwort | | --- | --- | | Bis zu welchem Alter ist ein Baby kostenlos? | Alter 0, 1 und 2. Ab 3 kostet ein Ticket Geld. | | Was ist ein „Kind“? | Alter 3 bis 12, beide inklusive. Preis 150. | | Ist ein 13-Jähriger ein Kind? | Nein – 13 zahlt den Erwachsenenpreis, 300. | | Ab wann gilt man als Rentner? Ist 65 selbst eingeschlossen? | 65 und älter. Preis 200. | | Was ist ein unmögliches Alter? | Unter 0 oder über 120: ValueError. | | Was kommt zurück? | Der Preis als ganze Zahl; nichts wird ausgegeben. |
2 und 3. Der Vertrag und die Funktion, cinema.py. Die Grenzen sind benannte Konstanten, sodass sich der Code wie die Tabelle liest:
FREE_UNDER = 3 # ages 0-2 go free
CHILD_UP_TO = 12 # ages 3-12 pay the child price
SENIOR_FROM = 65 # ages 65 and over pay the senior price
MAX_AGE = 120
def ticket_price(age):
"""Return the price of one cinema ticket, as a whole number.
- age: a whole number of years, 0 to 120. Outside that range: ValueError.
- 0-2: 0 (free), 3-12: 150, 13-64: 300, 65 and over: 200.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if not 0 <= age <= MAX_AGE:
raise ValueError(f"age must be 0-{MAX_AGE}, got {age}")
if age < FREE_UNDER:
return 0
if age <= CHILD_UP_TO:
return 150
if age < SENIOR_FROM:
return 300
return 2004. Die Testfalltabelle. Die Klassen lauten: ungültig (unter 0), kostenlos (0–2), Kind (3–12), Erwachsener (13–64), Senior (65–120), ungültig (über 120). Das ergibt vier typische Fälle, acht Grenzfälle und zwei ungültige:
| Fall | Eingabe | Erwartet | Warum | | --- | --- | --- | --- | | Typisches Baby | 1 | 0 | Klasse kostenlos | | Typisches Kind | 8 | 150 | Klasse Kind | | Typischer Erwachsener | 30 | 300 | Klasse Erwachsener | | Typischer Senior | 80 | 200 | Klasse Senior | | Neugeborenes | 0 | 0 | niedrigstes gültiges Alter | | Letztes kostenloses Alter | 2 | 0 | unterhalb der Grenze kostenlos/Kind | | Erstes Kindesalter | 3 | 150 | genau auf der Grenze kostenlos/Kind | | Letztes Kindesalter | 12 | 150 | „12 und jünger“ – die Grenze selbst | | Erstes Erwachsenenalter | 13 | 300 | knapp hinter der Kindergrenze | | Letztes Erwachsenenalter | 64 | 300 | unterhalb der Seniorengrenze | | Erstes Seniorenalter | 65 | 200 | „ab 65“ – die Grenze selbst | | Höchstes gültiges Alter | 120 | 200 | Obergrenze des gültigen Bereichs | | Unterhalb des Bereichs | -1 | ValueError | knapp außerhalb, untere Seite | | Über dem Bereich | 121 | ValueError | knapp außerhalb, obere Seite |
5. Nicht getestet: ein String oder None für das Alter – der Vertrag spricht von einer ganzen Zahl und verspricht sonst nichts. Ein Alter mit Nachkommastellen wie 12.5 – ebenso. Die Preise anderer Kinos oder Pythons <= – nicht unsere Aufgabe. Und ein zweiter typischer Fall in derselben Klasse wie 9 neben 8 – er würde nichts Neues aufdecken.
6. Die Prüfungen, check_cinema.py:
from cinema import ticket_price
def raises_value_error(age):
"""True if ticket_price raises ValueError for this age."""
try:
ticket_price(age)
except ValueError:
return True
return False
# One typical age from each class.
assert ticket_price(1) == 0
assert ticket_price(8) == 150
assert ticket_price(30) == 300
assert ticket_price(80) == 200
# Both sides of every edge between classes.
assert ticket_price(0) == 0
assert ticket_price(2) == 0
assert ticket_price(3) == 150
assert ticket_price(12) == 150
assert ticket_price(13) == 300
assert ticket_price(64) == 300
assert ticket_price(65) == 200
assert ticket_price(120) == 200
# Invalid input, just outside the valid range on each side.
assert raises_value_error(-1)
assert raises_value_error(121)
print("all 14 checks passed")$ python check_cinema.py
all 14 checks passedDer absichtliche Fehler: Aus if age <= CHILD_UP_TO: wird if age < CHILD_UP_TO:. Nur das Alter 12 wechselt die Klasse – es fällt auf den Erwachsenenpreis durch –, also sollte nur die Prüfung für 12 fehlschlagen:
$ python check_cinema.py
Traceback (most recent call last):
File "/home/you/cinema/check_cinema.py", line 23, in <module>
assert ticket_price(12) == 150
^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorZeile 23 ist assert ticket_price(12) == 150, genau wie vorhergesagt.
Warum es so gemacht wird:
- Die Fragen kamen zuerst. „Kinder“ und „Rentner“ sind keine Zahlen; jede Antwort in der ersten Tabelle hat ein Wort in eine Grenze verwandelt, die du testen kannst. Ohne sie wären
12und65bloße Vermutungen gewesen. - Die Funktion ist rein. Alter rein, Preis raus, sonst nichts – jede Prüfung ist also eine einzige Zeile und liefert zu jeder Tageszeit auf jedem Rechner dasselbe Ergebnis.
- Benannte Konstanten für die Grenzen.
CHILD_UP_TO = 12drückt an einer einzigen Stelle aus, was im Vertrag steht, und die Prüfungen nennen dieselben Zahlen. - Die Grenzfall-Zeilen haben die Arbeit erledigt. Die vier typischen Alterswerte – 1, 8, 30, 80 – bestehen trotz des eingebauten Fehlers alle weiterhin. Nur die Zeile für
12, die Grenze selbst, hat ihn aufgedeckt. Das ist das gesamte Argument für Grenzwerte in einem einzigen Durchlauf. - Jeder Erwartungswert stammte aus dem Vertrag. Keiner davon wurde durch Ausführen von
ticket_pricekopiert, sodass sich kein falsches Ergebnis des Codes in die Prüfungen einschleichen konnte. - Es stoppte beim ersten Fehlschlag und nannte nur die Zeile. Du wusstest, welche Prüfung fehlschlug, weil du es vorhergesagt hast – aber die Ausgabe verriet dir nicht, dass der tatsächliche Preis für das Alter 12 bei 300 lag. Das ist die Lücke, die das nächste Kapitel schließt.
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Melde dich an, um das Quiz zu machen