Testdesign und TDD — was einen guten Test ausmacht
Einen Test vor dem Schreiben planen, Arrange-Act-Assert, Verhalten statt Implementierung testen, Red-Green-Refactor, Property-based Testing mit Hypothesis, Ursachen und Heilmittel für flaky Tests und ein Regressionstest für jeden Bug.
Wie möchtest du lernen?
Das Problem, das wir lösen
Du kennst jetzt jedes Werkzeug, das dieser Kurs vermitteln wollte: assert, raises, parametrize, Fixtures, Mocks, Marker, Konfiguration, Coverage, Plugins. Und es ist durchaus möglich, alle davon zu benutzen und trotzdem Tests zu haben, die mehr schaden als nützen.
Hier ist ein kleiner Warenkorb mit zwei Tests, die bestehen:
class Cart:
def __init__(self):
self._items = []
def add(self, name, price, quantity=1):
self._items.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self._items)from cart import Cart
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart._items == [("pen", 15.0, 2)]
def test_total():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0.. [100%]
2 passed in 0.01sJetzt verbessert jemand den Warenkorb. Wenn man denselben Artikel zweimal hinzufügt, soll er zu einer Zeile zusammengefasst werden, also wird die Liste zu einem Dictionary, das über den Namen geschlüsselt ist. Der Warenkorb fügt weiterhin Dinge hinzu und summiert sie weiterhin richtig — nichts, was ein Kunde bemerken könnte, hat sich geändert:
class Cart:
def __init__(self):
self._lines = {}
def add(self, name, price, quantity=1):
_, already = self._lines.get(name, (price, 0))
self._lines[name] = (price, already + quantity)
def total(self):
return sum(price * quantity for price, quantity in self._lines.values())F. [100%]
=================================== FAILURES ===================================
___________________________________ test_add ___________________________________
def test_add():
cart = Cart()
cart.add("pen", 15.0, 2)
> assert cart._items == [("pen", 15.0, 2)]
^^^^^^^^^^^
E AttributeError: 'Cart' object has no attribute '_items'
test_cart.py:7: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_add - AttributeError: 'Cart' object has no attribut...
1 failed, 1 passed in 0.01sEin roter Test, und kein Bug. Der Test hat nicht geprüft, was der Warenkorb tut; er hat geprüft, wie der Warenkorb aufgebaut ist. Ein Test wie dieser schlägt fehl, wenn du den Code verbesserst, und bleibt still, wenn du etwas kaputt machst — das genaue Gegenteil seiner Aufgabe.
Dieses letzte Kapitel handelt nicht von einem weiteren Werkzeug. Es geht um Urteilsvermögen: wie ein guter Test aussieht, wie Tests den Code lenken, wie du den Computer Testfälle für dich erfinden lässt und wie verhindert wird, dass Tests zufällig fehlschlagen.
Am Ende dieses Kapitels kannst du
- Einen Test planen, bevor du ihn schreibst: den Vertrag, die Fälle und was du weglässt
- Einen Test als Arrange, Act, Assert strukturieren und ihn wie einen Satz benennen
- Einen Test des Verhaltens von einem Test der Implementierung unterscheiden und den zweiten in den ersten umschreiben
- In Red-Green-Refactor-Zyklen arbeiten, mit einem echten Fehlschlag bei jedem Red-Schritt
- Property-based Tests mit Hypothesis schreiben und ein geschrumpftes fehlschlagendes Beispiel lesen
- Die vier üblichen Ursachen für flaky Tests finden und beheben
- Jeden Bug-Report in einen Regressionstest verwandeln
Voraussetzungen: Async Tests, Hooks und Plugins.
Bevor du den Test schreibst
Die meisten schlechten Tests sind nicht schlecht geschrieben. Sie sind schlecht geplant — geschrieben, bevor jemand entschieden hat, was der Code verspricht. Also vier Fragen, bevor es irgendeinen Testcode gibt.
Durch dieses Kapitel bauen wir eine kleine Funktion test-first: slugify, die einen Beitragstitel wie "Café au lait" in den URL-Teil "cafe-au-lait" verwandelt.
1. Was ist der Vertrag? Sag es in ein oder zwei Sätzen, so wie der Aufrufer es sieht: Gegeben ein Titel (ein `str`), gib einen Slug zurück: nur ASCII-Kleinbuchstaben und Ziffern, Wörter durch einzelne Bindestriche verbunden, kein Bindestrich an einem der Enden. Akzentbuchstaben werden zu ihrem einfachen Buchstaben. Keine Seiteneffekte — die Funktion berührt keine Datei, keine Uhr und kein Netzwerk. Beachte, was der Vertrag nicht erwähnt: reguläre Ausdrücke, Unicode-Normalisierung, Hilfsfunktionen. Das ist die Umsetzung, und die darf sich ändern.
2. Was muss vorhanden sein? Eine virtuelle Umgebung mit den installierten Tools und dem Code, der aus den Tests importierbar ist:
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install pytest hypothesisDann zwei Dateien nebeneinander — slugs.py und test_slugs.py — und pytest aus diesem Ordner ausgeführt, damit from slugs import slugify funktioniert. Dieses Thema braucht keine Fixtures, keine temporären Dateien und keine Umgebungsvariablen. Auch das ist bemerkenswert: Eine reine Funktion ist die einfachste Sache der Welt zu testen, und das ist ein guter Grund, Logik in reine Funktionen zu verlagern.
3. Welche Fälle? Zuerst der Normalfall, dann die Randfälle, dann ungültige Eingaben, dann die Grenzwerte:
| Fall | Eingabe | Erwartet | |---|---|---| | gewöhnliche Wörter | "Hello World" | "hello-world" | | Satzzeichen | "Hello, World!" | "hello-world" | | Akzentbuchstaben | "Café au lait" | "cafe-au-lait" | | Leerzeichen an den Enden | " Hello World " | "hello-world" | | Ziffern bleiben erhalten | "Top 10 Tips" | "top-10-tips" | | nichts Brauchbares | "!!!" | ? |
Das Fragezeichen ist die nützlichste Zelle der Tabelle. Beim Planen ist eine Entscheidung aufgetaucht, die noch niemand getroffen hat: Was soll passieren, wenn ein Titel überhaupt keine Buchstaben enthält? Wir lassen das absichtlich offen und sehen später in diesem Kapitel, was eine offene Frage kostet.
4. Was testest du nicht? Das Modul re, unicodedata und str.lower() haben bereits eigene Tests; sie noch einmal zu testen, kostet dich nur Zeit. Private Hilfsfunktionen (alles, was mit _ beginnt) werden über slugify erreicht, niemals direkt. Und du wirst nicht versuchen, jedes Unicode-Zeichen von Hand aufzulisten — dafür gibt es einen Property-based Test, weiter unten in diesem Kapitel.
Die Tabelle wird zu den Tests. Alles Folgende setzt diesen Plan um.
Arrange, Act, Assert
Jeder gute Test hat dieselben drei Teile in derselben Reihenfolge:
- Arrange — die Welt aufbauen, die der Test braucht
- Act — die eine Sache ausführen, die getestet wird
- Assert — prüfen, was herausgekommen ist
Hier wird der Warenkorb noch einmal getestet — dieses Mal über das, was er verspricht, nicht darüber, wie er Dinge speichert:
from cart import Cart
def test_an_empty_cart_totals_zero():
cart = Cart()
assert cart.total() == 0
def test_total_is_price_times_quantity_summed_over_items():
# Arrange
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
# Act
total = cart.total()
# Assert
assert total == 880.0
def test_adding_the_same_item_twice_adds_up_the_quantity():
cart = Cart()
cart.add("pen", 15.0, 2)
cart.add("pen", 15.0, 1)
assert cart.total() == 45.0Gegen die listenbasierte Variante und dann noch einmal gegen die dictionarybasierte:
... [100%]
3 passed in 0.01s
... [100%]
3 passed in 0.01sDieselben Tests, zwei Implementierungen, beide Male alles grün. Das ist die Eigenschaft, die du willst: Ein Refactoring, das das Verhalten beibehält, sollte die Tests grün halten. Die Kommentare im mittleren Test sind nur da, um die Struktur zu zeigen; die meisten Leute lassen sie weg und trennen die drei Teile durch Leerzeilen, wie im dritten Test.
Warum ist es wichtig, dass der Act ein einzelner Schritt ist? Weil du, wenn der Test fehlschlägt, wissen willst, welche Sache kaputt ist. Wenn der Act aus fünf Aufrufen besteht, zeigt ein Fehlschlag auf fünf Verdächtige.
Verhalten testen, nicht Implementierung
Faustregel: Ein Test darf nur verwenden, was ein Aufrufer verwenden darf. Beim Warenkorb sind das Cart(), .add() und .total(). Nicht _items, nicht _lines. Der führende Unterstrich ist Pythons Art zu sagen: „Das ist mein Inneres, und ich darf es ändern.“
Der falsche Weg koppelt den Test an eine Entscheidung, die nie Teil des Vertrags war — dass Artikel in einer Liste gehalten werden. Der richtige Weg stellt die Frage, die ein Aufrufer stellen würde: Wenn ich diese Dinge hinzufüge, wie hoch ist die Summe?
Zwei Anzeichen, dass ein Test an die Implementierung gekoppelt ist:
- Er liest Attribute, die mit
_beginnen, oder mockt eine Funktion, die der Code intern aufruft - Ein Refactoring, das kein Benutzer bemerken könnte, lässt ihn rot werden
Zu Mocks eine Anmerkung. Die Mocks aus Kapitel elf sind das richtige Werkzeug an einer Grenze — dem Netzwerk, der Uhr, einem Zahlungsdienst. Innerhalb des eigenen Codes („prüfe, dass _calculate mit diesen Argumenten aufgerufen wurde“) werden sie zu Implementierungstests unter anderem Namen.
Namen, die wie Sätze klingen, und ein Grund zu fehlschlagen
Führe die drei Warenkorb-Tests mit -v aus:
test_cart_behaviour.py::test_an_empty_cart_totals_zero PASSED [ 33%]
test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items PASSED [ 66%]
test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity PASSED [100%]Lies nur die Namen, und du hast eine Spezifikation des Warenkorbs. test_add und test_total haben dir nur gesagt, welche Methode berührt wurde. Ein guter Name nennt die Situation und das erwartete Ergebnis: ein leerer Warenkorb ergibt null. Er ist lang; das ist in Ordnung. Du tippst ihn nie, aber du liest ihn jedes Mal, wenn der Test fehlschlägt.
Die zweite Hälfte der Regel ist ein Grund zu fehlschlagen. Hier das Gegenteil — ein Test, der alles auf einmal prüft, ausgeführt gegen einen Warenkorb mit einem Fehler (er ignoriert quantity):
from cart import Cart
def test_cart():
cart = Cart()
assert cart.total() == 0
cart.add("pen", 15.0, 2)
cart.add("bag", 850.0)
assert cart.total() == 880.0
cart.add("pen", 15.0, 1)
assert cart.total() == 895.0$ pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_one_big.py::test_cart - assert 865.0 == 880.0
1 failed in 0.01s(--tb=no blendet die Tracebacks aus und lässt nur die Zusammenfassung stehen — den Teil, den du zuerst liest.) test_cart ist fehlgeschlagen — was nichts aussagt — mit 865.0 == 880.0, worüber du jetzt rätseln musst. Und die dritte Assertion wurde nie ausgeführt, also weißt du nicht, ob sie bestanden hätte. Derselbe Fehler gegen die drei fokussierten Tests:
$ pytest -q --tb=no
.FF [100%]
=========================== short test summary info ============================
FAILED test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items
FAILED test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity
2 failed, 1 passed in 0.01sBevor du einen einzigen Traceback öffnest, liest sich die Zusammenfassung bereits wie eine Diagnose: Der leere Warenkorb ist in Ordnung, aber die Mengen laufen falsch. „Ein Grund zu fehlschlagen“ heißt nicht eine assert-Zeile — zwei Assertions, die zwei Seiten desselben Ergebnisses prüfen, sind in Ordnung. Es heißt ein Verhalten pro Test.
Die Test-Pyramide
Tests gibt es in verschiedenen Größen:
- Unit-Tests prüfen eine Funktion oder Klasse, ohne echte Abhängigkeiten drumherum. Jeder dauert Millisekunden. Du hast Tausende.
- Integrationstests prüfen, dass Teile zusammenpassen: dein Code gegen eine echte Datenbank, ein echtes Dateisystem, eine echte HTTP-App über einen Test-Client. Langsamer; du hast Dutzende bis Hunderte.
- End-to-End-Tests steuern das ganze System so, wie ein Benutzer es tun würde — ein Browser, eine deployte API. Langsam und fragil; du hast eine Handvoll, die die Pfade abdecken, mit denen Geld verdient wird.
Nach der Anzahl gezählt ergibt das eine Pyramide: eine breite Basis aus Unit-Tests, eine dünne Spitze aus End-to-End-Tests. Der Grund sind die Kosten. Wenn ein Unit-Test fehlschlägt, zeigt er auf ein paar Zeilen. Wenn ein End-to-End-Test fehlschlägt, zeigt er auf das ganze System. Schiebe jede Prüfung so weit nach unten in der Pyramide, wie sie gehen kann, und behalte die Spitze für das, was nur die Spitze sehen kann.
Red, Green, Refactor
Testgetriebene Entwicklung (Test-Driven Development, TDD) läuft in einer engen Schleife:
- Red — schreib einen kleinen Test für ein Verhalten, das noch nicht existiert, und sieh zu, wie er fehlschlägt
- Green — schreib den minimalen Code, der ihn bestehen lässt
- Refactor — räum den Code auf, während die Tests durchgehend grün bleiben
Warum zusehen, wie er fehlschlägt? Weil ein Test, den du nie hast fehlschlagen sehen, möglicherweise gar nichts testet. Der Red-Schritt beweist, dass der Test das Fehlen der Funktion erkennen kann.
Wir nehmen den Plan aus der Tabelle, eine Zeile nach der anderen.
Red. Die erste Zeile, bevor slugs.py existiert:
from slugs import slugify
def test_lowercases_and_joins_words_with_hyphens():
assert slugify("Hello World") == "hello-world"==================================== ERRORS ====================================
________________________ ERROR collecting test_slugs.py ________________________
ImportError while importing test module '/home/you/blog/test_slugs.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_slugs.py:1: in <module>
from slugs import slugify
E ModuleNotFoundError: No module named 'slugs'
=========================== short test summary info ============================
ERROR test_slugs.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sDas ist ein völlig in Ordnung roter Test. Der Test verlangt etwas, das nicht da ist.
Green. Der minimale Code, der besteht — und er ist wirklich der minimalste:
def slugify(title):
return title.lower().replace(" ", "-"). [100%]
1 passed in 0.01sEs fühlt sich wie Schummeln an. Das ist Absicht: Der Code tut nur, was ein Test bisher verlangt hat. Alles darüber hinaus wäre Code, den kein Test prüft.
Red. Die zweite Zeile der Tabelle:
def test_drops_punctuation():
assert slugify("Hello, World!") == "hello-world".F [100%]
=================================== FAILURES ===================================
____________________________ test_drops_punctuation ____________________________
def test_drops_punctuation():
> assert slugify("Hello, World!") == "hello-world"
E AssertionError: assert 'hello,-world!' == 'hello-world'
E
E - hello-world
E + hello,-world!
E ? + +
test_slugs.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_drops_punctuation - AssertionError: assert 'hello,...
1 failed, 1 passed in 0.01sDie ?-Zeile markiert genau die beiden Zeichen, die nicht da sein sollten.
Green. Jetzt reicht das Ersetzen eines Leerzeichens nicht mehr. Ersetze jede Folge von „kein Buchstabe und keine Ziffer“ durch einen Bindestrich und entferne danach die Bindestriche an den Enden:
import re
def slugify(title):
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-").. [100%]
2 passed in 0.01sDie Zeilen für Leerzeichen und Ziffern würden mit diesem Code schon bestehen. Schreib sie trotzdem hin — sie sind Teil des Vertrags —, aber wisse, dass ein Test, der beim ersten Lauf grün ist, nichts über den Code bewiesen hat, den du gerade geschrieben hast. Nur ein Test, der erst rot und dann grün war, hat das.
Red. Akzentbuchstaben:
def test_turns_accented_letters_into_plain_ones():
assert slugify("Café au lait") == "cafe-au-lait"..F [100%]
=================================== FAILURES ===================================
_________________ test_turns_accented_letters_into_plain_ones __________________
def test_turns_accented_letters_into_plain_ones():
> assert slugify("Café au lait") == "cafe-au-lait"
E AssertionError: assert 'caf-au-lait' == 'cafe-au-lait'
E
E - cafe-au-lait
E ? -
E + caf-au-lait
test_slugs.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_turns_accented_letters_into_plain_ones - Assertion...
1 failed, 2 passed in 0.01sé ist nicht in a-z, also wurde es zusammen mit den Satzzeichen weggeworfen.
Green. Die Unicode-„NFKD“-Normalisierung zerlegt é in e plus ein separates Akzentzeichen; die Kodierung nach ASCII mit "ignore" entfernt das Akzentzeichen und behält das e:
import re
import unicodedata
def slugify(title):
plain = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "-", plain.lower()).strip("-")... [100%]
3 passed in 0.01sRefactor. Es funktioniert, aber der Körper ist eine dichte Zeile. Gib jedem Schritt einen Namen, kompiliere das Muster einmal und ergänze Typhinweise — ohne das Verhalten zu ändern:
import re
import unicodedata
NOT_ALLOWED = re.compile(r"[^a-z0-9]+")
def _to_ascii(text: str) -> str:
"""Split accented letters into letter + accent, then drop the accents."""
decomposed = unicodedata.normalize("NFKD", text)
return decomposed.encode("ascii", "ignore").decode("ascii")
def slugify(title: str) -> str:
words = NOT_ALLOWED.sub("-", _to_ascii(title).lower())
return words.strip("-")test_slugs.py::test_lowercases_and_joins_words_with_hyphens PASSED [ 33%]
test_slugs.py::test_drops_punctuation PASSED [ 66%]
test_slugs.py::test_turns_accented_letters_into_plain_ones PASSED [100%]
============================== 3 passed in 0.01s ===============================Das ist der Schritt, bei dem sich TDD auszahlt. Du konntest frei umbauen, weil die Tests das Verhalten prüfen; hätten sie in _to_ascii oder den regulären Ausdruck hineingegriffen, hätte das Refactoring sie kaputt gemacht.
Property-based Testing mit Hypothesis
Jeder Test bisher hat ein erfundenes Beispiel: "Hello World", "Café au lait". Die Beispiele sind nur so gut wie deine Fantasie, und deine Fantasie teilt ihre blinden Flecken mit dem Code, den du gerade geschrieben hast.
Eine Property ist eine Aussage, die für jede Eingabe gilt. Hypothesis erzeugt die Eingaben — standardmäßig hundert pro Test — und versucht hart, eine zu finden, die die Aussage bricht. @given legt fest, woher die Eingaben kommen; strategies (immer als st importiert) beschreibt ihre Form:
from hypothesis import given
from hypothesis import strategies as st
@given(
prices=st.lists(st.integers(min_value=0, max_value=10_000), max_size=20),
discount=st.integers(min_value=0, max_value=100),
)
def test_a_discount_never_raises_the_total(prices, discount):
total = sum(prices)
discounted = total * (100 - discount) // 100
assert 0 <= discounted <= total. [100%]
1 passed in 0.01sEin Punkt, aber hundert Listen mit Preisen und hundert Rabatte sind durchgelaufen. Weitere Strategien, zu denen du greifen wirst: st.text(), st.floats(), st.booleans(), st.sampled_from([...]), st.dictionaries(...), st.builds(...).
Welche Properties hat slugify? Du kannst nicht sagen, wie der Slug eines zufälligen Strings aussieht, aber du kannst sagen, wie er aussehen muss, und dass ein Slug zu slugifizieren nichts ändert:
import re
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
@given(st.text())
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*|", slug)
@given(st.text())
def test_slugifying_a_slug_changes_nothing(title):
once = slugify(title)
assert slugify(once) == once.. [100%]
2 passed in 0.01sst.text() erzeugt Emojis, chinesische Zeichen, Steuerzeichen, leere Strings — Eingaben, die du nie in die Planungstabelle eingetragen hättest.
Ein Round Trip findet einen echten Bug
Die ergiebigste Property ist der Round Trip: Wenn du etwas kodieren und wieder dekodieren kannst, muss das Dekodieren der Kodierung das Original zurückgeben. Hier ist ein Lauflängen-Kodierer — "aaab" wird zu "3a1b" — mit zwei Beispieltests, die bestehen:
import re
def encode(text: str) -> str:
"""'aaab' -> '3a1b': each run of a character becomes count + character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
return "".join(char * int(count) for count, char in re.findall(r"(\d+)(\D)", encoded))from hypothesis import given
from hypothesis import strategies as st
from rle import decode, encode
def test_encode_counts_each_run():
assert encode("aaab") == "3a1b"
def test_decode_expands_each_run():
assert decode("3a1b") == "aaab"
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text..F [100%]
=================================== FAILURES ===================================
______________ test_decoding_an_encoding_gives_back_the_original _______________
@given(st.text())
> def test_decoding_an_encoding_gives_back_the_original(text):
^^^
test_rle.py:16:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
text = '0'
@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
> assert decode(encode(text)) == text
E AssertionError: assert '' == '0'
E
E - 0
E Failing test case: test_decoding_an_encoding_gives_back_the_original(
E text='0',
E )
test_rle.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_rle.py::test_decoding_an_encoding_gives_back_the_original - Asser...
1 failed, 2 passed in 0.01sDer Text "0" kodiert zu "10" — „eine Null“ — und der Decoder liest 10 als Anzahl ohne folgendes Zeichen. Jeder Text mit einer Ziffer wird verfälscht. Beide Beispieltests haben nur Buchstaben verwendet, also konnten sie es nie sehen.
Shrinking
Hypothesis ist nicht zuerst auf "0" gestoßen. Es fand irgendeinen langen, hässlichen String und schrumpfte ihn dann: Es probierte kleinere und einfachere Eingaben, behielt jede, die noch fehlschlug, bis nichts Einfacheres mehr fehlschlug. Du kannst das beobachten, indem du jede fehlschlagende Eingabe aufzeichnest:
from hypothesis import given, seed, settings
from hypothesis import strategies as st
from rle import decode, encode
failing = []
@seed(2026)
@settings(database=None)
@given(st.text())
def round_trip(text):
if decode(encode(text)) != text:
if text not in failing:
failing.append(text)
raise AssertionError
try:
round_trip()
except AssertionError:
pass
for text in failing:
print(repr(text))'wfê\x9f\x0c\U000b6082Ñ/\x879\x9dÂ\n'
'\x03\U000a1acb\U0003c4fc1'
'´\U000cd3dc\U00081acfÔ÷\U0007122f\x89ê½5'
'l𨺮\nÝÛ\x894\x8b'
'\U00083189𤾨«ñï\xa0^ó8A\x96\x88=\x05'
'0000'
'000'
'00'
'0'(@seed legt die Zufallsentscheidungen fest, damit dieser Lauf wiederholbar ist; database=None verhindert, dass Hypothesis einen gemerkten Fehlschlag wiederholt.) Der erste Fehlschlag ist dreizehn Zeichen Rauschen mit einer versteckten 9. Du hättest minutenlang darauf gestarrt. Das geschrumpfte Beispiel erzählt die ganze Geschichte mit einem einzigen Zeichen: eine Ziffer bricht es.
Hypothesis speichert fehlschlagende Beispiele auch in einem Ordner .hypothesis/, sodass der nächste Lauf zuerst "0" probiert. Nimm diesen Ordner in .gitignore auf.
Die Lösung setzt ein Trennzeichen zwischen Anzahl und Zeichen, sodass eine Ziffer nie als Teil einer Anzahl missverstanden werden kann. Und das Beispiel, das Hypothesis gefunden hat, wird mit @example festgenagelt, sodass es bei jedem Lauf und auf jedem Rechner ausgeführt wird:
import re
def encode(text: str) -> str:
"""'aaab' -> '3:a1:b': each run becomes count, a colon, then the character."""
out = []
for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
run = match.group(0)
out.append(f"{len(run)}:{run[0]}")
return "".join(out)
def decode(encoded: str) -> str:
pairs = re.findall(r"(\d+):(.)", encoded, flags=re.DOTALL)
return "".join(char * int(count) for count, char in pairs)Die beiden Beispieltests ändern sich auf das neue Format ("3:a1:b"), und die Property bekommt eine Zeile dazu:
from hypothesis import example, given
from hypothesis import strategies as st
from rle import decode, encode
@given(st.text())
@example("0") # the input Hypothesis found; now it is checked on every run
def test_decoding_an_encoding_gives_back_the_original(text):
assert decode(encode(text)) == text... [100%]
3 passed in 0.01sProperties ersetzen keine Beispiele. Die zwei Beispieltests dokumentieren das Format so, dass ein Leser es auf einen Blick versteht; die Property schützt die Ecken, an die niemand gedacht hat. Nimm beides.
Flaky Tests
Ein flaky Test besteht und schlägt fehl bei gleichem Code. Er ist schlimmer als gar kein Test: Die Leute gewöhnen sich an „noch mal ausführen“ und hören auf, Rot zu glauben. Fast jeder flaky Test stammt aus einer von vier Ursachen, und jede hat eine Lösung, die die Ursache beseitigt statt sie zu verstecken.
Geteilter Zustand und Testreihenfolge
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)from registry import count, register
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0$ pytest -q --tb=no
.F [100%]
=========================== short test summary info ============================
FAILED test_registry.py::test_a_new_registry_is_empty - assert 1 == 0
1 failed, 1 passed in 0.01s
$ pytest -q "test_registry.py::test_a_new_registry_is_empty"
. [100%]
1 passed in 0.01sSchlägt in der Suite fehl, besteht allein. Das Set auf Modulebene überlebt von einem Test zum nächsten, also sieht der zweite Test, was der erste hinterlassen hat. Ändere die Reihenfolge oder führe Tests parallel mit pytest-xdist aus, und das Ergebnis ändert sich. Die falsche Lösung wäre, die Tests umzusortieren. Die richtige Lösung entfernt den geteilten Zustand: Mach die Registry zu einem Objekt und gib jedem Test über eine Fixture eine frische.
class Registry:
def __init__(self):
self._users = set()
def register(self, name):
self._users.add(name)
def count(self):
return len(self._users)import pytest
from registry import Registry
@pytest.fixture
def registry():
return Registry() # a fresh one for every test
def test_registering_a_user_counts_them(registry):
registry.register("rahim")
assert registry.count() == 1
def test_a_new_registry_is_empty(registry):
assert registry.count() == 0.. [100%]
2 passed in 0.01sWenn du den Code nicht ändern kannst, ist eine autouse-Fixture, die den Zustand vor und nach jedem Test zurücksetzt, der Ausweg. Jeder Test sollte allein bestehen, und in jeder Reihenfolge.
Zeit
from datetime import datetime
def greeting(now=None):
now = now or datetime.now()
return "Good morning" if now.hour < 12 else "Good afternoon"from greeting import greeting
def test_greets_the_morning():
assert greeting() == "Good morning" # true only before noonDerselbe Code, im selben Moment ausgeführt, auf zwei Rechnern mit unterschiedlichen Zeitzonen eingestellt:
$ TZ=Europe/London pytest -q
. [100%]
1 passed in 0.01s
$ TZ=Asia/Dhaka pytest -q --tb=no
F [100%]
=========================== short test summary info ============================
FAILED test_greeting.py::test_greets_the_morning - AssertionError: assert 'Go...
1 failed in 0.01sDie Lösung steckt bereits in der Signatur der Funktion: now kann übergeben werden. Ein Test, der die Uhr kontrolliert, prüft beide Seiten der Grenze und gibt zu jeder Uhrzeit dieselbe Antwort:
from datetime import datetime
from greeting import greeting
def test_before_noon_it_says_good_morning():
assert greeting(now=datetime(2026, 1, 5, 9, 30)) == "Good morning"
def test_from_noon_on_it_says_good_afternoon():
assert greeting(now=datetime(2026, 1, 5, 12, 0)) == "Good afternoon".. [100%]
2 passed in 0.01sDie Zeit hereinzureichen ist einfacher als jeder Mock. Wenn du die Signatur nicht ändern kannst, ersetzt monkeypatch aus Kapitel zehn stattdessen die Uhr.
Zufall
import random
def pick_winner(names, rng=random):
return rng.choice(names)from raffle import pick_winner
def test_picks_a_winner():
assert pick_winner(["rahim", "karim", "salma"]) == "rahim"Sechs Läufe, keine Änderung an irgendetwas:
$ for i in 1 2 3 4 5 6; do pytest -q | tail -1; done
1 failed in 0.01s
1 failed in 0.01s
1 failed in 0.01s
1 passed in 0.01s
1 passed in 0.01s
1 passed in 0.01sZwei Lösungen, für zwei Arten von Fragen. Prüfe, was für jedes Ergebnis wahr ist — der Gewinner ist einer der Teilnehmer. Oder kontrolliere den Zufall, indem du einen Generator mit Seed übergibst:
import random
from raffle import pick_winner
def test_the_winner_is_one_of_the_entrants():
names = ["rahim", "karim", "salma"]
assert pick_winner(names) in names
def test_the_same_seed_picks_the_same_winner():
names = ["rahim", "karim", "salma"]
first = pick_winner(names, rng=random.Random(42))
second = pick_winner(names, rng=random.Random(42))
assert first == second$ for i in 1 2 3; do pytest -q | tail -1; done
2 passed in 0.01s
2 passed in 0.01s
2 passed in 0.01sDie äußere Welt
Die vierte Ursache ist alles, was du nicht kontrollierst: ein echter Netzwerkaufruf, ein echter Server, ein sleep(0.1), das „schon reichen sollte“. Die Lösungen sind die aus früheren Kapiteln — an der Grenze mocken, tmp_path statt eines gemeinsamen Ordners verwenden, auf eine Bedingung warten statt auf eine feste Zeit. Ein Muster deckt alle vier Ursachen ab: Was der Test auch braucht, der Test sollte es selbst erzeugen oder kontrollieren.
Ein Regressionstest für jeden Bug
Zurück zum Fragezeichen in der Planungstabelle. Niemand hat es beantwortet, und der Bug-Report kommt: Ein Beitrag mit dem Titel `"!!!"` wurde unter `/posts/` gespeichert und überschrieb die Indexseite.
Schreib, bevor du slugify anfasst, einen Test, der den Bericht nachstellt, und sieh zu, wie er fehlschlägt. Das beweist, dass der Test genau diesen Bug erkennt:
import pytest
from slugs import slugify
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
with pytest.raises(ValueError, match="no letters or digits"):
slugify("!!!")$ pytest -q test_slugs_regressions.py
F [100%]
=================================== FAILURES ===================================
______________ test_a_title_with_no_letters_or_digits_is_rejected ______________
def test_a_title_with_no_letters_or_digits_is_rejected():
# Bug: slugify("!!!") returned "", and the post was saved at /posts/
> with pytest.raises(ValueError, match="no letters or digits"):
E Failed: DID NOT RAISE ValueError
test_slugs_regressions.py:8: Failed
=========================== short test summary info ============================
FAILED test_slugs_regressions.py::test_a_title_with_no_letters_or_digits_is_rejected
1 failed in 0.01sDann behebe ihn — das Ende von slugify wird zu:
def slugify(title: str) -> str:
slug = NOT_ALLOWED.sub("-", _to_ascii(title).lower()).strip("-")
if not slug:
raise ValueError(f"title has no letters or digits: {title!r}")
return slug.... [100%]
4 passed in 0.01sDer Regressionstest und die drei Beispieltests bestehen. Aber führ die ganze Suite aus, und die Property-Tests protestieren:
FAILED test_slugs_properties.py::test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens
FAILED test_slugs_properties.py::test_slugifying_a_slug_changes_nothing - Val...
2 failed, 4 passed in 0.01sHypothesis hat den leeren String probiert, und slugify("") wirft jetzt einen Fehler. Das ist kein neuer Bug — es ist der Vertrag, der absichtlich geändert wurde, und die Properties bemerken es. Gut so. Aktualisiere sie, sodass sie den neuen Vertrag ausdrücken: Titel, die mindestens einen Buchstaben oder eine Ziffer enthalten.
import re
import string
from hypothesis import given
from hypothesis import strategies as st
from slugs import slugify
# Any text, with at least one plain letter or digit somewhere inside it.
titles = st.builds(
lambda before, word, after: before + word + after,
st.text(),
st.text(alphabet=string.ascii_letters + string.digits, min_size=1),
st.text(),
)
@given(titles)
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
slug = slugify(title)
assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug)test_slugifying_a_slug_changes_nothing wird auf dieselbe Weise auf @given(titles) umgestellt.
...... [100%]
6 passed in 0.01sWarum ein Test für jeden Bug? Weil ein Bug, der einmal passiert ist, bewiesen hat, dass er leicht zu machen ist, und die nächste Person, die diesen Code anfasst, macht ihn wahrscheinlich wieder. Der Kommentar, der sagt, was schiefging, ist Teil des Tests: In einem Jahr ist er der einzige Hinweis darauf, warum dieser seltsam aussehende Fall hier steht.
Ein vollständiges Beispiel
Ein Passwort-Stärke-Prüfer, gebaut mit allem aus diesem Kapitel. Zuerst der Plan:
| Fall | Eingabe | Erwartet | |---|---|---| | leer | "" | schwach | | alle Zeichenarten, zu kurz | "Ab1!xyz" (7) | schwach | | lang genug, eine Art | "abcdefgh" | schwach | | lang genug, zwei Arten | "abcdefg1" | mittel | | 12 lang, drei Arten | "abcdefghij1!" | stark | | 12 lang, zwei Arten | "abcdefghijk1" | mittel |
passwords.py:
def _kinds(password: str) -> int:
"""How many of the four kinds of character the password uses."""
return sum([
any(c.islower() for c in password),
any(c.isupper() for c in password),
any(c.isdigit() for c in password),
any(not c.isalnum() for c in password),
])
def strength(password: str) -> str:
"""Rate a password as 'weak', 'medium' or 'strong'."""
if len(password) < 8:
return "weak"
kinds = _kinds(password)
if len(password) >= 12 and kinds >= 3:
return "strong"
if kinds >= 2:
return "medium"
return "weak"test_passwords.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from passwords import strength
RANK = {"weak": 0, "medium": 1, "strong": 2}
@pytest.mark.parametrize(
"password, expected",
[
("", "weak"), # nothing at all
("Ab1!xyz", "weak"), # every kind, but only 7 long
("abcdefgh", "weak"), # 8 long, one kind
("abcdefg1", "medium"), # 8 long, two kinds
("abcdefghij1!", "strong"), # 12 long, three kinds
("abcdefghijk1", "medium"), # 12 long, only two kinds
],
)
def test_strength_follows_the_length_and_variety_rules(password, expected):
assert strength(password) == expected
@given(st.text(), st.text())
def test_adding_characters_never_makes_a_password_weaker(password, extra):
before = strength(password)
after = strength(password + extra)
assert RANK[after] >= RANK[before]
def test_a_long_password_of_one_kind_is_still_weak():
# Bug: "aaaaaaaaaaaaaaaaaaaa" (20 letters) was rated "medium"
assert strength("a" * 20) == "weak"$ pytest -v
collected 8 items
test_passwords.py::test_strength_follows_the_length_and_variety_rules[-weak] PASSED [ 12%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[Ab1!xyz-weak] PASSED [ 25%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefgh-weak] PASSED [ 37%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefg1-medium] PASSED [ 50%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghij1!-strong] PASSED [ 62%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghijk1-medium] PASSED [ 75%]
test_passwords.py::test_adding_characters_never_makes_a_password_weaker PASSED [ 87%]
test_passwords.py::test_a_long_password_of_one_kind_is_still_weak PASSED [100%]
============================== 8 passed in 0.01s ===============================Drei Entscheidungen sind bemerkenswert.
Die Tabellenzeilen liegen auf den Grenzwerten — 7 und 8 Zeichen, 11 und 12, zwei Arten und drei. Fehler sitzen an Grenzwerten: Ein <, das <= sein sollte, ist in der Mitte eines Bereichs unsichtbar.
Die Property sagt etwas, was kein Beispiel sagen kann: Zeichen hinzuzufügen macht ein Passwort nie schwächer. Niemand würde diesen Test von Hand für jedes Passwort schreiben, und Hypothesis prüft ihn gegen Hunderte, einschließlich Unicode-Buchstaben, die weder Groß- noch Kleinbuchstaben sind.
Und kein Test berührt _kinds. Es wird über strength erreicht; morgen kann es weg sein.
Wenn es kracht
AttributeError: 'Cart' object has no attribute '_items' nach einem Refactoring Der Test hat das Innere des Objekts gelesen. Schreib ihn um, sodass er über die öffentlichen Methoden prüft — was ein Aufrufer sehen kann —, dann übersteht er auch das nächste Refactoring.
Ein Test besteht allein, schlägt aber im vollen Lauf fehl (oder umgekehrt) Geteilter Zustand: eine Liste, ein Dictionary oder ein Cache auf Modulebene; eine Datei an einem festen Ort; eine Umgebungsvariable, die gesetzt und nie wieder entfernt wird. Lass jeden Test erzeugen, was er braucht (Fixtures, tmp_path, monkeypatch), statt die Tests umzusortieren.
hypothesis.errors.FailedHealthCheck: It looks like this test is filtering out a lot of inputs. 0 inputs were generated successfully, while 50 inputs were filtered out. Ein .filter() (oder assume()) wirft fast alles weg, was Hypothesis erzeugt — zum Beispiel st.integers().filter(lambda n: n % 1000 == 7). Erzeuge stattdessen direkt die Werte, die du willst: st.integers().map(lambda n: n * 1000 + 7).
hypothesis.errors.FlakyFailure: Hypothesis test_depends_on_earlier_runs(n=-25617) produces unreliable results: Failed on the first call but did not on a subsequent one Der Test hat für dieselbe Eingabe ein unterschiedliches Ergebnis geliefert. Etwas außerhalb der generierten Argumente hat sich zwischen den Aufrufen geändert — ein Zähler, eine Liste auf Modulebene, die Uhr. Hypothesis wiederholt jeden Fehlschlag, also kann ein Test mit verstecktem Zustand nicht schrumpfen.
hypothesis.errors.DeadlineExceeded: Test took 300.07ms, which exceeds the deadline of 200.00ms. Jedes erzeugte Beispiel muss standardmäßig in 200 ms fertig sein, denn hundert langsame Beispiele machen eine langsame Suite. Mach den Test schneller oder, wenn die Langsamkeit echt ist, hebe die Grenze mit @settings(deadline=...) an.
Ein neuer TDD-Test besteht beim ersten Lauf Er beweist nichts über den Code, den du gleich schreiben willst. Entweder existiert das Verhalten schon (in Ordnung — behalte den Test als Dokumentation), oder der Test prüft nicht, was du denkst. Brich den Code kurz absichtlich und bestätige, dass der Test rot wird.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Als ganze Datei ausgeführt, schlägt der zweite Test fehl. Was steht in der letzten Zeile, wenn nur der zweite Test allein läuft?
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0
# Run only the second test:
# pytest -q "test_registry.py::test_a_new_registry_is_empty"- A1 failed, 1 passed in 0.01s
- B1 failed in 0.01s
- C1 error in 0.01s
- D1 passed in 0.01s
Cart wechselt intern von einer Liste zu einem Dictionary, und sein Verhalten bleibt gleich. Welcher Test wird rot?
def test_total_of_two_pens():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart.total() == 30.0
def test_empty_cart_totals_zero():
assert Cart().total() == 0
def test_one_line_per_item():
cart = Cart()
cart.add("pen", 15.0)
assert len(cart._items) == 1
def test_adding_twice_adds_up():
cart = Cart()
cart.add("pen", 15.0)
cart.add("pen", 15.0)
assert cart.total() == 30.0- A`test_empty_cart_totals_zero`
- B`test_one_line_per_item`
- C`test_total_of_two_pens`
- D`test_adding_twice_adds_up`
Dieser Test besteht von Montag bis Freitag und schlägt am Samstag und Sonntag fehl. Was ist die beste Lösung?
from datetime import date
def is_weekend(day=None):
day = day or date.today()
return day.weekday() >= 5
def test_weekdays_are_not_weekend():
assert is_weekend() is False- AEin festes Datum übergeben — `is_weekend(date(2026, 10, 7))` — und einen eigenen Test für einen Samstag hinzufügen
- B`== False` statt `is False` schreiben
- CDen Test bei einem Fehlschlag automatisch erneut ausführen
- DDen Test samstags und sonntags überspringen
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
Schreib zwei Funktionen, test-first, in durations.py:
parse_duration(text)wandelt"1h30m"in5400(Sekunden) um. Die Einheiten sindh,m,s, in dieser Reihenfolge, jede optional;"45s","2h"und"1h0m5s"sind alle gültig. Alles andere —"","h","1x","30m1h","1.5h"— wirftValueErrormitnot a durationin der Meldung.format_duration(seconds)geht in die andere Richtung:5400wird zu"1h30m",3605wird zu"1h5s",0wird zu"0s". Eine negative Zahl wirftValueError.
Arbeite in dieser Reihenfolge:
- Schreib zuerst die Planungstabelle: Fall, Eingabe, Erwartet — einschließlich der ungültigen Eingaben und der Grenzwerte (zum Beispiel
59und60Sekunden). - Geh Zeile für Zeile in Red-Green-Zyklen vor. Führ
pytestnach jeder Änderung aus und sieh dir das Rot an, bevor du es grün machst. - Füge die Round-Trip-Property mit Hypothesis hinzu: Für jede ganze Anzahl von Sekunden ab
0giltparse_duration(format_duration(n)) == n. - Eine Meldung kommt:
"90m"wurde abgelehnt, obwohl Leute das ständig tippen. Füge einen Regressionstest mit einem Kommentar hinzu, der sagt, was schiefging.
Wenn du fertig bist, räum den Code einmal auf und bestätige, dass die ganze Suite grün bleibt.
Lösung
durations.py:
import re
UNITS = {"h": 3600, "m": 60, "s": 1}
PATTERN = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?")
def parse_duration(text: str) -> int:
"""'1h30m' -> 5400. Units must appear in the order h, m, s."""
match = PATTERN.fullmatch(text)
if not text or match is None:
raise ValueError(f"not a duration: {text!r}")
hours, minutes, seconds = (int(part or 0) for part in match.groups())
return hours * 3600 + minutes * 60 + seconds
def format_duration(seconds: int) -> str:
"""5400 -> '1h30m'. Zero is '0s'."""
if seconds < 0:
raise ValueError(f"duration cannot be negative: {seconds}")
if seconds == 0:
return "0s"
parts = []
for unit, size in UNITS.items():
amount, seconds = divmod(seconds, size)
if amount:
parts.append(f"{amount}{unit}")
return "".join(parts)test_durations.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from durations import format_duration, parse_duration
@pytest.mark.parametrize(
"text, seconds",
[
("45s", 45),
("2h", 7200),
("1h30m", 5400),
("1h0m5s", 3605),
("0s", 0),
],
)
def test_parse_reads_hours_minutes_and_seconds(text, seconds):
assert parse_duration(text) == seconds
@pytest.mark.parametrize("text", ["", "h", "1x", "30m1h", "1.5h"])
def test_parse_rejects_text_that_is_not_a_duration(text):
with pytest.raises(ValueError, match="not a duration"):
parse_duration(text)
@pytest.mark.parametrize(
"seconds, text",
[(0, "0s"), (59, "59s"), (60, "1m"), (3605, "1h5s"), (5400, "1h30m")],
)
def test_format_writes_only_the_units_it_needs(seconds, text):
assert format_duration(seconds) == text
def test_format_rejects_a_negative_duration():
with pytest.raises(ValueError, match="negative"):
format_duration(-1)
@given(st.integers(min_value=0, max_value=10**7))
def test_parsing_a_formatted_duration_gives_back_the_seconds(seconds):
assert parse_duration(format_duration(seconds)) == seconds
def test_minutes_over_sixty_are_accepted():
# Bug: "90m" was rejected, though people type it all the time
assert parse_duration("90m") == 5400$ pytest -q
.................. [100%]
18 passed in 0.01sWarum es so gebaut ist:
- Der Plan wurde zu den parametrize-Tabellen. Jede Zeile der Tabelle ist eine Zeile von
parametrize, sodass ein weiterer Fall später eine Zeile ist, und die Testnamen in der-v-Ausgabe zeigen, welche Zeile fehlschlug. - Gültige und ungültige Eingaben sind getrennte Tests. Sie sind eigenständige Verhalten mit eigenen Gründen zu fehlschlagen. Die Liste der ungültigen Eingaben enthält je ein Beispiel für jede Art von Fehler: leer, Einheit ohne Zahl, unbekannte Einheit, falsche Reihenfolge, Dezimalzahl.
match="not a duration"prüft die Meldung zusätzlich zum Typ, sodass einValueError, der zufällig an einer anderen Stelle ausgelöst wird —int("")etwa —, den Test nicht bestehen lassen kann.if not textist da, weil das Muster, bei dem jeder Teil optional ist, den leeren String matcht. Genau bei solchen Fällen zwingt dich die Planungstabelle, vorher darüber nachzudenken, bevor der reguläre Ausdruck es für dich erledigt.59und60sind der Grenzwert, an dem Sekunden in Minuten überlaufen; ein Test in der Mitte des Bereichs würde einen Off-by-one dort nie fangen.- Die Round-Trip-Property ist der stärkste Test in der Datei. Sie kennt keinen einzigen erwarteten Wert, prüft aber zehn Millionen mögliche Dauern gegeneinander — jede Abweichung zwischen den beiden Funktionen zeigt sich, geschrumpft auf die kleinste Zahl, die sie bricht.
- Der Regressionstest besteht schon mit dieser Implementierung; er existiert, um eine künftige „Aufräumaktion“ zu stoppen, die Minuten auf
0-59begrenzt und den Bug zurückbringt. Sein Kommentar sagt, warum. - Nichts testet
PATTERNoderUNITSdirekt. Sie sind Implementierung, und du kannst den regulären Ausdruck morgen durch einen handgeschriebenen Parser ersetzen.
Wie es weitergeht
Hier endet der Kurs. Du kannst Tests schreiben, ihre Fehlschläge lesen, sie strukturieren, isolieren, messen, pytest selbst erweitern und — nach diesem Kapitel — entscheiden, was es überhaupt wert ist, getestet zu werden. Einige Richtungen von hier aus:
- Mach es zur Gewohnheit an echtem Code. Nimm ein Projekt, das du schon hast, und ergänze Tests für den Teil, vor dem du dich am meisten fürchtest, etwas zu ändern. Schreib zuerst die Planungstabelle. Die Angst sinkt, während die Tests steigen.
- Geh weiter mit Hypothesis. Die Dokumentation behandelt zustandsbasiertes Testen (
RuleBasedStateMachine), das ganze Folgen von Operationen gegen ein Objekt erzeugt und Bugs findet, die keine einzelne Eingabe finden könnte. - Mutationstests. Werkzeuge wie
mutmutmachen kleine Änderungen an deinem Code —<zu<=,+zu-— und prüfen, dass irgendein Test fehlschlägt. Eine Mutation, die überlebt, ist eine Zeile, die deine Tests nicht wirklich prüfen, egal wie hoch die Coverage-Zahl ist. - Bau das CI-Setup aus Kapitel vierzehn aus. Führ die Suite auf jeder Python-Version aus, die du unterstützt, und lass einen fehlschlagenden Test den Merge blockieren — eine Suite, die niemand grün halten muss, hört langsam auf, grün zu sein.
- Lies gute Test-Suiten. Die Tests von
pytest,requests,attrsundhypothesisselbst sind Open Source. Zu lesen, wie erfahrene Leute Tests benennen, strukturieren und isolieren, lehrt mehr als jede Regelliste.
Was auch immer du als Nächstes schreibst, fang mit der Frage an, mit der dieses Kapitel begonnen hat: Wenn morgen jemand diesen Code verbessert, ohne zu ändern, was er tut, bleiben meine Tests grün — und wenn jemand ihn kaputt macht, werden sie rot?
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