Zum Inhalt springen

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:

  1. Programm starten.
  2. Namen eingeben.
  3. Gold eingeben.
  4. nimm fackel eintippen.
  5. inventar prüfen.
  6. speichern testen.
  7. Programm beenden.
  8. Neu starten.
  9. laden testen.

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.
  • assert prü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:

  1. Ausgangslage herstellen.
  2. Aktion ausführen.
  3. 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 spieler ist falsch und führt zu merkwürdigen Fehlern. pytest löst Fixtures über den Namen auf.
  • Mehrere Ebenen sind erlaubt. Eine conftest.py in tests/ gilt für alle Tests darunter, eine zusätzliche in tests/integration/ nur dort. Die nähere gewinnt.
  • conftest.py ist 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:

  • .out für stdout,
  • .err fü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 normales assert.

  • 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 mit monkeypatch oder 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 capsys testen, 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
Ausführen:
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

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,
  • capsys für Ausgaben,
  • monkeypatch für ersetzte Abhängigkeiten,
  • tmp_path fü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.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!