Test-Driven Development (TDD) verlagert das Schreiben von Tests zeitlich vor die eigentliche Implementierung. In diesem Notebook betrachten wir verschiedene Werkzeuge im Python-Ökosystem, um die Korrektheit und Robustheit unseres Codes von einfachen interaktiven Beispielen bis hin zu massenhaft generierten Randfällen sicherzustellen.
Dies ist eine logische Fortführung unserer Beschäftigung mit Lintern und Typisierung. Wenn Typ-Checks die strukturelle Korrektheit gewährleisten, sichern Tests die semantische Korrektheit.
Worum es geht. Tests als Mittel, die semantische Korrektheit zu sichern — von einem ersten assert über den vollständigen TDD-Zyklus bis zu Werkzeugen, die Testfälle selbst erzeugen und die Tests ihrerseits auf die Probe stellen. Laufendes Beispiel ist die Berechnung der Shannon-Entropie.
Was Sie danach können. Einen Test vor der Implementierung schreiben und den Zyklus Rot–Grün–Refaktorisieren durchlaufen; mit parametrize und Fixtures Wiederholung vermeiden; Zeit, Zufall und Nutzereingaben testbar machen; Coverage richtig interpretieren; und mit Mutation Testing prüfen, ob die eigenen Tests überhaupt etwas prüfen.
Die Java-Entsprechung in einem Satz. pytest ist JUnit ohne Klassenzwang: Ein Test ist eine Funktion, deren Name mit test_ beginnt, und die Prüfung ist ein gewöhnliches assert, dessen Fehlermeldung das Framework aus dem Ausdruck selbst rekonstruiert.
# Vorbereitung: Virtuelle Umgebung und Tools installieren
!uv venv --quiet
!uv sync --quiet
!uv pip --quiet install faker hydra-core hypothesis nox pytest pytest-cov pytest-gremlinsDas durchgehende Beispiel: Berechnung der Shannon-Entropie¶
Wir nutzen als Running Example die Berechnung der Shannon-Entropie für eine diskrete Wahrscheinlichkeitsverteilung:
dabei soll calculate_entropy also eine Funktion sein, die die Verteilung (also die ) gegeben bekommt, und einen float zurück gibt, der die Entropie der gegebenen Verteilung ist.
import math
def calculate_entropy(probs):
return -sum(p * math.log2(p) for p in probs if p > 0)
print(calculate_entropy([0.25, 0.25, 0.25, 0.25])) # Output: 2.02.0
Wir fügen nun eine Logik hinzu, die unsere Annahmen über den Parameter probs teilweise verifiziert, ergänzen Type Hints und betten jetzt Doctests ein. Das Modul Doctest sucht interaktive Python-Beispiele in den Docstrings und führt sie aus. Das automatisierte Prüfen von Doctests verhindert, dass diese Code-Beispiele veralten. Da der Docstring z.B. in IDEs oft eingeblendet wird, ist das sehr nützlich.
%%writefile entropy.py
import math
def calculate_entropy(probs: list[float]) -> float:
"""
Berechnet die Shannon-Entropie einer diskreten Verteilung.
>>> calculate_entropy([0.5, 0.5])
1.0
>>> calculate_entropy([1.0, 0.0])
0.0
"""
if not math.isclose(sum(probs), 1.0):
raise ValueError("Wahrscheinlichkeiten müssen sich zu 1 summieren.")
return -sum(p * math.log2(p) for p in probs if p > 0)
Writing entropy.py
# Doctests ausführen/prüfen
!uv run python -m doctest -v entropy.pyTrying:
calculate_entropy([0.5, 0.5])
Expecting:
1.0
ok
Trying:
calculate_entropy([1.0, 0.0])
Expecting:
0.0
**********************************************************************
File "/home/user/Sciebo/hhu/eipy-26/eipy-skript/part-tools/entropy.py", line 9, in entropy.calculate_entropy
Failed example:
calculate_entropy([1.0, 0.0])
Expected:
0.0
Got:
-0.0
1 item had no tests:
entropy
**********************************************************************
1 item had failures:
1 of 2 in entropy.calculate_entropy
2 tests in 2 items.
1 passed and 1 failed.
***Test Failed*** 1 failure.
Wir sehen also: unser Doctest hat bereits erste Erfolge gezeigt, nämlich eine falsche Annahme aufgedeckt (dass die Null ohne Vorzeichen ist).
Jetzt müssen wir uns fragen, ob der Test falsch geschrieben ist (also wir dort als Erwartung -0.0 hinschreiben sollten) oder ob wir unseren Code ändern, z.B. durch + 0.0, was gemäß IEEE 754 aus sowohl 0.0 als auch -0.0 einheitlich 0.0 macht.
Hier mag einigermaßen klar sein, was richtig ist; im Allgemeinen sollte man bei jeder verletzten Annahme in Ruhe darüber nachdenken, welche anderen Teile unseres Programm- und Weltmodells von diesem Fehler betroffen sein könnten.
%%writefile entropy.py
import math
def calculate_entropy(probs: list[float]) -> float:
"""
Berechnet die Shannon-Entropie einer diskreten Verteilung.
>>> calculate_entropy([0.5, 0.5])
1.0
>>> calculate_entropy([1.0, 0.0])
0.0
"""
if not math.isclose(sum(probs), 1.0):
raise ValueError("Wahrscheinlichkeiten müssen sich zu 1 summieren.")
return -sum(p * math.log2(p) for p in probs if p > 0) + 0.0Overwriting entropy.py
# Erneut Doctests prüfen
!uv run python -m doctest -v entropy.pyTrying:
calculate_entropy([0.5, 0.5])
Expecting:
1.0
ok
Trying:
calculate_entropy([1.0, 0.0])
Expecting:
0.0
ok
1 item had no tests:
entropy
1 item passed all tests:
2 tests in entropy.calculate_entropy
2 tests in 2 items.
2 passed.
Test passed.
Grundlagen: Unit Tests mit pytest¶
pytest ist das De-facto-Standard-Framework in der Python-Welt. Im Gegensatz zum eingebauten Modul unittest, welches stark objektorientiert ist, glänzt pytest durch eine minimale Syntax (wir nutzen ein simples assert) und aussagekräftige Fehlermeldungen.
Wir erstellen nun eine Datei für unsere Unit Tests, welche die entropy-Funktion isoliert testen. Dabei testen wir auch, ob unter bestimmten Bedingungen ein erwarteter Fehler auftritt (also testen wir nicht nur den Happy Path).
%%writefile test_entropy.py
import pytest
from entropy import calculate_entropy
def test_calculate_entropy_fair_coin():
# Ein typischer Unit Test: Eine spezifische Eingabe testen
assert calculate_entropy([0.5, 0.5]) == 1.0
def test_calculate_entropy_invalid_distribution():
# Hier testen wir, ob die Funktion auch korrekte Fehler wirft!
with pytest.raises(ValueError, match="summieren"):
calculate_entropy([0.5, 0.6])
Writing test_entropy.py
# Tests mit pytest ausführen
!uv run pytest -v test_entropy.py============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0 -- /home/user/Sciebo/hhu/eipy-26/eipy-skript/.venv/bin/python
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: jaxtyping-0.3.11, cov-5.0.0, hypothesis-6.152.9, hydra-core-1.3.2, Faker-40.23.0, gremlins-1.8.1, typeguard-4.5.2, anyio-4.13.0
collected 2 items
test_entropy.py::test_calculate_entropy_fair_coin PASSED [ 50%]
test_entropy.py::test_calculate_entropy_invalid_distribution PASSED [100%]
============================== 2 passed in 0.09s ===============================
Unter der Haube nutzt pytest das Framework Pluggy, welches eine extrem flexible Plugin-Architektur ermöglicht. Dadurch gibt es hunderte Erweiterungen wie pytest-cov oder pytest-mock (s.u.). Pluggy kommt auch in anderen Testing-Frameworks (wie z.B. tox, s.u.) aber auch in Datenverarbeitungswerkzeugen zum Einsatz.
Der Zyklus: Rot, Grün, Refaktorisieren¶
Bis hierher sind wir code-first vorgegangen: erst die Funktion, dann die Tests. Das ist völlig legitim, aber es ist nicht TDD. Der eigentliche Zyklus besteht aus drei Schritten, und der erste ist der ungewohnte:
Rot — einen Test schreiben, der das gewünschte Verhalten beschreibt, und ihn fehlschlagen sehen.
Grün — die einfachste Implementierung schreiben, die ihn bestehen lässt. Nicht die schönste.
Refaktorisieren — den Code aufräumen, während die Tests grün bleiben.
Warum zuerst der Test? Vor allem, weil man sonst nie erfährt, ob der Test etwas prüft. Ein Test, den man nach der Implementierung schreibt und der sofort grün ist, kann grün sein, weil der Code stimmt — oder weil der Test nichts misst (falscher Import, vertippter Funktionsname, vergessenes assert). Diesen Unterschied sieht man nur, wenn man den Test einmal rot gesehen hat. Zweitens zwingt der Test dazu, die Schnittstelle aus der Sicht des Aufrufers zu entwerfen, bevor man sich in die Implementierung verliebt. Und drittens ist der rote Test die Definition von “fertig”: Wenn er grün ist, hört man auf.
Machen wir das an einer neuen Anforderung durch: Die Entropie soll auch in anderen Einheiten als Bit gemessen werden können — in nats (Basis e) oder dits (Basis 10). Die Schnittstelle soll calculate_entropy(probs, base=2) heißen, und der bisherige Aufruf muss unverändert funktionieren.
Schritt 1: Rot¶
%%writefile test_entropy_base.py
import math
import pytest
from entropy import calculate_entropy
def test_voreinstellung_bleibt_bit():
# Die alte Schnittstelle muss unverändert funktionieren.
assert calculate_entropy([0.5, 0.5]) == 1.0
def test_entropie_in_nats():
# Dieselbe Verteilung, zur Basis e gemessen: ln(2) nats statt 1 bit.
assert calculate_entropy([0.5, 0.5], base=math.e) == pytest.approx(math.log(2))
def test_entropie_in_dits():
# Vier gleichwahrscheinliche Ereignisse, zur Basis 10.
assert calculate_entropy([0.25] * 4, base=10) == pytest.approx(math.log10(4))
Writing test_entropy_base.py!uv run pytest -v test_entropy_base.pyZwei rote Tests, einer grün — genau so soll es aussehen. Der grüne Test ist wertvoll: Er hält fest, dass wir die bestehende Schnittstelle nicht kaputtmachen dürfen. Und die Fehlermeldung ist nicht etwa ein falsches Ergebnis, sondern ein TypeError: Die Funktion kennt das Argument noch gar nicht. Auch das ist ein legitimer roter Test.
Schritt 2: Grün¶
Jetzt die kleinstmögliche Änderung. math.log(p, base) rechnet den Logarithmus zu beliebiger Basis; das ist nicht die beste Lösung, aber die schnellste.
%%writefile entropy.py
import math
def calculate_entropy(probs: list[float], base: float = 2.0) -> float:
"""
Berechnet die Shannon-Entropie einer diskreten Verteilung.
>>> calculate_entropy([0.5, 0.5])
1.0
>>> calculate_entropy([1.0, 0.0])
0.0
"""
if not math.isclose(sum(probs), 1.0):
raise ValueError("Wahrscheinlichkeiten müssen sich zu 1 summieren.")
return -sum(p * math.log(p, base) for p in probs if p > 0) + 0.0
Overwriting entropy.py!uv run pytest test_entropy_base.pySchritt 3: Refaktorisieren¶
Grün heißt nicht fertig. math.log(p, base) hat zwei Nachteile: Es berechnet für jedes Element zwei Logarithmen statt einen, und es verliert die numerische Genauigkeit, die math.log2 für die Basis 2 besonders sorgfältig herstellt. Die Mathematik erlaubt uns, den Basiswechsel einmal am Ende vorzunehmen, weil gilt.
Beim Refaktorisieren ändert sich das Verhalten nicht — deshalb rühren wir die Tests nicht an. Sie sind jetzt das Sicherheitsnetz, das uns diese Umstellung überhaupt erst erlaubt.
%%writefile entropy.py
import math
def calculate_entropy(probs: list[float], base: float = 2.0) -> float:
"""
Berechnet die Shannon-Entropie einer diskreten Verteilung.
>>> calculate_entropy([0.5, 0.5])
1.0
>>> calculate_entropy([1.0, 0.0])
0.0
"""
if not math.isclose(sum(probs), 1.0):
raise ValueError("Wahrscheinlichkeiten müssen sich zu 1 summieren.")
bits = -sum(p * math.log2(p) for p in probs if p > 0) + 0.0
return bits / math.log2(base)
Overwriting entropy.py!uv run pytest test_entropy_base.py && uv run python -m doctest entropy.py && echo "Doctests ebenfalls grün."Die Tests sind grün geblieben, und die Doctests aus dem ersten Abschnitt ebenfalls. Genau das ist der Gewinn: Wir haben die Implementierung ausgetauscht, ohne sie erneut von Hand prüfen zu müssen.
Der Zyklus ist bewusst klein gehalten — eine Anforderung, drei Schritte, wenige Minuten. Wer eine halbe Stunde lang rot ist, hat sich zu viel auf einmal vorgenommen.
pytest im Alltag: Parametrisierung und Fixtures¶
Zwei Mechanismen tragen in der Praxis den größten Teil einer Test-Suite. Ohne sie schreibt man denselben Test viermal mit anderen Zahlen und baut sich die Testumgebung in jedem Test von Hand auf.
Parametrisierung¶
@pytest.mark.parametrize erzeugt aus einer Testfunktion mehrere Tests — einen pro Datensatz. Der Unterschied zu einer Schleife im Test ist entscheidend: Jeder Fall wird einzeln ausgeführt und einzeln berichtet, und ein Fehlschlag stoppt die übrigen nicht.
%%writefile test_entropy_param.py
import pytest
from entropy import calculate_entropy
@pytest.mark.parametrize(
"probs, erwartet",
[
([1.0], 0.0), # Sicherheit: kein Informationsgehalt
([0.5, 0.5], 1.0), # fairer Münzwurf
([0.25] * 4, 2.0), # vier gleichwahrscheinliche Ereignisse
([0.5, 0.25, 0.25], 1.5), # ungleiche Verteilung
],
)
def test_entropie_bekannter_verteilungen(probs, erwartet):
assert calculate_entropy(probs) == pytest.approx(erwartet)
@pytest.mark.parametrize("ungueltig", [[0.5, 0.6], [0.1], []])
def test_ungueltige_verteilung_wirft(ungueltig):
with pytest.raises(ValueError):
calculate_entropy(ungueltig)
Writing test_entropy_param.py
!uv run pytest -v test_entropy_param.py============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0 -- /home/user/Sciebo/hhu/eipy-26/eipy-skript/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: jaxtyping-0.3.11, cov-5.0.0, hypothesis-6.152.9, logfire-4.37.0, hydra-core-1.3.2, Faker-40.23.0, gremlins-1.8.1, typeguard-4.5.2, anyio-4.13.0
collecting ... collected 7 items
test_entropy_param.py::test_entropie_bekannter_verteilungen[probs0-0.0] PASSED [ 14%]
test_entropy_param.py::test_entropie_bekannter_verteilungen[probs1-1.0] PASSED [ 28%]
test_entropy_param.py::test_entropie_bekannter_verteilungen[probs2-2.0] PASSED [ 42%]
test_entropy_param.py::test_entropie_bekannter_verteilungen[probs3-1.5] PASSED [ 57%]
test_entropy_param.py::test_ungueltige_verteilung_wirft[ungueltig0] PASSED [ 71%]
test_entropy_param.py::test_ungueltige_verteilung_wirft[ungueltig1] PASSED [ 85%]
test_entropy_param.py::test_ungueltige_verteilung_wirft[ungueltig2] PASSED [100%]
============================== 7 passed in 0.10s ===============================
In der Ausgabe steht hinter jedem Test der konkrete Datensatz in eckigen Klammern. Schlägt einer fehl, weiß man sofort, welcher — und kann ihn mit pytest "test_entropy_param.py::test_entropie_bekannter_verteilungen[probs3-1.5]" einzeln erneut ausführen.
Beachtenswert ist außerdem pytest.approx: Fließkommazahlen auf Gleichheit zu prüfen ist fast immer falsch. 0.1 + 0.2 == 0.3 ist False, und Entropiewerte entstehen aus Logarithmen.
Fixtures¶
Eine Fixture stellt einem Test seine Vorbedingungen bereit und räumt danach auf. Man fordert sie an, indem man ihren Namen als Parameter schreibt — pytest setzt ihn ein. Neben eigenen Fixtures gibt es eingebaute; drei davon braucht man ständig:
tmp_path— ein leeres Verzeichnis (alspathlib.Path), das nach dem Test verschwindet. Damit muss kein Test mehr in das Projektverzeichnis schreiben.monkeypatch— setzt Attribute, Umgebungsvariablen oder Arbeitsverzeichnisse für die Dauer eines Tests und stellt sie danach zurück.capsys— fängtstdoutundstderrab, sodass man Ausgaben prüfen kann.
%%writefile test_fixtures.py
import json
import os
import pytest
from entropy import calculate_entropy
@pytest.fixture
def verteilung():
"""Wird für jeden Test neu ausgewertet - kein geteilter Zustand."""
return [0.5, 0.25, 0.25]
def test_mit_eigener_fixture(verteilung):
assert calculate_entropy(verteilung) == pytest.approx(1.5)
def test_schreibt_ergebnisdatei(tmp_path, verteilung):
ziel = tmp_path / "ergebnis.json"
ziel.write_text(json.dumps({"entropie": calculate_entropy(verteilung)}))
wieder_gelesen = json.loads(ziel.read_text())
assert wieder_gelesen["entropie"] == pytest.approx(1.5)
def test_umgebungsvariable(monkeypatch):
monkeypatch.setenv("ENTROPY_BASE", "10")
assert os.environ["ENTROPY_BASE"] == "10"
def test_nach_dem_test_ist_die_variable_weg():
assert "ENTROPY_BASE" not in os.environ
def test_ausgabe_pruefen(capsys):
print("Entropie:", calculate_entropy([0.5, 0.5]))
erfasst = capsys.readouterr()
assert erfasst.out.strip() == "Entropie: 1.0"
Writing test_fixtures.py
!uv run pytest -v test_fixtures.py============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0 -- /home/user/Sciebo/hhu/eipy-26/eipy-skript/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: jaxtyping-0.3.11, cov-5.0.0, hypothesis-6.152.9, logfire-4.37.0, hydra-core-1.3.2, Faker-40.23.0, gremlins-1.8.1, typeguard-4.5.2, anyio-4.13.0
collected 5 items
test_fixtures.py::test_mit_eigener_fixture PASSED [ 20%]
test_fixtures.py::test_schreibt_ergebnisdatei PASSED [ 40%]
test_fixtures.py::test_umgebungsvariable PASSED [ 60%]
test_fixtures.py::test_nach_dem_test_ist_die_variable_weg PASSED [ 80%]
test_fixtures.py::test_ausgabe_pruefen PASSED [100%]
============================== 5 passed in 0.10s ===============================
Der vierte Test ist der eigentliche Beweis: monkeypatch hat die Umgebungsvariable nach dem dritten Test wieder entfernt. Genau diese Aufräumgarantie ist der Grund, monkeypatch gegenüber einem selbstgebauten os.environ[...] = ... zu bevorzugen — auch dann, wenn der Test mittendrin mit einer Exception abbricht.
Eigene Fixtures können ebenfalls aufräumen, indem sie den Wert mit yield herausgeben und danach weiterarbeiten:
@pytest.fixture
def datenbank():
con = sqlite3.connect(":memory:")
yield con # hier läuft der Test
con.close() # und danach das hier, auch bei FehlernMit @pytest.fixture(scope="module") oder scope="session" wird sie einmal pro Datei bzw. pro Lauf statt pro Test ausgewertet — schneller, aber der Zustand ist dann geteilt, und Tests können sich gegenseitig beeinflussen.
Wohin mit den Testdateien?¶
Sobald es mehr als eine Handvoll Tests sind, gehören sie in ein eigenes Verzeichnis neben den Quelltext:
projekt/
├── pyproject.toml
├── src/
│ └── cyoa/
│ ├── __init__.py
│ └── engine.py
└── tests/
├── conftest.py # Fixtures, die mehrere Testdateien teilen
├── test_engine.py
└── test_speichern.pyconftest.py ist dabei eine Besonderheit: Was dort als Fixture definiert ist, steht allen Tests im selben Verzeichnis (und darunter) zur Verfügung, ohne dass man es importieren muss. Die Datei wird nie selbst als Test ausgeführt.
Damit pytest ohne Argumente das Richtige tut, trägt man die Konvention in die pyproject.toml ein:
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"Die Namenskonventionen sind nicht verhandelbar: Dateien heißen test_*.py, Funktionen test_*, Klassen Test* (ohne __init__). Wer eine Testdatei entropy_tests.py nennt, wundert sich, warum pytest nichts findet.
Testabdeckung (Coverage)¶
Mit dem Plugin pytest-cov können wir messen, wie viel Prozent des Quellcodes während unserer Tests ausgeführt wurden. Eine niedrige Abdeckung ist ein Indikator für fehlende Tests, aber eine hohe Abdeckung keine absolute Garantie für Fehlerfreiheit.
!uv run pytest --cov=entropy test_entropy.py============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: jaxtyping-0.3.11, cov-5.0.0, hypothesis-6.152.9, logfire-4.37.0, hydra-core-1.3.2, Faker-40.23.0, gremlins-1.8.1, typeguard-4.5.2, anyio-4.13.0
collected 2 items
test_entropy.py .. [100%]
---------- coverage: platform linux, python 3.13.3-final-0 -----------
Name Stmts Miss Cover
--------------------------------
entropy.py 6 0 100%
--------------------------------
TOTAL 6 0 100%
============================== 2 passed in 0.14s ===============================
Wie viele Tests sind genug?¶
Die Coverage-Zahl beantwortet diese Frage nicht — die Zelle darüber ist der Beleg: entropy.py steht bei 100%, obwohl keiner der beiden Tests jemals den Parameter base benutzt. Jede Zeile wurde ausgeführt, die halbe Schnittstelle ist ungeprüft. Sie misst, welche Zeilen ausgeführt wurden — nicht, ob dabei etwas geprüft wurde. Ein Test ohne einziges assert erzeugt 100% Abdeckung und findet nichts. Coverage ist eine Untergrenze: Was nie ausgeführt wurde, ist sicher ungetestet; was ausgeführt wurde, ist vielleicht getestet.
Drei brauchbare Regeln statt einer Zahl:
Ein Test pro Verhalten, nicht pro Methode. Eine Methode mit drei Sonderfällen braucht vier Tests; drei triviale Getter brauchen keinen. Die Frage lautet nicht “welchen Code habe ich geschrieben”, sondern “welche Zusagen mache ich dem Aufrufer”.
Ein Regressionstest pro gefundenem Fehler. Sobald ein Bug auftaucht, wird er zuerst zu einem roten Test — dann behoben. Das ist der billigste Test, den man je schreibt, denn die Arbeit (das Reproduzieren) hat man beim Debuggen ohnehin geleistet. Und es ist der einzige, von dem man sicher weiß, dass er einen echten Fehler findet.
Der unangenehme Fall zuerst. Die Happy-Path-Tests schreiben sich von selbst und finden selten etwas. Die Fehler sitzen an den Rändern: leere Eingabe, ein Element, doppelte Einträge, negative Zahl, fehlende Datei, abgebrochene Eingabe. Wer wenig Zeit hat, testet die Ränder und lässt die Mitte weg.
Als Untergrenze lässt sich Coverage automatisch erzwingen, damit sie nicht schleichend sinkt:
uv run pytest --cov=entropy --cov-fail-under=80
uv run coverage html # erzeugt htmlcov/index.html mit ZeilenmarkierungDer HTML-Bericht ist dabei nützlicher als die Prozentzahl: Er zeigt die nicht ausgeführten Zeilen rot, und die sind oft genau die Fehlerbehandlung, an die beim Testen niemand gedacht hat.
Für die Abschlussprojekte in diesem Kurs ist der Maßstab damit klar benannt — und er steht bewusst in dieser Reihenfolge: nachvollziehbare Tests für die Kernlogik, ein Regressionstest pro behobenem Fehler, und erst danach eine Zahl. Was die Bewertung im Einzelnen erwartet, steht im Projektbericht.
Mocking: Externe Abhängigkeiten isolieren¶
Gute Unit Tests müssen deterministisch und extrem schnell sein. Wenn unsere Funktion Daten über das Netzwerk lädt oder eine Datenbank abfragt, ersetzen wir diesen Aufruf im Test durch einen Mock. Das Modul unittest.mock ist Teil der Standardbibliothek und wird auch von pytest genutzt.
!uv pip install requestsResolved 5 packages in 87ms
Installed 5 packages in 3ms3.4.7
+ certifi==2026.6.17
+ charset-normalizer==3.4.7
+ idna==3.18
+ requests==2.34.2
+ urllib3==2.7.0
%%writefile entropy_api.py
import requests
from entropy import calculate_entropy
def get_entropy_from_api(url: str) -> float:
"""Simuliert das Abrufen einer Wahrscheinlichkeitsverteilung über ein Netzwerk."""
response = requests.get(url)
probs = response.json().get("probabilities", [])
return calculate_entropy(probs)
Writing entropy_api.py
%%writefile test_entropy_api.py
from unittest.mock import patch
from entropy_api import get_entropy_from_api
# Wir ersetzen 'requests.get' in unserem Modul temporär durch einen Mock
@patch("entropy_api.requests.get")
def test_get_entropy_from_api(mock_get):
# Konfiguration des Mocks: Was soll zurückgegeben werden?
mock_get.return_value.json.return_value = {"probabilities": [0.5, 0.5]}
# Test ausführen (es wird kein echter Netzwerk-Call gemacht!)
result = get_entropy_from_api("http://fake-api.local/data")
# Behauptungen aufstellen
assert result == 1.0
mock_get.assert_called_once_with("http://fake-api.local/data")
Writing test_entropy_api.py
!uv run pytest -v test_entropy_api.py============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0 -- /home/user/Sciebo/hhu/eipy-26/eipy-skript/.venv/bin/python
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: jaxtyping-0.3.11, cov-5.0.0, hypothesis-6.152.9, hydra-core-1.3.2, Faker-40.23.0, gremlins-1.8.1, typeguard-4.5.2, anyio-4.13.0
collected 1 item
test_entropy_api.py::test_get_entropy_from_api PASSED [100%]
============================== 1 passed in 0.13s ===============================
Oft reichen hartkodierte Werte wie [0.5, 0.5] nicht aus, um realistische API-Antworten oder Datenbankeinträge zu simulieren, insbesondere wenn Metadaten gefordert sind. Das Paket faker generiert realistische, randomisierte Mock-Daten (Namen, URLs, IP-Adressen etc.).
%%writefile data_faker.py
from faker import Faker
fake = Faker()
# Wir generieren dynamisch eine Fake-URL und eine zufällige Verteilung
fake_url = fake.url()
fake_probs = [abs(fake.pyfloat(min_value=0.1, max_value=0.9)) for _ in range(3)]
# Normieren auf 1.0
fake_probs = [p / sum(fake_probs) for p in fake_probs]
print(f"Teste API-Endpunkt: {fake_url}")
print(f"Mit generierter Verteilung: {fake_probs}")Writing data_faker.py
!uv run python data_faker.py
!uv run python data_faker.py
!uv run python data_faker.pyTeste API-Endpunkt: http://snow-torres.biz/
Mit generierter Verteilung: [0.5650466036121851, 0.08917233411751792, 0.345781062270297]
Teste API-Endpunkt: https://www.wilson.com/
Mit generierter Verteilung: [0.3598635496692357, 0.34223561300687505, 0.29790083732388917]
Teste API-Endpunkt: http://www.collins.com/
Mit generierter Verteilung: [0.5781763354772447, 0.11723057081004999, 0.30459309371270543]
Zeit und Zufall testbar machen¶
Zwei Dinge machen Tests zuverlässig unzuverlässig: die Uhr und der Zufallsgenerator. Ein Test, der um 23:59 Uhr fehlschlägt oder alle zwanzig Läufe einmal rot wird, ist schlimmer als kein Test — er bringt dem Team bei, rote Tests zu ignorieren.
Die Lösung ist fast immer dieselbe und keine Test-Technik, sondern eine Entwurfsentscheidung: Zeit und Zufall sind Eingaben, keine Umwelt. Statt sie in der Geschäftslogik zu beschaffen, übergibt man sie.
Der Koffein-Tracker macht das konsequent: calculate_level_at(target_time) bekommt den Zeitpunkt übergeben und ruft nirgends datetime.now() auf. Der Aufruf von now() steht in der Oberfläche, nicht in der Logik. Deshalb braucht dieses Modul zum Testen kein einziges Hilfsmittel — man setzt einfach datetime(2026, 5, 22, 8, 0) ein.
Beim Zufall geht dasselbe, indem man einen eigenen Generator durchreicht statt das Modul random global zu benutzen:
%%writefile test_zufall.py
import random
def wuerfelwurf(rng: random.Random) -> int:
"""Nimmt den Generator als Parameter - das macht ihn austauschbar."""
return rng.randint(1, 6)
def test_mit_festem_seed_reproduzierbar():
rng = random.Random(42)
assert [wuerfelwurf(rng) for _ in range(5)] == [6, 1, 1, 6, 3]
def test_zwei_generatoren_stoeren_sich_nicht():
a, b = random.Random(42), random.Random(42)
assert [wuerfelwurf(a) for _ in range(3)] == [wuerfelwurf(b) for _ in range(3)]
def test_wertebereich_haelt_immer():
rng = random.Random(1)
assert all(1 <= wuerfelwurf(rng) <= 6 for _ in range(1000))
Writing test_zufall.py
!uv run pytest -v test_zufall.py============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0 -- /home/user/Sciebo/hhu/eipy-26/eipy-skript/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: jaxtyping-0.3.11, cov-5.0.0, hypothesis-6.152.9, logfire-4.37.0, hydra-core-1.3.2, Faker-40.23.0, gremlins-1.8.1, typeguard-4.5.2, anyio-4.13.0
collecting ... collected 3 items
test_zufall.py::test_mit_festem_seed_reproduzierbar PASSED [ 33%]
test_zufall.py::test_zwei_generatoren_stoeren_sich_nicht PASSED [ 66%]
test_zufall.py::test_wertebereich_haelt_immer PASSED [100%]
============================== 3 passed in 0.10s ===============================
Der Unterschied zu random.seed(42) ist wichtiger, als er aussieht: random.seed setzt den globalen Generator. Läuft ein anderer Test parallel (pytest -n auto) oder zieht zwischendurch selbst eine Zahl, ist die Reihenfolge dahin. Eine eigene random.Random-Instanz hat diesen Zustand nicht.
Der dritte Test zeigt außerdem eine Alternative zum festgeschriebenen Erwartungswert: Statt konkreter Zahlen prüft er eine Eigenschaft, die für alle Ziehungen gelten muss. Systematisch weitergedacht ist das Property-Based Testing — der nächste Abschnitt.
Wenn man nicht injizieren kann¶
Bei fremdem Code hat man die Wahl oft nicht. Dann ersetzt man die Quelle von außen. Für die Uhr gibt es dafür spezialisierte Werkzeuge, weil datetime.now als C-Funktion nicht einfach zu überschreiben ist:
import time_machine
from datetime import datetime
@time_machine.travel(datetime(2026, 5, 22, 8, 0))
def test_am_morgen():
assert datetime.now().hour == 8time-machine ist der schnellere Nachfolger von freezegun, das man in älteren Projekten häufiger antrifft; beide können auch als Kontextmanager benutzt werden und die Zeit während des Tests weiterlaufen lassen.
Für alles andere genügt monkeypatch — wichtig ist dabei, dort zu patchen, wo der Name benutzt wird, nicht dort, wo er definiert ist:
def test_zufall_von_aussen(monkeypatch):
monkeypatch.setattr("mein_spiel.random.randint", lambda a, b: 6)
assert mein_spiel.wuerfeln() == 6Das Plugin pytest-mock bietet dieselbe Funktionalität als Fixture mocker an, mit der bequemeren Zusage, Aufrufe mitzuzählen (mocker.patch(...) liefert ein Mock, das assert_called_once_with kennt). Es ist Zucker über unittest.mock aus dem vorigen Abschnitt, mit automatischem Aufräumen nach dem Test.
Nutzeroberflächen und Ein-/Ausgabe testen¶
Dies ist die Lücke, die in studentischen Projekten am zuverlässigsten offen bleibt: Die Kernlogik hat Tests, das Spiel selbst nicht — mit der Begründung, dass man Eingaben und Bildschirmausgabe nun einmal nicht testen könne. Man kann.
Der erste Schritt ist kein Test, sondern ein Schnitt¶
Testbarkeit ist hier vor allem eine Architekturfrage. Solange input() und print() mitten in der Spiellogik stehen, testet man beides zusammen oder gar nicht. Trennt man Engine (was passiert) von UI (wie es erscheint), lässt sich die Engine mit gewöhnlichen Unit Tests prüfen, und für die UI bleibt eine dünne Schicht übrig, die man mit wenigen Tests abdeckt.
Genau diesen Schnitt vollzieht der Übergang von practical-0 zu practical-1 — dort als Frage der Lesbarkeit begründet. Die Testbarkeit ist das zweite, härtere Argument dafür: Sie ist der Grund, warum sich die Mühe auch dann lohnt, wenn nie eine zweite Oberfläche dazukommt.
Terminal-Ein-/Ausgabe: monkeypatch und capsys¶
Selbst eine klassische Eingabeschleife ist mit den beiden Fixtures aus dem vorigen Abschnitt vollständig testbar. input ist eine gewöhnliche Funktion in builtins und lässt sich ersetzen:
%%writefile cyoa.py
"""Ein sehr kleines CYOA-Textadventure, stellvertretend für Blatt 1 und 2."""
SZENEN = {
"start": ("Du stehst vor einer Höhle.", {"1": "hoehle", "2": "wald"}),
"hoehle": ("Es ist dunkel. Ende.", {}),
"wald": ("Du verirrst dich. Ende.", {}),
}
def spiele(szene: str = "start") -> str:
"""Spielt bis zu einer Szene ohne Auswahl und liefert deren Namen."""
while True:
text, auswahl = SZENEN[szene]
print(text)
if not auswahl:
return szene
for taste, ziel in auswahl.items():
print(f" [{taste}] -> {ziel}")
eingabe = input("> ")
if eingabe not in auswahl:
print("Unbekannte Eingabe.")
continue
szene = auswahl[eingabe]
Writing cyoa.py
%%writefile test_cyoa.py
import pytest
from cyoa import spiele
def tastendruecke(monkeypatch, *werte):
"""Ersetzt input() durch eine feste Folge von Eingaben."""
folge = iter(werte)
monkeypatch.setattr("builtins.input", lambda *_: next(folge))
def test_weg_in_die_hoehle(monkeypatch, capsys):
tastendruecke(monkeypatch, "1")
assert spiele() == "hoehle"
assert "Es ist dunkel" in capsys.readouterr().out
def test_ungueltige_eingabe_fragt_erneut(monkeypatch, capsys):
tastendruecke(monkeypatch, "9", "2")
assert spiele() == "wald"
assert "Unbekannte Eingabe." in capsys.readouterr().out
def test_eingaben_gehen_aus(monkeypatch):
# Der Test endet, statt in einer Endlosschleife zu hängen.
tastendruecke(monkeypatch, "9")
with pytest.raises(StopIteration):
spiele()
Writing test_cyoa.py
!uv run pytest -v test_cyoa.py============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0 -- /home/user/Sciebo/hhu/eipy-26/eipy-skript/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: jaxtyping-0.3.11, cov-5.0.0, hypothesis-6.152.9, logfire-4.37.0, hydra-core-1.3.2, Faker-40.23.0, gremlins-1.8.1, typeguard-4.5.2, anyio-4.13.0
collected 3 items
test_cyoa.py::test_weg_in_die_hoehle PASSED [ 33%]
test_cyoa.py::test_ungueltige_eingabe_fragt_erneut PASSED [ 66%]
test_cyoa.py::test_eingaben_gehen_aus PASSED [100%]
============================== 3 passed in 0.10s ===============================
Drei Tests, und damit ist die Schleife abgedeckt: der Normalfall, die Fehlerbehandlung und — am wichtigsten — der Fall, dass die Eingaben ausgehen. Der letzte ist keine Spielerei: Ohne ihn hängt ein Test, der die Abbruchbedingung nie erreicht, bis zum Timeout. Mit iter(...) und next bekommt man stattdessen ein sauberes StopIteration.
capsys.readouterr() liefert ein Objekt mit .out und .err und leert den Puffer dabei. Wer die Ausgabe zeichengenau prüfen will, vergleicht mit ==; meistens ist in die bessere Wahl, weil der Test sonst bei jeder Formulierungsänderung bricht, ohne dass ein Fehler vorliegt.
Die übrigen Oberflächen¶
Für jede Art von UI aus dem Kapitel User Interfaces gibt es einen etablierten Weg. Keiner davon ist aufwendig, und alle laufen ohne Bildschirm in der CI:
| Oberfläche | Wie man sie testet |
|---|---|
Terminal (input/print) | monkeypatch.setattr("builtins.input", ...) + capsys, wie oben |
argparse/typer-CLI | die main-Funktion mit einer Argumentliste aufrufen statt sys.argv zu benutzen |
| Pygame | SDL_VIDEODRIVER=dummy setzen (headless), dann Events per pygame.event.post einspeisen und den Zustand prüfen |
| Textual | async with app.run_test() as pilot: — erlaubt pilot.press("q") und pilot.click(...) ohne Terminal |
| FastAPI | TestClient(app) aus fastapi.testclient — führt Requests direkt gegen die App aus, ohne Server und ohne Port |
| Browser (E2E) | playwright (siehe Ausblick am Kapitelende) |
Für Pygame genügt in der Praxis eine conftest.py mit zwei Zeilen, damit alle Tests headless laufen:
import os
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")Und noch einmal der entscheidende Punkt: Diese Tests prüfen die Verdrahtung — dass ein Tastendruck die richtige Methode auslöst und die Anzeige den Zustand zeigt. Ob die Spiellogik stimmt, gehört in Tests der Engine, wo weder Tastatur noch Bildschirm vorkommen. Wer die Trennung nicht vollzogen hat, schreibt beide Arten von Test in einem — und keine davon gut.
Snapshot-Tests und Golden Master¶
Alle Tests bisher hatten eine Erwartung, die jemand aufgeschrieben hat: assert "Es ist dunkel" in .... Dafür muss man wissen, was richtig ist. Es gibt eine Lage, in der man das nicht weiß und trotzdem testen will: fremder, gewachsener Code, den man ändern soll. Was er tut, ist unbekannt; dass er nach der Änderung dasselbe tut, ist die Anforderung.
Die Antwort darauf heißt Golden Master oder Snapshot-Test und dreht die Reihenfolge um: Man nimmt das aktuelle Verhalten auf, schreibt es in eine Datei, checkt sie ein — und vergleicht ab dann jeden Lauf damit. Der Test behauptet nicht, das Verhalten sei richtig. Er behauptet nur, es sei unverändert.
Selbst gebaut braucht das keine Bibliothek, sondern zwölf Zeilen:
%%writefile test_golden.py
"""Golden-Master-Test: friert die Ausgabe ein, ohne sie zu beschreiben."""
from pathlib import Path
import pytest
from cyoa import spiele
GOLDEN = Path(__file__).with_name("golden_wald.txt")
def test_ausgabe_unveraendert(monkeypatch, capsys):
folge = iter(["9", "2"])
monkeypatch.setattr("builtins.input", lambda *_: next(folge))
spiele()
ist = capsys.readouterr().out
if not GOLDEN.exists():
GOLDEN.write_text(ist, encoding="utf-8")
pytest.skip(f"{GOLDEN.name} neu aufgenommen - ansehen und einchecken")
assert ist == GOLDEN.read_text(encoding="utf-8")Writing test_golden.py
Der erste Lauf hat noch keine Vergleichsdatei. Er legt sie an und meldet sich als skipped — nicht als passed, denn bestanden hat er nichts:
!uv run pytest -q -rs test_golden.pys [100%]
=========================== short test summary info ============================
SKIPPED [1] test_golden.py:21: golden_wald.txt neu aufgenommen - ansehen und einchecken
1 skipped in 0.10s
Jetzt kommt der Schritt, den man nicht überspringen darf: die aufgenommene Datei lesen. Ein Golden Master friert ein, was da ist — Fehler eingeschlossen. Wer ungeprüft einfriert, hat einen Test, der ab jetzt verlangt, dass der Bug bleibt.
!cat golden_wald.txtDu stehst vor einer Höhle.
[1] -> hoehle
[2] -> wald
Unbekannte Eingabe.
Du stehst vor einer Höhle.
[1] -> hoehle
[2] -> wald
Du verirrst dich. Ende.
Sieht plausibel aus — also einchecken. Ab dem zweiten Lauf ist es ein echter Test:
!uv run pytest -q test_golden.py. [100%]
1 passed in 0.09s
Und so sieht es aus, wenn sich das Verhalten ändert. Wir ändern einen Ausgabetext im Programm — eine Änderung, die kein bestehender Test der vorigen Abschnitte bemerkt, weil keiner auf genau diesen Satz prüft:
!sed -i 's/Du verirrst dich. Ende./Du verirrst dich im Wald. Ende./' cyoa.py
!uv run pytest -q --tb=line test_golden.pyF [100%]
=================================== FAILURES ===================================
E AssertionError: assert 'Du stehst vo...Wald. Ende.\n' == 'Du stehst vo...dich. Ende.\n'
Skipping 140 identical leading characters in diff, use -v to show
- irrst dich. Ende.
+ irrst dich im Wald. Ende.
? ++++++++
/test_golden.py:23: AssertionError: assert 'Du stehst vo...Wald. Ende.\n' == 'Du stehst vo...dich. Ende.\n'
=========================== short test summary info ============================
FAILED test_golden.py::test_ausgabe_unveraendert - AssertionError: assert 'Du...
1 failed in 0.09s
Der Test sagt genau, was sich geändert hat, und zwar als Diff. Das ist die Stärke des Verfahrens: ein Test deckt die gesamte Ausgabe ab, ohne dass jemand sie Zeile für Zeile beschreiben musste.
Dieselbe Stärke ist die Schwäche. Ein Golden Master sagt nicht, ob eine Änderung richtig ist — nur, dass es eine gibt. Der Arbeitsablauf lautet deshalb immer: Diff ansehen, entscheiden, und bei gewollter Änderung die Datei neu aufnehmen. Genau dafür gibt es Bibliotheken: syrupy ist die verbreitete für pytest, mit snapshot als Fixture und pytest --snapshot-update zum Neuaufnehmen. Der Gewinn gegenüber der Handarbeit oben ist die Verwaltung — viele Snapshots in einer Datei, brauchbare Diffs für Dicts und Objekte, und ein Befehl zum Aktualisieren. Das Prinzip bleibt dasselbe.
Vier Regeln entscheiden darüber, ob solche Tests helfen oder quälen:
Nichts Schwankendes einfrieren. Zeitstempel, Zufallszahlen, Speicheradressen, Dictionary-Reihenfolgen aus Mengen — alles das macht den Test rot, ohne dass sich etwas geändert hat. Erst injizieren (siehe Zeit und Zufall), dann einfrieren.
Klein halten. Ein Snapshot über 500 Zeilen wird beim ersten roten Lauf nicht gelesen, sondern aktualisiert. Damit ist der Test wertlos.
Nie blind aktualisieren.
--snapshot-updateohne Blick auf den Diff ist dasselbe wie den Test löschen, nur langsamer.Als Netz, nicht als Spezifikation. Ein Golden Master ersetzt keinen Test, der eine Regel festhält. Er ergänzt ihn dort, wo noch niemand die Regeln kennt.
Damit ist das Verfahren vor allem für eines gemacht: Charakterisierungstests für fremden Code, bevor man ihn anfasst. In den Übungen zu Wesen und BamBam ist das der erste Schritt — Verhalten einfrieren, dann refaktorisieren, und der Golden Master meldet, wenn das Refactoring doch keins war.
# Die Änderung an cyoa.py wieder zurücknehmen.
!sed -i 's/Du verirrst dich im Wald. Ende./Du verirrst dich. Ende./' cyoa.py
!uv run pytest -q test_golden.py. [100%]
1 passed in 0.09s
Property-Based Testing mit hypothesis¶
In der Mathematik und Statistik prüft ein Hypothesentest Daten auf Signifikanz. In der Programmierung meinen wir mit Paketen wie hypothesis jedoch etwas anderes: das Property-Based Testing.
Anstatt fixe Eingabewerte wie [0.5, 0.5] manuell vorzugeben, definieren wir Invarianten (Properties). Das Framework generiert dann systematisch Hunderte bis Tausende von (auch unüblichen) Eingabewerten.
Für unsere Entropie-Funktion gilt beispielsweise die mathematische Invariante: für jede gültige Verteilung.
Damit hypothesis nicht nur Eingaben, sondern sinnvolle Eingaben erzeugt, muss irgendwo stehen, was eine gültige Eingabe ist. Genau das leisten Vor- und Nachbedingungen: Im Kapitel Design by Contract schreiben wir sie an die Funktion, und die Werkzeuge dort leiten daraus die Testfälle ab, die wir hier noch von Hand beschreiben.
%%writefile -a test_entropy.py
from hypothesis import given, strategies as st
# Wir generieren zufällige Listen von positiven Fließkommazahlen (Mindestlänge 1, Maximallänge 10)
@given(st.lists(st.floats(min_value=0.01, max_value=1.0), min_size=1, max_size=10))
def test_entropy_is_non_negative(raw_probs):
# Da unsere Funktion normierte Wahrscheinlichkeiten erwartet, normieren wir die Werte vorher:
total = sum(raw_probs)
probs = [p / total for p in raw_probs]
# Die eigentliche Invariante prüfen
assert calculate_entropy(probs) >= 0.0
Appending to test_entropy.py
# Durch Hypothesis laufen jetzt automatisch viele verschiedene Randfälle durch
!uv run pytest -v test_entropy.py============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0 -- /home/user/Sciebo/hhu/eipy-26/eipy-skript/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: jaxtyping-0.3.11, cov-5.0.0, hypothesis-6.152.9, logfire-4.37.0, hydra-core-1.3.2, Faker-40.23.0, gremlins-1.8.1, typeguard-4.5.2, anyio-4.13.0
collected 3 items
test_entropy.py::test_calculate_entropy_fair_coin PASSED [ 33%]
test_entropy.py::test_calculate_entropy_invalid_distribution PASSED [ 66%]
test_entropy.py::test_entropy_is_non_negative PASSED [100%]
============================== 3 passed in 0.20s ===============================
Symbolic Execution und formale Verifikation: CrossHair¶
Während hypothesis den Zustandsraum stochastisch abtastet (Property-Based Testing), wählt CrossHair einen analytischen Ansatz. Es übersetzt Python-Bytecode in logische Formeln und nutzt einen SMT-Solver (Z3), um mathematisch zu beweisen, ob ein Fehlerzustand erreichbar ist.
CrossHair harmoniert perfekt mit Design by Contract. Wir können Vor- und Nachbedingungen direkt im Code definieren:
%%writefile symbolic_math.py
def integer_square_root(n: int) -> int:
"""
Berechnet die ganzzahlige Quadratwurzel.
pre: n >= 0
post: __return__ ** 2 <= n < (__return__ + 1) ** 2
"""
import math
if(n > 0 and n % 121 == 0):
return 11 # absichtlicher Fehler
else:
return math.isqrt(n)Overwriting symbolic_math.py
!uv run crosshair check --per_condition_timeout=3 symbolic_math.py/home/user/Sciebo/hhu/eipy-26/eipy-skript/part-tools/symbolic_math.py:6: error: false when calling integer_square_root(242) (which returns 11)
Mutation Testing: Die Qualität der Tests testen¶
Wer testet eigentlich die Tests? Eine hohe Testabdeckung (Coverage) bedeutet nur, dass der Code ausgeführt wurde, nicht, dass die asserts sinnvoll sind. Mutation Testing verändert den Quellcode (die “Mutanten” – z.B. wird ein > durch ein >= ersetzt oder ein + durch ein -) und führt die Tests erneut aus. Wenn die Tests danach nicht fehlschlagen (der Mutant überlebt), fehlt uns ein spezifischer Testfall. Das Tool mutatest automatisierte dies, wird allerdings nicht mehr aktiv gepflegt. Alternativen sind pytest-gremlins, fest und mutmut, die sich durchaus in ihrer Herangehensweise und damit Performance unterscheiden.
Abschnitt für die pyproject.toml um gremlins zu benutzen:
[tool.pytest.ini_options]
addopts = "--gremlins"
Alternativ kann man direkt beim Aufruf von pytest ein --gremlins angeben.
Man muss ein bisschen aufpassen, denn wenn der Testcode selbst mutiert werden kann, gerade in Kombination mit hypothesis, kann es leicht zu Endlosschleifen kommen. Daher schränkt man ein, welche Dateien mutiert werden sollen (das geht leider nicht per Kommandozeilenoptionen):
[tool.pytest-gremlins]
paths = [ "part-tools/entropy.py" ]
!uv run pytest --gremlins -v test_entropy.py============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0 -- /home/user/Sciebo/hhu/eipy-26/eipy-skript/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: jaxtyping-0.3.11, cov-5.0.0, hypothesis-6.152.9, logfire-4.37.0, hydra-core-1.3.2, Faker-40.23.0, gremlins-1.8.1, typeguard-4.5.2, anyio-4.13.0
collected 3 items
test_entropy.py::test_calculate_entropy_fair_coin PASSED [ 33%]
test_entropy.py::test_calculate_entropy_invalid_distribution PASSED [ 66%]
test_entropy.py::test_entropy_is_non_negative PASSED [100%]Gremlin 1/9: entropy_0617_g001 - running 3/3 tests
Gremlin 2/9: entropy_0617_g002 - running 2/3 tests
Gremlin 3/9: entropy_0617_g003 - running 2/3 tests
Gremlin 4/9: entropy_0617_g004 - running 2/3 tests
Gremlin 5/9: entropy_0617_g005 - running 2/3 tests
Gremlin 6/9: entropy_0617_g006 - running 2/3 tests
Gremlin 7/9: entropy_0617_g007 - running 2/3 tests
Gremlin 8/9: entropy_0617_g008 - running 2/3 tests
Gremlin 9/9: entropy_0617_g009 - running 2/3 tests
======================= pytest-gremlins mutation report ========================
Zapped: 9 gremlins (100%)
Survived: 0 gremlins (0%)
Run with --gremlin-report=html for detailed report.
======================================= =======================================
============================== 3 passed in 2.72s ===============================
Automatisierung und Ökosystem¶
Sobald unsere Test-Suite wächst, wollen wir sie automatisieren und strukturieren.
nox (und tox): Automatisieren das Testen über verschiedene Python-Versionen und getrennte virtuelle Umgebungen hinweg. tox nutzt statische tox.ini-Konfigurationen, während nox reine Python-Skripte (noxfile.py) nutzt.
%%writefile noxfile.py
import nox
# Wir testen gegen zwei Python-Versionen (sofern lokal installiert)
@nox.session(python=["3.12", "3.13"])
def tests(session):
session.install("pytest", "pytest-cov", "hypothesis", "requests")
session.run("pytest", "--cov=entropy")
Writing noxfile.py
# Nox führt nun isolierte Umgebungen aus
!uv run nox -s testsnox > Running session tests-3.12
nox > Creating virtual environment (virtualenv) using python3.12 in .nox/tests-3-12
nox > python -m pip install pytest pytest-cov hypothesis requests
nox > pytest --cov=entropy
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: cov-7.1.0, hypothesis-6.168.1
collecting ... collecting 3 items collected 4 items
test_entropy.py ... [ 75%]
test_entropy_api.py . [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover
--------------------------------
entropy.py 6 0 100%
--------------------------------
TOTAL 6 0 100%
============================== 4 passed in 1.23s ===============================
nox > Session tests-3.12 was successful in 2 minutes.
nox > Running session tests-3.13
nox > Creating virtual environment (virtualenv) using python3.13 in .nox/tests-3-13
nox > python -m pip install pytest pytest-cov hypothesis requests
nox > pytest --cov=entropy
============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/user/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: cov-7.1.0, hypothesis-6.168.1
collecting ... collecting 3 items collected 4 items
test_entropy.py ... [ 75%]
test_entropy_api.py . [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.3-final-0 ________________
Name Stmts Miss Cover
--------------------------------
entropy.py 6 0 100%
--------------------------------
TOTAL 6 0 100%
============================== 4 passed in 0.85s ===============================
nox > Session tests-3.13 was successful in 2 minutes.
nox > Ran 2 sessions in 4 minutes:
nox > * tests-3.12: success, took 2 minutes
nox > * tests-3.13: success, took 2 minutes
Continuous Integration Testing¶
Eine exemplarische .gitlab-ci.yml um nur Commits zu erlauben, die erfolgreich alle Tests bestehen:
stages:
- test
run_tests:
stage: test
image: python:3.13-slim
script:
- pip install uv
- uv tool install nox
- nox -s testsDer Einsatz von Nox bietet hierbei den Vorteil, dass Testing in einer lokalen Umgebung mit hoher Wahrscheinlichkeit zu den gleichen Ergebnissen kommt wie Testing auf dem Gitlab Runner.
Konfigurationsmanagement mit Hydra¶
Insbesondere im Data Science Bereich und bei Deep Learning Modellen (aber auch in großen Test-Setups) stoßen Kommandozeilenargumente (argparse) schnell an ihre Grenzen. Hydra löst dieses Problem durch hierarchische Komposition von YAML-Dateien. Achtung: Das PyPI-Paket heißt hydra-core, während hydra ein veraltetes anderes Paket ist; das Python-Modul heißt dennoch hydra.
Wir definieren eine Basis-Konfiguration:
%%writefile config.yaml
distribution:
type: "coin_flip"
probs: [0.5, 0.5]
Writing config.yaml
%%writefile run_experiment.py
import hydra
from omegaconf import DictConfig
from entropy import calculate_entropy
# Der Dekorator verknüpft die YAML-Datei automatisch mit dem cfg-Objekt
@hydra.main(version_base=None, config_path=".", config_name="config")
def my_experiment(cfg: DictConfig):
print(f"Starte Experiment: {cfg.distribution.type}")
print(f"Berechnete Entropie: {calculate_entropy(cfg.distribution.probs)}")
if __name__ == "__main__":
my_experiment()
Writing run_experiment.py
Der Clou an Hydra ist, dass wir nun Parameter über die Kommandozeile dynamisch überschreiben können, ohne den Code anzufassen:
# Standard-Ausführung mit Werten aus der config.yaml
!uv run python run_experiment.py
# Dynamisches Überschreiben der Parameter via CLI
!uv run python run_experiment.py distribution.type="loaded_die" distribution.probs="[0.1, 0.9]"Starte Experiment: coin_flip
Berechnete Entropie: 1.0
Starte Experiment: loaded_die
Berechnete Entropie: 0.4689955935892812
Exkurs: Die Rolle von OmegaConf¶
In unserem Skript taucht der Typ DictConfig auf, der aus dem Paket OmegaConf stammt. Hydra nutzt OmegaConf unter der Haube als eigentliche Konfigurations-Engine. OmegaConf bietet weit mehr als nur das simple Einlesen von YAML-Dateien:
Variablen-Interpolation: Werte in der Konfiguration können dynamisch aufeinander verweisen (z. B.
log_name: ${distribution.type}_experiment).Structured Configs: Hier schließt sich der Kreis zur Typisierung. Durch die Kombination von OmegaConf mit Pythons
dataclasseslässt sich ein striktes Schema für die Konfiguration definieren. Falsche Datentypen – etwa die Übergabe eines Strings statt einer Liste fürprobs– werden dadurch direkt beim Start des Programms abgefangen, noch bevor die erste Codezeile des eigentlichen Experiments ausgeführt wird.
Ausblick: End-to-End (E2E) Browser-Testing:¶
Geht es über reine Logik hinaus in Richtung Web-Anwendungen, sind Frameworks wie Selenium oder zunehmend Playwright der moderne Standard, um Klicks und User-Interaktionen in echten Browser-Engines programmatisch zu testen. Das ist sogar außerhalb von Tests interessant, wenn man Browser automatisieren möchte.
Empfehlenswerte Literatur:
PyCQA (Python Code Quality Authority): Das Meta-Projekt hinter Tools wie
autoflake,isortundbandit- GitHub.“Obey the Testing Goat”: Ein hervorragendes, kostenfrei online lesbares Buch über TDD in Python (insb. Webentwicklung): obeythetestinggoat
.com. Software Engineering at Google: Speziell das Kapitel 11 (Testing Overview) von Adam Bender.
Zusammenfassung und Kontrollfragen¶
Der Test entsteht zuerst, und er muss aus dem richtigen Grund fehlschlagen — ein Test, der wegen eines Tippfehlers rot ist, prüft später nicht das, was man glaubt.
@pytest.mark.parametrizeund Fixtures ersetzen kopierte Tests;tmp_path,monkeypatchundcapsysdecken genau die Fälle ab, für die man sonst eigene Hilfskonstruktionen baut.Zeit und Zufall werden injiziert, nicht importiert: Ein übergebenes
random.Random(seed)macht aus einem gelegentlich fehlschlagenden Test einen deterministischen.Coverage ist eine Untergrenze und kein Qualitätsmaß — vollständig abgedeckter Code kann vollständig falsch sein, und Mutation Testing ist das Werkzeug, das diese Lücke sichtbar macht.
Ein Golden Master friert Verhalten ein, ohne es zu beschreiben: das richtige Mittel für fremden Code, bevor man ihn ändert, und wertlos, sobald man den Diff ungeprüft aktualisiert.
Kontrollfragen
Ein neu geschriebener Test ist beim ersten Lauf rot. Woran erkennen Sie, dass er aus dem richtigen Grund rot ist, und warum lohnt es sich, das zu prüfen?
Ein Test würfelt und schlägt etwa jedes zwanzigste Mal fehl. Was ändern Sie — und zwar an der getesteten Funktion, nicht am Test?
Ihre Test-Suite erreicht 100 % Coverage, und
pytest --gremlinslässt trotzdem Mutanten überleben. Was bedeutet das, und was fehlt den Tests?
# Aufräumen der temporären Dateien
!rm -f config.yaml cyoa.py data_faker.py entropy.py entropy_api.py noxfile.py run_experiment.py symbolic_math.py \
test_cyoa.py test_entropy.py test_entropy_api.py test_entropy_base.py test_entropy_param.py \
test_fixtures.py test_golden.py test_zufall.py tmp_run.py golden_wald.txt
!rm -rf ./outputs
!rm -rf ./__pycache__ ./.pytest_cache