cat posts/pytest-von-null.md
pytest von Null
Automatische Tests statt Durchklicken: pytest finden, schreiben und ausführen – mit assert, Fixtures, Parametrisierung, tmp_path, monkeypatch und pytest.raises.
In Teil 11 der Python-Serie
ist pytest als nächster Schritt aufgetaucht.
Bisher testen wir den Dungeon vor allem von Hand:
- Programm starten.
- Namen eingeben.
- Gold eingeben.
nimm fackeleintippen.inventarprüfen.speicherntesten.- Programm beenden.
- Neu starten.
ladentesten.
Das funktioniert am Anfang. Aber je mehr Code dazukommt, desto leichter übersiehst Du einen Fall.
Automatische Tests prüfen wiederholbar, ob bestimmte Erwartungen weiterhin gelten. Nach einer Änderung kannst Du mit einem Befehl kontrollieren, ob noch alles funktioniert.
Das bekannteste Test-Werkzeug im Python-Umfeld ist pytest.
Warum Tests?
Ein Test ist ausführbarer Code, der eine Erwartung prüft.
Zum Beispiel:
def test_addition():
assert 2 + 2 == 4
Das wirkt banal, zeigt aber das Prinzip:
- Es gibt eine Annahme.
- Der Code wird ausgeführt.
assertprüft das Ergebnis.- Ist die Annahme falsch, schlägt der Test fehl.
Für unseren Dungeon sind interessantere Fragen:
- Fallen HP nie unter
0? - Steigen HP nie über
max_hp? - Wird ein gültiger Befehl richtig zerlegt?
- Löst ein ungültiger Befehl die erwartete Exception aus?
- Bleibt ein Spielstand nach dem Speichern und Laden erhalten?
- Werden Gegenstände beim Aufnehmen aus dem Raum entfernt?
Solche Regeln möchtest Du nicht nach jeder Änderung manuell durchklicken.
pytest installieren
pytest gehört nicht zur Standardbibliothek. Es ist ein Drittanbieterpaket.
Mit uv:
uv add --dev pytest
Ausführen:
uv run pytest
Klassisch mit venv und pip:
python -m pip install pytest
Ausführen:
python -m pytest
Wenn Du eine aktivierte venv verwendest, funktioniert oft auch:
pytest
Die Schreibweise mit python -m pytest ist aus zwei Gründen robuster. Sie legt das
aktuelle Verzeichnis vorn in den Suchpfad, sodass tests/-Dateien die Module aus
der Projektwurzel importieren können – bei pytest beziehungsweise uv run pytest
scheitert das sonst mit ModuleNotFoundError. Wer die kurzen Aufrufe nutzen will,
trägt einmalig in die pyproject.toml ein:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
Und sie ist robust, weil sie pytest mit genau dem Python startet, das gerade aktiv ist.
Der erste Test
Lege eine Datei test_mathe.py an:
def test_addition():
assert 2 + 2 == 4
def test_string_kleinbuchstaben():
assert "Verlies".lower() == "verlies"
Starte im Projektordner:
python -m pytest
pytest sucht automatisch nach Tests und führt sie aus.
Eine erfolgreiche Ausgabe sieht ungefähr so aus:
============================= test session starts =============================
collected 2 items
test_mathe.py .. [100%]
============================== 2 passed in 0.01s ==============================
Die beiden Punkte stehen für zwei erfolgreiche Tests.
Wie pytest Tests findet
pytest verwendet Namenskonventionen.
Typisch sind Testdateien mit Namen wie:
test_modelle.py
test_speicher.py
test_befehle.py
Oder:
modelle_test.py
speicher_test.py
Darin sucht pytest nach Funktionen, deren Namen mit test_ beginnen:
def test_irgendwas():
...
Eine sinnvolle Projektstruktur wäre:
dungeon-projekt/
├── befehle.py
├── eingabe.py
├── fehler.py
├── modelle.py
├── speicher.py
├── spiel.py
├── welt.py
└── tests/
├── test_befehle.py
├── test_eingabe.py
├── test_modelle.py
└── test_speicher.py
Für kleine Projekte können Tests auch direkt neben den Modulen liegen. Ein
eigener Ordner tests/ wird aber schnell übersichtlicher.
assert: normale Python-Syntax
In pytest verwendest Du normales assert.
def test_addition():
assert 2 + 2 == 4
Du brauchst keine Methoden wie assertEqual(...). Das gehört eher zum
unittest-Stil aus der Standardbibliothek.
Der Vorteil von pytest: Es wertet fehlgeschlagene Assertions sehr lesbar aus.
Dieser Test schlägt fehl:
def test_fehlerhafte_addition():
assert 2 + 2 == 5
pytest zeigt dann nicht nur „falsch“, sondern auch die konkreten Werte:
E assert (2 + 2) == 5
Bei Funktionsaufrufen und Attributzugriffen zeigt pytest zusätzlich die Zwischenwerte:
E assert 'verlie' == 'verlies'
E + where 'verlies' = <built-in method lower of str object>()
Das ist eines der angenehmsten pytest-Features.
Eine echte Methode testen
Nehmen wir die Spieler-Klasse aus der Serie.
In Teil 10 sah sie unter anderem so aus:
class Spieler:
def __init__(self, name, max_hp=100, gold=0):
name = name.strip()
if not name:
raise ValueError("Der Name darf nicht leer sein.")
if max_hp <= 0:
raise ValueError("max_hp muss größer als 0 sein.")
if gold < 0:
raise ValueError("gold darf nicht negativ sein.")
self.name = name
self.hp = max_hp
self.max_hp = max_hp
self.gold = gold
self.inventar = []
def erleide_schaden(self, menge):
if menge < 0:
raise ValueError("Schaden darf nicht negativ sein.")
self.hp = max(self.hp - menge, 0)
def heile(self, menge):
if menge < 0:
raise ValueError("Heilung darf nicht negativ sein.")
self.hp = min(self.hp + menge, self.max_hp)
Dazu schreiben wir Tests in tests/test_modelle.py:
from modelle import Spieler
def test_schaden_reduziert_hp():
spieler = Spieler("Test", max_hp=100)
spieler.erleide_schaden(30)
assert spieler.hp == 70
def test_hp_faellt_nicht_unter_null():
spieler = Spieler("Test", max_hp=10)
spieler.erleide_schaden(999)
assert spieler.hp == 0
def test_heilung_steigt_nicht_ueber_max_hp():
spieler = Spieler("Test", max_hp=100)
spieler.erleide_schaden(50)
spieler.heile(999)
assert spieler.hp == 100
Jeder Test folgt grob diesem Muster:
- Ausgangslage herstellen.
- Aktion ausführen.
- Ergebnis prüfen.
Dieses Muster wird oft auch als Arrange, Act, Assert beschrieben.
Testnamen sollen Erwartungen beschreiben
Ein schlechter Testname:
def test_spieler():
...
Ein besserer Testname:
def test_hp_faellt_nicht_unter_null():
...
Wenn der Test fehlschlägt, zeigt pytest den Namen an. Ein guter Name sagt sofort, welche Erwartung verletzt wurde.
Du musst dabei keine perfekten Romane schreiben. Aber der Name sollte mehr sagen als nur, welche Funktion getestet wird.
Exceptions testen mit pytest.raises
In Teil 8 haben wir eigene Exceptions
und raise eingeführt.
Auch Exceptions lassen sich testen.
Beispiel: Ein Spieler darf keine negativen HP-Regeln erhalten:
import pytest
from modelle import Spieler
def test_negativer_schaden_wirft_value_error():
spieler = Spieler("Test")
with pytest.raises(ValueError):
spieler.erleide_schaden(-10)
Der Test ist erfolgreich, wenn im with-Block ein ValueError ausgelöst wird.
Bleibt die Exception aus, schlägt der Test fehl.
Du kannst auch die Fehlermeldung prüfen:
def test_negativer_schaden_meldet_problem():
spieler = Spieler("Test")
with pytest.raises(ValueError, match="Schaden darf nicht negativ sein"):
spieler.erleide_schaden(-10)
match=... verwendet einen regulären Ausdruck. Für einfache Texte reicht oft ein
eindeutiger Ausschnitt der Meldung.
Eigene Exceptions testen
Für den Dungeon gab es eigene Fehlertypen:
class DungeonError(Exception):
...
class UngueltigerBefehlError(DungeonError):
...
class UnmoeglicheAktionError(DungeonError):
...
Ein ungültiger leerer Befehl sollte beim Zerlegen eine Exception auslösen:
import pytest
from eingabe import teile_befehl
from fehler import UngueltigerBefehlError
def test_leere_eingabe_wirft_ungueltiger_befehl():
with pytest.raises(UngueltigerBefehlError):
teile_befehl("")
Ein unbekannter Ausgang im Raum sollte eine UnmoeglicheAktionError auslösen:
import pytest
from fehler import UnmoeglicheAktionError
from modelle import Raum
def test_unbekannte_richtung_wirft_unmoegliche_aktion():
raum = Raum(
name="Halle",
beschreibung="Eine dunkle Halle.",
ausgaenge={"norden": "bibliothek"},
)
with pytest.raises(UnmoeglicheAktionError):
raum.ziel("unten")
Damit prüfst Du nicht nur, dass etwas schiefgeht, sondern dass es auf die erwartete Weise schiefgeht.
Fixtures: gemeinsame Vorbereitung
Wenn mehrere Tests denselben Aufbau benötigen, kannst Du ihn in eine Fixture auslagern.
Ohne Fixture:
from modelle import Spieler
def test_schaden_reduziert_hp():
spieler = Spieler("Test", max_hp=100)
spieler.erleide_schaden(30)
assert spieler.hp == 70
def test_heilung_steigt_nicht_ueber_max_hp():
spieler = Spieler("Test", max_hp=100)
spieler.erleide_schaden(50)
spieler.heile(999)
assert spieler.hp == 100
Mit Fixture:
import pytest
from modelle import Spieler
@pytest.fixture
def spieler():
return Spieler("Test", max_hp=100)
def test_schaden_reduziert_hp(spieler):
spieler.erleide_schaden(30)
assert spieler.hp == 70
def test_heilung_steigt_nicht_ueber_max_hp(spieler):
spieler.erleide_schaden(50)
spieler.heile(999)
assert spieler.hp == 100
pytest sieht, dass der Test einen Parameter spieler besitzt. Dann sucht pytest
nach einer Fixture mit genau diesem Namen und übergibt ihren Rückgabewert.
Fixtures sind standardmäßig frisch pro Test
Wichtig: Eine Fixture mit Standard-Scope wird für jeden Test neu ausgeführt.
Das bedeutet:
def test_eins(spieler):
spieler.erleide_schaden(50)
assert spieler.hp == 50
def test_zwei(spieler):
assert spieler.hp == 100
test_zwei bekommt einen frischen Spieler. Der Schaden aus test_eins wirkt
nicht nach.
Das ist gut. Tests sollten sich nicht gegenseitig beeinflussen.
Dieses Verhalten hat einen Namen: Der Standard-Scope heißt function. Du kannst
ihn ausdrücklich hinschreiben:
@pytest.fixture(scope="function")
def spieler():
return Spieler("Test", max_hp=100)
Daneben gibt es class, module, package und session. Eine Fixture mit
scope="session" läuft einmal für den gesamten Testlauf – praktisch für teure
Dinge wie einen Testserver, aber gefährlich für alles, was ein Test verändern
könnte. Für den Einstieg ist der Standard pro Test fast immer richtig.
Fixtures können Fixtures verwenden
Fixtures dürfen andere Fixtures als Parameter anfordern.
import pytest
from modelle import Gegenstand, Raum
@pytest.fixture
def fackel():
return Gegenstand(
kennung="fackel",
name="Fackel",
beschreibung="Eine rußige Fackel.",
)
@pytest.fixture
def halle(fackel):
return Raum(
name="Halle",
beschreibung="Eine dunkle Halle.",
ausgaenge={"norden": "bibliothek"},
gegenstaende=[fackel],
)
def test_raum_enthaelt_fackel(halle):
assert len(halle.gegenstaende) == 1
assert halle.gegenstaende[0].name == "Fackel"
pytest baut automatisch die benötigte Fixture-Kette auf.
Aufräumen mit yield-Fixtures
Manche Vorbereitungen müssen hinterher wieder rückgängig gemacht werden: eine geöffnete Datei gehört geschlossen, eine gestartete Verbindung getrennt, eine geänderte Einstellung zurückgesetzt.
Dafür gibt es yield statt return:
import pytest
@pytest.fixture
def logdatei(tmp_path):
pfad = tmp_path / "spiel.log"
handle = pfad.open("w", encoding="utf-8")
yield handle
handle.close()
Alles vor dem yield ist der Aufbau. Der Wert hinter yield geht an den Test.
Alles nach dem yield läuft, wenn der Test fertig ist – das ist der Teardown.
def test_schreibt_ins_log(logdatei):
logdatei.write("Spiel gestartet\n")
assert not logdatei.closed
Nach dem Test ist der Handle geschlossen, ohne dass der Test sich darum kümmern musste.
Zwei Eigenschaften machen das wertvoll:
Der Teardown läuft auch, wenn der Test fehlschlägt. Genau darin liegt der Unterschied zu Aufräumcode am Ende der Testfunktion – der wird bei einer fehlgeschlagenen Assertion nämlich übersprungen.
Der Zeitpunkt hängt am Scope. Bei scope="function" räumt pytest nach jedem
Test auf, bei scope="session" erst am Ende des gesamten Laufs.
Wenn Dir das bekannt vorkommt: Es ist dasselbe Muster wie bei einem
Kontextmanager mit with. Der Unterschied ist nur, dass pytest das Betreten und
Verlassen für Dich übernimmt.
Gemeinsame Fixtures in conftest.py
Bisher stand jede Fixture in derselben Datei wie ihre Tests. Sobald mehrere Testdateien dieselbe Vorbereitung brauchen, willst Du sie nicht dreimal kopieren.
Dafür gibt es eine Datei mit festem Namen: conftest.py. Fixtures, die dort
stehen, kennt pytest in allen Tests desselben Verzeichnisses und aller
Unterverzeichnisse – ganz ohne Import.
# tests/conftest.py
import pytest
from modelle import Spieler
@pytest.fixture
def spieler():
return Spieler("Test", max_hp=100)
Damit funktioniert die Fixture in jeder Testdatei unter tests/:
# tests/test_modelle.py
def test_schaden_reduziert_hp(spieler):
spieler.erleide_schaden(30)
assert spieler.hp == 70
# tests/test_speicher.py
from speicher import speichere_spielstand
from welt import erstelle_raeume
def test_spielstand_enthaelt_den_namen(spieler, tmp_path):
speicherdatei = tmp_path / "spielstand.json"
speichere_spielstand(
spieler=spieler,
ort="halle",
raeume=erstelle_raeume(),
besuchte_raeume={"halle"},
speicherdatei=speicherdatei,
)
assert "Test" in speicherdatei.read_text(encoding="utf-8")
Kein import, keine Wiederholung. Das ist kein Zufall, sondern der Zweck der
Datei: pytest sammelt conftest.py automatisch ein, bevor die Tests laufen.
Wichtig dabei:
- Nicht importieren.
from conftest import spielerist falsch und führt zu merkwürdigen Fehlern. pytest löst Fixtures über den Namen auf. - Mehrere Ebenen sind erlaubt. Eine
conftest.pyintests/gilt für alle Tests darunter, eine zusätzliche intests/integration/nur dort. Die nähere gewinnt. conftest.pyist auch der Ort für Test-Konfiguration, etwa eigene Marker oder Hooks. Für den Einstieg reichen Fixtures.
Eine leere conftest.py in der Projektwurzel hat übrigens einen praktischen
Nebeneffekt: Sie markiert die Wurzel für pytest, sodass Deine Module aus tests/
importierbar sind – die Alternative zum pythonpath-Eintrag von oben.
Parametrisierung: ein Test, viele Fälle
Manchmal ist derselbe Test mit mehreren Eingaben sinnvoll.
Ohne Parametrisierung:
def test_schaden_30():
spieler = Spieler("Test", max_hp=100)
spieler.erleide_schaden(30)
assert spieler.hp == 70
def test_schaden_999():
spieler = Spieler("Test", max_hp=10)
spieler.erleide_schaden(999)
assert spieler.hp == 0
Mit Parametrisierung:
import pytest
from modelle import Spieler
@pytest.mark.parametrize(
"max_hp, schaden, erwartet",
[
(100, 30, 70),
(10, 999, 0),
(50, 0, 50),
],
)
def test_schaden(max_hp, schaden, erwartet):
spieler = Spieler("Test", max_hp=max_hp)
spieler.erleide_schaden(schaden)
assert spieler.hp == erwartet
pytest führt diesen Test dreimal aus, einmal pro Datensatz.
Schlägt eine Kombination fehl, zeigt pytest genau an, welche Parameter betroffen waren.
Parametrisierung mit IDs
Bei mehreren Fällen kann die Ausgabe lesbarer werden, wenn Du IDs vergibst:
@pytest.mark.parametrize(
"max_hp, schaden, erwartet",
[
(100, 30, 70),
(10, 999, 0),
(50, 0, 50),
],
ids=[
"normaler-schaden",
"nicht-unter-null",
"kein-schaden",
],
)
def test_schaden(max_hp, schaden, erwartet):
spieler = Spieler("Test", max_hp=max_hp)
spieler.erleide_schaden(schaden)
assert spieler.hp == erwartet
In der Ausgabe erscheinen dann sprechendere Testfälle.
Das ist besonders praktisch, wenn viele Werte kombiniert werden.
Ausgaben testen mit capsys
Manchmal möchtest Du prüfen, was eine Funktion mit print(...) ausgibt.
pytest stellt dafür die Fixture capsys bereit.
Beispiel mit zeige_inventar(...):
from modelle import Gegenstand, Spieler
def test_zeige_leeres_inventar(capsys):
spieler = Spieler("Test")
spieler.zeige_inventar()
ausgabe = capsys.readouterr()
assert "Dein Inventar ist leer." in ausgabe.out
def test_zeige_inventar_mit_gegenstand(capsys):
spieler = Spieler("Test")
spieler.nimm(
Gegenstand(
kennung="fackel",
name="Fackel",
)
)
spieler.zeige_inventar()
ausgabe = capsys.readouterr()
assert "Inventar:" in ausgabe.out
assert "Fackel" in ausgabe.out
capsys.readouterr() liefert ein Objekt mit:
.outfür stdout,.errfür stderr.
Für reine Logik ist es oft besser, Funktionen Werte zurückgeben zu lassen, statt
Ausgaben zu testen. Aber bei CLI- und Terminalcode ist capsys sehr hilfreich.
Eingaben testen mit monkeypatch
Funktionen mit input(...) sind schwerer zu testen, weil sie interaktiv warten.
Die Funktion aus Teil 10:
def frage_name(text):
while True:
name = input(text).strip()
if name:
return name
print("Der Name darf nicht leer sein.")
Mit monkeypatch kannst Du input(...) im Test ersetzen:
from eingabe import frage_name
def test_frage_name_liefert_eingabe(monkeypatch):
monkeypatch.setattr("builtins.input", lambda text: "Karl")
name = frage_name("Name: ")
assert name == "Karl"
Für mehrere Eingaben verwendest Du einen Iterator:
from eingabe import frage_name
def test_frage_name_fragt_bei_leerem_namen_erneut(monkeypatch, capsys):
eingaben = iter(["", "Karl"])
monkeypatch.setattr("builtins.input", lambda text: next(eingaben))
name = frage_name("Name: ")
ausgabe = capsys.readouterr()
assert name == "Karl"
assert "Der Name darf nicht leer sein." in ausgabe.out
monkeypatch setzt die Änderung nach dem Test automatisch zurück.
Das ist wichtig: Andere Tests sollen danach wieder das echte input(...)
verwenden.
Temporäre Dateien mit tmp_path
Für Speicherfunktionen möchtest Du nicht versehentlich echte Dateien im Projektordner überschreiben.
pytest stellt dafür tmp_path bereit.
tmp_path ist ein temporärer Ordner als Path-Objekt.
def test_tmp_path(tmp_path):
datei = tmp_path / "beispiel.txt"
datei.write_text("Hallo\n", encoding="utf-8")
assert datei.read_text(encoding="utf-8") == "Hallo\n"
Nach dem Test kümmert sich pytest um den temporären Bereich.
Das ist ideal für Tests rund um:
- JSON-Dateien,
- Spielstände,
- Logs,
- Exporte,
- temporäre Konfigurationen.
Speicherlogik testen
Unsere Speicherfunktion aus Teil 9 verwendet eine Konstante wie:
SPEICHERDATEI = Path("spielstand.json")
Für Tests wäre es besser, wenn Funktionen den Pfad als Argument bekommen könnten. Dann müsste man nichts patchen.
Eine testfreundliche Variante wäre:
def speichere_spielstand(
spieler,
ort,
raeume,
besuchte_raeume,
speicherdatei,
):
daten = erstelle_spielstand(
spieler=spieler,
ort=ort,
raeume=raeume,
besuchte_raeume=besuchte_raeume,
)
with speicherdatei.open("w", encoding="utf-8") as datei:
json.dump(daten, datei, ensure_ascii=False, indent=2)
datei.write("\n")
Dann kann der Test tmp_path übergeben:
from modelle import Spieler
from speicher import speichere_spielstand
from welt import erstelle_raeume
def test_speichere_spielstand_schreibt_datei(tmp_path):
speicherdatei = tmp_path / "spielstand.json"
spieler = Spieler("Test", gold=50)
raeume = erstelle_raeume()
speichere_spielstand(
spieler=spieler,
ort="halle",
raeume=raeume,
besuchte_raeume={"halle"},
speicherdatei=speicherdatei,
)
assert speicherdatei.exists()
Das zeigt einen wichtigen Punkt:
Gut testbarer Code nimmt Abhängigkeiten als Argumente entgegen, statt sie tief im Inneren fest zu verdrahten.
Wenn Du die Funktion aus Teil 9 nicht umbauen willst, kannst Du mit
monkeypatch die Konstante SPEICHERDATEI ersetzen. Langfristig ist ein
expliziter Parameter aber meist sauberer.
Tests für reine Funktionen sind einfacher
Diese Funktion ist leicht zu testen:
def teile_befehl(eingabe):
verb, _, argument = eingabe.strip().lower().partition(" ")
if not verb:
raise UngueltigerBefehlError(
"Bitte gib einen Befehl ein."
)
return verb, argument.strip()
Tests:
import pytest
from eingabe import teile_befehl
from fehler import UngueltigerBefehlError
def test_teile_befehl_ohne_argument():
verb, argument = teile_befehl("inventar")
assert verb == "inventar"
assert argument == ""
def test_teile_befehl_mit_argument():
verb, argument = teile_befehl("nimm fackel")
assert verb == "nimm"
assert argument == "fackel"
def test_teile_befehl_entfernt_leerzeichen():
verb, argument = teile_befehl(" nimm fackel ")
assert verb == "nimm"
assert argument == "fackel"
def test_teile_befehl_leer_wirft():
with pytest.raises(UngueltigerBefehlError):
teile_befehl(" ")
Diese Tests brauchen kein input(...), kein Dateisystem und keine echte
Spielrunde. Genau deshalb ist die Aufteilung in kleine Funktionen so wertvoll.
Was ist ein guter Test?
Ein guter Test ist:
- verständlich benannt,
- unabhängig von anderen Tests,
- schnell,
- möglichst klein,
- auf eine klare Erwartung fokussiert,
- deterministisch,
- hilfreich beim Fehlschlag.
Deterministisch bedeutet: Der Test sollte bei gleichem Code immer dasselbe Ergebnis liefern.
Wenn ein Test nur manchmal fehlschlägt, kannst Du ihm nicht vertrauen.
Zufall, Uhrzeit, Netzwerk, Dateisystem und globale Zustände machen Tests oft komplizierter. Deshalb werden sie in Tests häufig kontrolliert, ersetzt oder in temporäre Bereiche gelenkt.
Was sollte man testen?
Teste vor allem Regeln und Verhalten.
Gute Kandidaten:
- Schadensberechnung
- Heilung
- Befehlszerlegung
- erlaubte und verbotene Richtungen
- Gegenstände aufnehmen
- Speichern und Laden
- Fehlerfälle
- Randfälle
Weniger sinnvoll:
- jede einzelne triviale Zuweisung
- interne Implementierungsdetails
- exakt formatierte Ausgaben, wenn sie sich häufig ändern
- fremde Bibliotheken statt Deines Codes
Ein Test sollte nicht nur den aktuellen Code nacherzählen. Er sollte eine fachliche Erwartung absichern.
Tests und Refactoring
Tests sind besonders wertvoll beim Refactoring.
Refactoring bedeutet: Du änderst die Struktur des Codes, ohne das gewünschte Verhalten zu ändern.
Beispiele:
- Eine lange Funktion in mehrere kleine Funktionen zerlegen.
- Eine Klasse in ein anderes Modul verschieben.
- Speicherlogik von Dateipfad-Konstanten auf Parameter umstellen.
- Ein Dictionary durch eine Dataclass ersetzen.
Ohne Tests musst Du danach alles manuell prüfen.
Mit Tests kannst Du nach dem Umbau ausführen:
python -m pytest
Wenn alles grün bleibt, ist die Chance hoch, dass das Verhalten erhalten blieb.
pytest kurz ausgeben lassen
pytest kann sehr ausführlich sein. Für den Alltag ist oft diese Variante angenehm:
python -m pytest -q
-q steht für quiet.
Für mehr Details:
python -m pytest -v
-v steht für verbose und zeigt einzelne Testnamen ausführlicher an.
Nur eine Datei testen:
python -m pytest tests/test_modelle.py
Nur einen bestimmten Test:
python -m pytest tests/test_modelle.py::test_hp_faellt_nicht_unter_null
Das ist praktisch, wenn Du gerade an einem bestimmten Fehler arbeitest.
Tests überspringen
Manchmal soll ein Test vorübergehend übersprungen werden.
import pytest
@pytest.mark.skip(reason="Wird später implementiert")
def test_mehrere_spielstaende():
...
Oder abhängig von einer Bedingung:
import sys
import pytest
@pytest.mark.skipif(
sys.platform == "win32",
reason="Pfadverhalten wird hier nur für Unix getestet",
)
def test_unix_pfad():
...
Für den Einstieg solltest Du Skip-Markierungen sparsam verwenden. Ein dauerhaft übersprungener Test prüft nichts.
Erwartete Fehlschläge mit xfail
Manchmal dokumentierst Du einen bekannten Bug oder ein noch nicht erfülltes Verhalten:
import pytest
@pytest.mark.xfail(reason="Mehrere Spielstände sind noch nicht implementiert")
def test_mehrere_spielstaende():
...
xfail bedeutet: Dieser Test darf aktuell fehlschlagen.
Das kann in größeren Projekten nützlich sein. Für Lernprojekte ist es meist besser, den Test erst zu schreiben, wenn Du das Verhalten wirklich angehen möchtest.
Tests im Dungeon-Projekt
Eine sinnvolle erste Testsuite für den Dungeon könnte so aussehen:
tests/
├── conftest.py
├── test_befehle.py
├── test_eingabe.py
├── test_modelle.py
└── test_speicher.py
In conftest.py landen die Fixtures, die mehrere dieser Dateien brauchen. Die
Beispiele unten legen ihre Objekte der Deutlichkeit halber direkt im Test an.
tests/test_modelle.py
import pytest
from fehler import UnmoeglicheAktionError
from modelle import Gegenstand, Raum, Spieler
def test_schaden_reduziert_hp():
spieler = Spieler("Test", max_hp=100)
spieler.erleide_schaden(30)
assert spieler.hp == 70
def test_hp_faellt_nicht_unter_null():
spieler = Spieler("Test", max_hp=10)
spieler.erleide_schaden(999)
assert spieler.hp == 0
def test_heilung_steigt_nicht_ueber_max_hp():
spieler = Spieler("Test", max_hp=100)
spieler.erleide_schaden(50)
spieler.heile(999)
assert spieler.hp == 100
def test_raum_ziel_fuer_gueltige_richtung():
raum = Raum(
name="Halle",
beschreibung="Eine dunkle Halle.",
ausgaenge={"norden": "bibliothek"},
)
assert raum.ziel("norden") == "bibliothek"
def test_raum_ziel_fuer_ungueltige_richtung_wirft():
raum = Raum(
name="Halle",
beschreibung="Eine dunkle Halle.",
ausgaenge={"norden": "bibliothek"},
)
with pytest.raises(UnmoeglicheAktionError):
raum.ziel("unten")
def test_nimm_gegenstand_entfernt_gegenstand_aus_raum():
fackel = Gegenstand(
kennung="fackel",
name="Fackel",
)
raum = Raum(
name="Halle",
beschreibung="Eine dunkle Halle.",
gegenstaende=[fackel],
)
gegenstand = raum.nimm_gegenstand("fackel")
assert gegenstand == fackel
assert raum.gegenstaende == []
tests/test_eingabe.py
import pytest
from eingabe import frage_name, teile_befehl
from fehler import UngueltigerBefehlError
def test_teile_befehl_ohne_argument():
verb, argument = teile_befehl("inventar")
assert verb == "inventar"
assert argument == ""
def test_teile_befehl_mit_argument():
verb, argument = teile_befehl("nimm fackel")
assert verb == "nimm"
assert argument == "fackel"
def test_teile_befehl_leer_wirft():
with pytest.raises(UngueltigerBefehlError):
teile_befehl(" ")
def test_frage_name_liefert_eingabe(monkeypatch):
monkeypatch.setattr("builtins.input", lambda text: "Karl")
name = frage_name("Name: ")
assert name == "Karl"
tests/test_befehle.py
from befehle import finde_raeume_mit_loot
from modelle import Gegenstand, Raum
def test_finde_raeume_mit_loot():
raeume = {
"halle": Raum(
name="Halle",
beschreibung="Eine dunkle Halle.",
gegenstaende=[
Gegenstand(
kennung="fackel",
name="Fackel",
)
],
),
"krypta": Raum(
name="Krypta",
beschreibung="Eine kalte Krypta.",
),
}
assert finde_raeume_mit_loot(raeume) == ["Halle"]
Diese Tests sind noch nicht vollständig. Aber sie sichern bereits wichtige Grundregeln des Spiels ab.
Tests mit uv ausführen
Wenn Du uv verwendest:
uv add --dev pytest
Dann:
uv run pytest
Oder etwas kompakter:
uv run pytest -q
Für CI wäre später typisch:
uv sync --locked
uv run pytest
Wenn Ruff im Projekt ist:
uv run ruff check .
uv run ruff format --check .
uv run pytest
Damit prüfst Du Stil, Formatierung und Verhalten.
Stolperfallen
-
Falsche Namenskonvention: Wenn pytest nichts findet, heißen Datei oder Funktion oft nicht
test_.... -
assertEqual(...)aus Gewohnheit verwenden: In pytest reicht normalesassert. -
Zu viel in einem Test prüfen: Wenn ein Test alles auf einmal macht, ist der Fehlschlag schwerer zu verstehen.
-
Tests voneinander abhängig machen: Jeder Test sollte unabhängig laufen. Verlasse Dich nicht auf eine bestimmte Reihenfolge.
-
Globalen State verändern: Wenn ein Test globale Werte verändert, kann das andere Tests beeinflussen.
-
Echte Dateien im Projektordner überschreiben: Verwende für Dateitests
tmp_path. -
Interaktive Eingaben direkt testen wollen: Ersetze
input(...)im Test mitmonkeypatchoder trenne Eingabe und Logik besser. -
Zufall unkontrolliert lassen: Zufällige Tests sind schwer reproduzierbar. Nutze feste Daten oder einen kontrollierten Zufallsgenerator.
-
Nur den glücklichen Pfad testen: Fehlerfälle sind mindestens genauso wichtig.
-
Print-Ausgaben statt Rückgabewerte testen müssen: Wenn möglich, gib Werte zurück und teste diese. Ausgaben lassen sich mit
capsystesten, sind aber oft fragiler. -
Implementierung statt Verhalten testen: Ein Test sollte möglichst prüfen, was eine Funktion verspricht, nicht jedes interne Detail.
-
Zu spät mit Tests anfangen: Ein paar Tests früh im Projekt machen späteres Refactoring deutlich entspannter.
Übungen
1. Einen ersten Test schreiben
Schreibe eine Funktion heile(hp, menge, max_hp), die nicht über max_hp
hinaus heilt, und teste sie.
Lösung
def heile(hp, menge, max_hp):
return min(hp + menge, max_hp)
def test_heile_nicht_ueber_max_hp():
assert heile(90, 20, 100) == 100
python -m pytest
2. Eine Exception testen
Teste, dass int("viel") einen ValueError auslöst.
Lösung
import pytest
def test_int_mit_text_wirft_value_error():
with pytest.raises(ValueError):
int("viel")
3. Eine Fixture verwenden
Erstelle eine Fixture spieler, die einen Spieler mit max_hp=100 zurückgibt.
Teste damit, dass Schaden die HP reduziert.
Lösung
import pytest
from modelle import Spieler
@pytest.fixture
def spieler():
return Spieler("Test", max_hp=100)
def test_schaden_reduziert_hp(spieler):
spieler.erleide_schaden(30)
assert spieler.hp == 70
4. Einen Test parametrisieren
Teste mehrere Schadensfälle mit pytest.mark.parametrize.
Lösung
import pytest
from modelle import Spieler
@pytest.mark.parametrize(
"max_hp, schaden, erwartet",
[
(100, 30, 70),
(10, 999, 0),
(50, 0, 50),
],
)
def test_schaden(max_hp, schaden, erwartet):
spieler = Spieler("Test", max_hp=max_hp)
spieler.erleide_schaden(schaden)
assert spieler.hp == erwartet
5. Eine Ausgabe testen
Teste mit capsys, dass eine Funktion begruesse("Karl") den Text
Hallo, Karl! ausgibt.
Lösung
def begruesse(name):
print(f"Hallo, {name}!")
def test_begruesse_gibt_text_aus(capsys):
begruesse("Karl")
ausgabe = capsys.readouterr()
assert "Hallo, Karl!" in ausgabe.out
6. Eine temporäre Datei verwenden
Schreibe einen Test, der mit tmp_path eine Datei erzeugt und wieder liest.
Lösung
def test_datei_schreiben_und_lesen(tmp_path):
datei = tmp_path / "notiz.txt"
datei.write_text("Hallo\n", encoding="utf-8")
assert datei.read_text(encoding="utf-8") == "Hallo\n"
Vertiefung
- Python lernen, Teil 8: Wenn's schiefgeht
- Python lernen, Teil 11: Werkzeugkasten und Ausblick
- uv: pip und venv in schnell
- Ruff: Linter und Formatter in einem
Fazit
pytest macht aus manuellem Durchprobieren wiederholbare Prüfungen.
Der Kern ist einfach:
def test_etwas():
assert erwartung
Dazu kommen praktische Werkzeuge:
pytest.raises(...)für Exceptions,- Fixtures für gemeinsamen Aufbau,
- Parametrisierung für mehrere Fälle,
capsysfür Ausgaben,monkeypatchfür ersetzte Abhängigkeiten,tmp_pathfür temporäre Dateien.
Du musst nicht sofort alles testen. Beginne mit den wichtigsten Regeln Deines Codes: Schadensberechnung, Heilung, Befehlszerlegung, Speichern und Laden.
Jeder gute Test ist ein kleines Sicherheitsnetz. Je größer das Projekt wird, desto wertvoller wird dieses Netz.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.