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 hauptsächlich von Hand:

  1. Programm starten.
  2. Namen und Gold eingeben.
  3. nimm fackel ausprobieren.
  4. Inventar kontrollieren.
  5. Speichern.
  6. Programm neu starten.
  7. Spielstand laden.
  8. Prüfen, ob noch alles stimmt.

Das funktioniert erstaunlich lange. Irgendwann wird daraus aber eine Tätigkeit, die man entweder ständig wiederholt oder zunehmend weglässt.

Genau hier kommen automatische Tests ins Spiel.

Statt nach jeder Änderung den kompletten Dungeon durchzuklicken, können wir Regeln als Python-Code formulieren:

def test_hp_faellt_nicht_unter_null():
    spieler = Spieler("Test", max_hp=10)

    spieler.erleide_schaden(999)

    assert spieler.hp == 0

Danach genügt:

pytest

und diese Erwartung wird bei jedem Testlauf erneut geprüft.

Das Werkzeug, das wir dafür verwenden, ist pytest.

Was ein Test eigentlich macht

Ein Test ist zunächst nur ausführbarer Code mit einer Erwartung.

Das kleinste Beispiel:

def test_addition():
    assert 2 + 2 == 4

Der interessante Teil ist:

assert 2 + 2 == 4

Ist der Ausdruck wahr, läuft der Test weiter.

Ist er falsch, entsteht ein AssertionError und pytest meldet den Test als fehlgeschlagen.

Für unseren Dungeon lassen sich damit wesentlich interessantere Regeln absichern:

HP fallen nie unter 0.
HP steigen beim Heilen nie über max_hp.
Ein leerer Befehl wird abgelehnt.
Eine gültige Richtung führt in den richtigen Raum.
Aufgenommene Gegenstände verschwinden aus dem Raum.
Ein gespeicherter Spielstand lässt sich wieder laden.

Solche Regeln möchtest Du nicht nach jeder Änderung von Hand überprüfen.

pytest installieren

pytest gehört nicht zur Standardbibliothek.

In einem uv-Projekt würde ich es als Development Dependency hinzufügen:

uv add --dev pytest

Danach:

uv run pytest

Mit einem klassischen Virtual Environment:

python -m pip install pytest

und anschließend:

python -m pytest

Wenn das Environment aktiviert ist, funktioniert normalerweise auch:

pytest

Welche Variante Du verwendest, hängt vom Projektworkflow ab.

In einem uv-Projekt ist:

uv run pytest

die natürliche Form.

pytest und python -m pytest

Diese beiden Befehle sind fast identisch:

pytest
python -m pytest

Es gibt aber einen Unterschied.

Bei:

python -m pytest

fügt Python das aktuelle Arbeitsverzeichnis zusätzlich zu sys.path hinzu.

Bei einem einfachen flachen Lernprojekt kann das dazu führen, dass lokale Module wie:

modelle.py
eingabe.py

direkt importierbar sind:

from modelle import Spieler

Darauf würde ich aber keine größere Projektstruktur aufbauen.

Für ein richtiges Package ist es sauberer, das Projekt installierbar zu machen und die Tests gegen dieses installierte Package laufen zu lassen.

Eine brauchbare Projektstruktur

Für unseren bisherigen Lern-Dungeon kann eine einfache Struktur noch so aussehen:

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

Dann kann:

python -m pytest

für den Einstieg völlig ausreichen.

Bei einem installierbaren Projekt würde ich dagegen ein src/-Layout bevorzugen:

dungeon-projekt/
├── pyproject.toml
├── src/
│   └── dungeon/
│       ├── __init__.py
│       ├── befehle.py
│       ├── eingabe.py
│       ├── modelle.py
│       └── speicher.py
└── tests/
    ├── test_befehle.py
    ├── test_eingabe.py
    └── test_modelle.py

Die Tests importieren dann:

from dungeon.modelle import Spieler

statt sich darauf zu verlassen, dass zufällig das Repository-Verzeichnis im Importpfad liegt.

Wie ein solches Projekt mit uv aufgebaut wird, findest Du in uv: pip und venv in schnell.

pytest konfigurieren

Seit pytest 9 kann pytest native TOML-Konfiguration direkt aus der pyproject.toml lesen.

Für ein neues Projekt ist beispielsweise:

[tool.pytest]
testpaths = ["tests"]
addopts = ["--import-mode=importlib"]
strict = true

eine interessante Ausgangsbasis.

testpaths sagt pytest:

Suche Tests standardmäßig unter tests/.

Mit:

--import-mode=importlib

importiert pytest die Testmodule, ohne dafür während der Collection ständig Verzeichnisse in sys.path einzufügen.

Genau diesen Import Mode empfiehlt die pytest-Dokumentation für neue Projekte.

strict = true aktiviert mehrere strengere Prüfungen. Unter pytest 9 gehört dazu unter anderem eine striktere Behandlung von:

Konfigurationsoptionen
Markern
Parametrisierungs-IDs
xfail

Das hilft dabei, Tippfehler nicht unbemerkt durchgehen zu lassen.

Ältere pytest-Konfigurationen

In vielen bestehenden Projekten wirst Du noch diese Form sehen:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
    "--import-mode=importlib",
]

Sie wird weiterhin unterstützt und existiert schon wesentlich länger.

Die native:

[tool.pytest]

-Konfiguration benötigt pytest 9 oder neuer.

Wenn ein Projekt bewusst ältere pytest-Versionen unterstützt, bleibt [tool.pytest.ini_options] deshalb relevant.

Der erste Test

Lege eine Datei:

tests/test_mathe.py

an:

def test_addition():
    assert 2 + 2 == 4


def test_string_kleinbuchstaben():
    assert "Verlies".lower() == "verlies"

Starte:

uv run pytest

oder:

python -m pytest

Eine erfolgreiche Ausgabe sieht ungefähr so aus:

============================= test session starts =============================
collected 2 items

tests/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.

Standardmäßig sucht es unter anderem nach Dateien wie:

test_modelle.py
test_speicher.py
befehle_test.py

und darin nach Testfunktionen:

def test_schaden_reduziert_hp():
    ...

Auch Testklassen sind möglich:

class TestSpieler:
    def test_schaden_reduziert_hp(self):
        ...

Für die meisten Tests in diesem Artikel brauchen wir aber keine Klassen.

Normale Funktionen sind einfacher.

assert: normale Python-Syntax

Einer der angenehmen Punkte an pytest ist, dass Du normales Python-assert verwendest:

def test_addition():
    assert 2 + 2 == 4

Du brauchst kein:

self.assertEqual(...)

wie bei klassischen unittest.TestCase-Tests.

Noch wichtiger: pytest schreibt Assertions beim Import der Tests um und kann dadurch beim Fehlschlag zusätzliche Informationen anzeigen.

Dieser Test:

def test_fehler():
    erwartet = 5
    ergebnis = 2 + 2

    assert ergebnis == erwartet

liefert eine Ausgabe, aus der die beteiligten Werte erkennbar sind:

E       assert 4 == 5

Bei Listen, Strings, Dictionaries und anderen Strukturen zeigt pytest häufig noch wesentlich hilfreichere Diffs.

Eine echte Methode testen

Nehmen wir die Spieler-Klasse aus der Python-Serie:

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:

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

Das Muster dahinter ist immer ähnlich:

Ausgangslage
    ↓
Aktion
    ↓
Erwartung

Dafür wird häufig der Begriff Arrange, Act, Assert verwendet.

Du musst diese drei Phasen nicht mit Kommentaren markieren. Ein bisschen Abstand im Code macht die Struktur oft schon deutlich genug.

Gute Testnamen beschreiben Verhalten

Dieser Name sagt kaum etwas:

def test_spieler():
    ...

Dieser schon:

def test_hp_faellt_nicht_unter_null():
    ...

Wenn ein Test später in der CI fehlschlägt, liest Du vielleicht nur:

FAILED tests/test_modelle.py::test_hp_faellt_nicht_unter_null

Ein guter Name erklärt bereits, welche Regel verletzt wurde.

Testnamen dürfen deshalb ruhig etwas länger sein als normale Funktionsnamen.

Exceptions mit pytest.raises() testen

Auch erwartete Exceptions gehören zur öffentlichen Funktionalität Deines Codes.

Ein Spieler darf beispielsweise keinen negativen Schaden 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 besteht nur dann, wenn innerhalb des with-Blocks tatsächlich ein ValueError ausgelöst wird.

Bleibt die Exception aus, schlägt der Test fehl.

Wird stattdessen eine völlig andere Exception ausgelöst, schlägt der Test ebenfalls fehl.

Exception-Meldungen prüfen

Zusätzlich kannst Du die Meldung kontrollieren:

def test_negativer_schaden_meldet_problem():
    spieler = Spieler("Test")

    with pytest.raises(
        ValueError,
        match="Schaden darf nicht negativ sein",
    ):
        spieler.erleide_schaden(-10)

Wichtig:

match=...

ist ein regulärer Ausdruck.

Dieser Text:

match="Wert (ungültig)"

bedeutet deshalb nicht automatisch:

Suche exakt die Zeichenfolge "Wert (ungültig)".

Wenn Du einen vorhandenen Text wirklich wörtlich verwenden möchtest:

import re

with pytest.raises(
    ValueError,
    match=re.escape("Wert (ungültig)"),
):
    ...

Für einfache Meldungen ohne Regex-Sonderzeichen reicht der direkte String.

Die Exception selbst untersuchen

pytest.raises() kann Dir das Exception-Objekt zur Verfügung stellen:

def test_negativer_schaden_enthaelt_menge():
    spieler = Spieler("Test")

    with pytest.raises(ValueError) as exc_info:
        spieler.erleide_schaden(-10)

    assert "negativ" in str(exc_info.value)

exc_info.value ist die tatsächliche Exception.

Damit kannst Du beispielsweise eigene Attribute einer Domain Exception prüfen.

check= bei pytest.raises()

Seit pytest 8.4 kann pytest.raises() zusätzlich einen Callable über check= verwenden.

Angenommen, wir wollen bei einem OSError nicht nur den Typ, sondern auch einen bestimmten Fehlercode prüfen:

import errno

import pytest


def test_berechtigungsfehler():
    with pytest.raises(
        OSError,
        check=lambda fehler: (
            fehler.errno == errno.EACCES
        ),
    ):
        raise OSError(
            errno.EACCES,
            "Keine Berechtigung",
        )

check läuft nach der Prüfung von Exception-Typ und gegebenenfalls match.

Für normale Tests brauchst Du diese Option selten. Bei Exceptions mit zusätzlichen strukturierten Informationen ist sie aber nützlich.

Eigene Exceptions testen

Unser Dungeon besitzt beispielsweise:

class DungeonError(Exception):
    pass


class UngueltigerBefehlError(DungeonError):
    pass


class UnmoeglicheAktionError(DungeonError):
    pass

Eine leere Eingabe:

import pytest

from eingabe import teile_befehl
from fehler import UngueltigerBefehlError


def test_leere_eingabe_wirft_ungueltigen_befehl():
    with pytest.raises(
        UngueltigerBefehlError
    ):
        teile_befehl("")

Ein ungültiger Ausgang:

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üfen wir nicht nur:

Irgendetwas geht schief.

sondern:

Dieser konkrete Fehlerfall wird über die vorgesehene Exception signalisiert.

Fixtures: gemeinsame Vorbereitung

Mehrere Tests brauchen häufig dieselbe Ausgangslage.

Ohne Fixture:

def test_schaden_reduziert_hp():
    spieler = Spieler(
        "Test",
        max_hp=100,
    )

    spieler.erleide_schaden(30)

    assert spieler.hp == 70


def test_heilung_stoppt_bei_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_stoppt_bei_max_hp(spieler):
    spieler.erleide_schaden(50)
    spieler.heile(999)

    assert spieler.hp == 100

pytest erkennt den Parameter:

spieler

und sucht nach einer Fixture mit demselben Namen.

Ihr Rückgabewert wird an den Test übergeben.

Das ist Dependency Injection für Tests.

Fixtures sind standardmäßig frisch pro Test

Der Default-Scope einer Fixture ist:

function

Das bedeutet: Sie wird für jeden Testaufruf neu erzeugt.

def test_eins(spieler):
    spieler.erleide_schaden(50)

    assert spieler.hp == 50


def test_zwei(spieler):
    assert spieler.hp == 100

test_zwei bekommt einen neuen Spieler.

Die Mutation aus test_eins wirkt nicht nach.

Genau das wollen wir normalerweise.

Tests sollen möglichst unabhängig voneinander sein.

Fixture-Scopes

Neben function kennt pytest:

class
module
package
session

Beispiel:

@pytest.fixture(scope="session")
def teure_testdaten():
    return erzeuge_teure_testdaten()

Diese Fixture wird nur einmal für die gesamte Test Session erzeugt und von allen passenden Tests geteilt.

Das kann sinnvoll sein, wenn die Vorbereitung wirklich teuer ist.

Bei mutablem State ist ein großer Scope dagegen gefährlich:

@pytest.fixture(scope="session")
def spieler():
    return Spieler("Test")

Wenn ein Test diesen Spieler verändert, sehen spätere Tests denselben veränderten State.

Als Default ist:

function

deshalb meistens genau richtig.

Fixtures können Fixtures verwenden

Eine Fixture kann andere Fixtures 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 den benötigten Fixture Graph automatisch auf:

test_raum_enthaelt_fackel
          ↓
        halle
          ↓
        fackel

Aufräumen mit yield-Fixtures

Manche Fixtures müssen nach dem Test etwas aufräumen.

Zum Beispiel eine geöffnete Datei:

import pytest


@pytest.fixture
def logdatei(tmp_path):
    pfad = tmp_path / "spiel.log"
    handle = pfad.open(
        "w",
        encoding="utf-8",
    )

    yield handle

    handle.close()

Vor:

yield handle

steht das Setup.

Der Wert hinter yield wird an den Test gegeben.

Danach folgt das Teardown.

def test_schreibt_ins_log(logdatei):
    logdatei.write("Spiel gestartet\n")

    assert not logdatei.closed

Auch wenn der eigentliche Test fehlschlägt, führt pytest den Teardown nach dem yield normalerweise weiterhin aus.

Eine wichtige Grenze gibt es allerdings: Scheitert die Fixture bereits vor dem yield, kann ihr eigener Code hinter dem yield natürlich noch nicht als Teardown registriert worden sein.

Andere Fixtures, deren Setup bereits erfolgreich abgeschlossen wurde, werden trotzdem sauber abgebaut.

Gemeinsame Fixtures in conftest.py

Brauchen mehrere Testdateien dieselbe Fixture, kommt conftest.py ins Spiel.

# tests/conftest.py

import pytest

from modelle import Spieler


@pytest.fixture
def spieler():
    return Spieler(
        "Test",
        max_hp=100,
    )

Danach kann jede passende Testdatei darunter die Fixture einfach anfordern:

def test_schaden_reduziert_hp(spieler):
    spieler.erleide_schaden(30)

    assert spieler.hp == 70

Du brauchst kein:

from conftest import spieler

pytest entdeckt Fixtures aus conftest.py selbst.

Mehrere Ebenen sind möglich:

tests/
├── conftest.py
├── test_modelle.py
└── integration/
    ├── conftest.py
    └── test_speicher.py

Die innere conftest.py kann Fixtures für diesen Bereich ergänzen oder überschreiben.

conftest.py ist dabei mehr als eine normale Hilfsdatei: pytest behandelt sie als lokales Plugin.

Deshalb sollte Anwendungscode nichts aus einer conftest.py importieren.

Auch Tests sollten gemeinsame normale Hilfsfunktionen besser in ein echtes Python-Modul auslagern, statt conftest.py als allgemeine Utility-Sammlung zu missbrauchen.

conftest.py ist kein sauberer Importpfad-Fix

Eine leere:

conftest.py

in der Projektwurzel ist keine saubere Lösung dafür, dass Deine Anwendungsmodule nicht importierbar sind.

Im Standard-Import-Mode prepend landet das Verzeichnis einer conftest.py tatsächlich in sys.path – deshalb funktioniert der Trick überhaupt. Mit --import-mode=importlib funktioniert er nicht mehr, und er koppelt Deine Imports an ein pytest-Detail.

Wenn Imports nur zufällig funktionieren, ist meistens das Projektlayout das eigentliche Thema.

Für neue installierbare Projekte ist ein src/-Layout mit installiertem Package und --import-mode=importlib eine robuste Lösung.

Parametrisierung: ein Test, viele Fälle

Für dieselbe Regel mit mehreren Eingaben brauchen wir nicht mehrere fast identische Testfunktionen.

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:

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 die Funktion dreimal aus.

Jede Parameterkombination ist ein eigener Testfall.

Sprechende Parameter-IDs

Bei größeren Datensätzen können automatisch erzeugte Testnamen schwer zu lesen sein.

Dann helfen IDs:

@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,
):
    ...

Die Namen erscheinen anschließend beispielsweise als:

test_schaden[normaler-schaden]
test_schaden[nicht-unter-null]
test_schaden[kein-schaden]

Bei einem Fehlschlag erkennst Du damit sofort, welcher Fall betroffen ist.

pytest.param() für einzelne Fälle

IDs und Marker können auch direkt am einzelnen Datensatz hängen:

@pytest.mark.parametrize(
    "schaden, erwartet",
    [
        pytest.param(
            30,
            70,
            id="normal",
        ),
        pytest.param(
            999,
            0,
            id="overkill",
        ),
    ],
)
def test_schaden(
    schaden,
    erwartet,
):
    spieler = Spieler(
        "Test",
        max_hp=100,
    )

    spieler.erleide_schaden(schaden)

    assert spieler.hp == erwartet

Das wird praktisch, wenn einzelne Fälle zusätzlich xfail, skip oder andere Marker bekommen sollen.

Konsolenausgaben mit capsys testen

CLI-Code und unser Dungeon verwenden print().

pytest kann stdout und stderr auffangen:

def test_zeige_leeres_inventar(capsys):
    spieler = Spieler("Test")

    spieler.zeige_inventar()

    ausgabe = capsys.readouterr()

    assert (
        "Dein Inventar ist leer."
        in ausgabe.out
    )

capsys.readouterr() liefert:

ausgabe.out
ausgabe.err

also stdout und stderr.

Nach dem Aufruf wird der bisherige Capture-Puffer zurückgesetzt, während das Capturing weiterläuft.

capsys oder capfd?

Für normales Python-print() reicht:

capsys

Es fängt Zugriffe auf:

sys.stdout
sys.stderr

ab.

Manche Bibliotheken, C-Erweiterungen oder Unterprozesse schreiben dagegen direkt auf die Betriebssystem-File-Descriptors 1 und 2.

Dafür gibt es:

capfd

Beispiel:

def test_shell_ausgabe(capfd):
    import os

    os.system("echo Hallo")

    ausgabe = capfd.readouterr()

    assert "Hallo" in ausgabe.out

Für unseren Dungeon genügt fast immer capsys.

Logging mit caplog testen

Im Artikel Logging statt print haben wir Modul-Logger eingeführt.

pytest besitzt dafür caplog.

Angenommen:

import logging

logger = logging.getLogger(__name__)


def lade_spielstand(datei):
    if not datei.exists():
        logger.warning(
            "Spielstand fehlt: %s",
            datei,
        )
        return None

Der Test:

import logging


def test_fehlender_spielstand_warnt(
    caplog,
    tmp_path,
):
    datei = (
        tmp_path
        / "nicht-vorhanden.json"
    )

    with caplog.at_level(logging.WARNING):
        lade_spielstand(datei)

    assert "Spielstand fehlt" in caplog.text

Für genauere Prüfungen stehen unter anderem zur Verfügung:

caplog.messages
caplog.records
caplog.record_tuples

Damit musst Du Logging nicht über stdout simulieren.

Interaktive Eingaben mit monkeypatch

Unsere Funktion:

def frage_name(text):
    while True:
        name = input(text).strip()

        if name:
            return name

        print(
            "Der Name darf nicht leer sein."
        )

würde in einem automatischen Test auf Tastatureingabe warten.

Mit monkeypatch ersetzen wir input() vorübergehend:

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"

Nach dem Test stellt pytest den ursprünglichen Zustand wieder her.

Mehrere Eingaben simulieren

Soll die Funktion mehrfach fragen:

def test_frage_name_fragt_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
    )

Der Iterator liefert beim ersten Aufruf:

""

und beim zweiten:

"Karl"

Damit können wir sogar interaktive Schleifen reproduzierbar testen.

monkeypatch kann mehr als Funktionen ersetzen

Die Fixture kann unter anderem:

Attribute setzen und löschen
Dictionary-Einträge verändern
Environment Variables setzen
das Arbeitsverzeichnis wechseln
sys.path verändern

Eine Environment Variable:

def test_debug_modus(
    monkeypatch,
):
    monkeypatch.setenv(
        "DUNGEON_DEBUG",
        "1",
    )

    ...

Oder das aktuelle Verzeichnis:

def test_relative_datei(
    monkeypatch,
    tmp_path,
):
    monkeypatch.chdir(tmp_path)

    ...

Alle Änderungen werden nach dem Test wieder rückgängig gemacht.

Beim Patchen zählt der verwendete Name

Eine der häufigsten Mocking-Fallen ist, an der falschen Stelle zu patchen.

Angenommen, speicher.py enthält:

from pathlib import Path


def aktueller_ordner():
    return Path.cwd()

Dann ist entscheidend, welchen Namen der zu testende Code tatsächlich verwendet.

Mocks und Patches sollten im Allgemeinen dort ansetzen, wo der Name nachgeschlagen wird, nicht blind dort, wo die ursprüngliche Funktion einmal definiert wurde.

Je weniger Du patchen musst, desto einfacher bleiben Tests allerdings häufig.

Dependencies lieber übergeben als wegpatchen

Nehmen wir:

SPEICHERDATEI = Path(
    "spielstand.json"
)

und eine Funktion, die diese globale Konstante immer direkt verwendet.

Im Test könnten wir SPEICHERDATEI mit monkeypatch ersetzen.

Oft ist diese API aber testfreundlicher:

def speichere_spielstand(
    spieler,
    ort,
    raeume,
    besuchte_raeume,
    speicherdatei,
):
    ...

Der Test kann dann einfach einen eigenen Pfad übergeben.

Das ist keine pytest-Sonderregel.

Es ist ein allgemeines Designprinzip:

Explizite Dependencies sind leichter zu testen als versteckte globale Dependencies.

Temporäre Dateien mit tmp_path

Für Dateitests sollten wir nicht ständig:

spielstand.json

im echten Projektordner überschreiben.

pytest liefert dafür die Fixture:

tmp_path

Sie ist ein pathlib.Path und für jeden Testaufruf eindeutig:

def test_datei_schreiben_und_lesen(
    tmp_path,
):
    datei = tmp_path / "beispiel.txt"

    datei.write_text(
        "Hallo\n",
        encoding="utf-8",
    )

    assert (
        datei.read_text(
            encoding="utf-8"
        )
        == "Hallo\n"
    )

Damit bekommt jeder Test seinen eigenen Arbeitsbereich.

tmp_path wird nicht zwingend sofort gelöscht

„Temporär“ bedeutet bei pytest nicht unbedingt:

Nach jeder Testfunktion ist der Ordner sofort weg.

pytest verwaltet die Verzeichnisse und behält standardmäßig temporäre Verzeichnisse aus mehreren vergangenen Testläufen.

Das ist praktisch, wenn ein Test fehlschlägt und Du anschließend die erzeugte Datei ansehen möchtest.

Die Retention lässt sich konfigurieren.

Unter pytest 9 beispielsweise:

[tool.pytest]
tmp_path_retention_count = "3"
tmp_path_retention_policy = "all"

Mögliche Policies sind:

all
failed
none

Für normale Tests musst Du daran nichts ändern.

Wichtig ist nur: Verlasse Dich nicht darauf, dass ein tmp_path nach dem Test sofort physisch vom Dateisystem verschwunden ist.

Speicherlogik mit tmp_path testen

Die Speicherfunktion mit dem expliziten Pfad-Parameter von oben:

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")

kann im Test einen eigenen Pfad erhalten:

from modelle import Spieler
from speicher import speichere_spielstand
from welt import erstelle_raeume


def test_speichern_erzeugt_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()

Noch besser ist es, anschließend nicht nur die Existenz zu prüfen.

Wir können kontrollieren, ob der Inhalt stimmt.

JSON-Inhalt statt Textfragmente testen

Weniger robust:

assert (
    '"gold": 50'
    in speicherdatei.read_text(
        encoding="utf-8"
    )
)

Damit hängt der Test von der konkreten Formatierung der JSON-Datei ab.

Besser:

import json


def test_spielstand_enthaelt_gold(
    tmp_path,
):
    # Spielstand erzeugen ...

    daten = json.loads(
        speicherdatei.read_text(
            encoding="utf-8"
        )
    )

    assert daten["spieler"]["gold"] == 50

Wenn später Einrückung oder Zeilenumbrüche geändert werden, bleibt der Test weiterhin grün.

Wir prüfen die Bedeutung der Daten, nicht ihre zufällige Textformatierung.

Reine Funktionen sind besonders angenehm zu testen

Diese Funktion:

def teile_befehl(eingabe):
    verb, _, argument = (
        eingabe
        .strip()
        .lower()
        .partition(" ")
    )

    if not verb:
        raise UngueltigerBefehlError(
            "Bitte gib einen Befehl ein."
        )

    return verb, argument.strip()

braucht weder Dateisystem noch input() noch globale Variablen.

Tests:

import pytest

from eingabe import teile_befehl
from fehler import UngueltigerBefehlError


def test_befehl_ohne_argument():
    verb, argument = teile_befehl(
        "inventar"
    )

    assert verb == "inventar"
    assert argument == ""


def test_befehl_mit_argument():
    verb, argument = teile_befehl(
        "nimm fackel"
    )

    assert verb == "nimm"
    assert argument == "fackel"


def test_befehl_entfernt_leerzeichen():
    verb, argument = teile_befehl(
        "  nimm   fackel  "
    )

    assert verb == "nimm"
    assert argument == "fackel"


def test_leerer_befehl_wirft():
    with pytest.raises(
        UngueltigerBefehlError
    ):
        teile_befehl("   ")

Solche Tests sind schnell und sehr leicht zu verstehen.

Das ist einer der Gründe, warum kleine Funktionen mit klaren Inputs und Outputs so angenehm sind.

Aber Moment: Was passiert bei mehreren Leerzeichen?

Schauen wir genauer auf:

eingabe.strip().lower().partition(" ")

Bei:

"  nimm   fackel  "

entsteht:

verb == "nimm"
argument == "  fackel"

und das anschließende:

argument.strip()

macht daraus:

"fackel"

Der Test dokumentiert damit ganz konkret, dass zusätzliche Leerzeichen zwischen Verb und Argument akzeptiert werden.

Ein guter Test kann also gleichzeitig eine kleine Spezifikation sein.

Marker für Testgruppen

Nicht jeder Test muss bei jedem Aufruf laufen.

Vielleicht gibt es später langsame Integration Tests:

import pytest


@pytest.mark.slow
def test_grosser_spielstand():
    ...

Ausführen:

pytest -m slow

Oder alles außer diesen Tests:

pytest -m "not slow"

Eigene Marker solltest Du registrieren.

Mit pytest 9 und nativer TOML-Konfiguration:

[tool.pytest]
markers = [
    "slow: langsame Tests",
]

Bei aktiviertem:

strict = true

führt ein unbekannter beziehungsweise falsch geschriebener Marker zu einem Fehler statt nur unbemerkt eine neue Kategorie zu erzeugen.

Das ist einer der Gründe, warum Strict Mode sinnvoll sein kann.

Tests überspringen

Manchmal kann ein Test unter bestimmten Bedingungen gar nicht sinnvoll laufen.

Dann gibt es skip:

import pytest


@pytest.mark.skip(
    reason="Feature noch nicht verfügbar"
)
def test_mehrere_spielstaende():
    ...

Oder abhängig von einer Bedingung:

import sys

import pytest


@pytest.mark.skipif(
    sys.platform == "win32",
    reason=(
        "Dieser Test prüft "
        "Unix-spezifisches Verhalten"
    ),
)
def test_unix_pfad():
    ...

Ein Skip bedeutet:

Dieser Test wurde nicht ausgeführt.

Das ist etwas anderes als ein erfolgreicher Test.

Dauerhaft übersprungene Tests sind deshalb kein Sicherheitsnetz.

Erwartete Fehlschläge mit xfail

xfail bedeutet:

Dieser Test beschreibt gewünschtes Verhalten, von dem wir wissen, dass es momentan noch nicht funktioniert.

@pytest.mark.xfail(
    reason=(
        "Mehrere Spielstände "
        "sind noch nicht implementiert"
    ),
)
def test_mehrere_spielstaende():
    ...

Schlägt der Test fehl, erscheint er als:

XFAIL

Interessant wird es, wenn er plötzlich erfolgreich ist.

Ohne striktes Verhalten entsteht:

XPASS

und der gesamte Testlauf kann trotzdem erfolgreich bleiben.

Mit:

@pytest.mark.xfail(
    reason="...",
    strict=True,
)

wird ein unerwarteter Erfolg dagegen zum Fehler.

Unser weiter oben vorgeschlagenes:

[tool.pytest]
strict = true

aktiviert unter pytest 9 auch striktes xfail global.

Das ist für langfristig gepflegte Tests oft die bessere Variante: Wenn ein bekannter Bug plötzlich behoben ist, soll der alte xfail nicht für immer liegen bleiben.

Ein guter Test ist nicht einfach ein großer Test

Es ist verlockend, den kompletten Dungeon in einem Test durchzuspielen:

def test_gesamtes_spiel():
    ...

mit hundert Assertions.

Wenn Assertion Nummer 73 fehlschlägt, weißt Du dann nur:

Irgendwo im kompletten Spielablauf stimmt etwas nicht.

Kleinere Tests grenzen Fehler besser ein.

Zum Beispiel:

test_schaden_reduziert_hp
test_heilung_stoppt_bei_max_hp
test_befehl_mit_argument
test_raum_ziel_fuer_norden
test_gegenstand_wird_aus_raum_entfernt

Das heißt nicht, dass größere Integration Tests schlecht wären.

Sie beantworten nur eine andere Frage.

Unit Tests und Integration Tests

Ein Test für:

teile_befehl("nimm fackel")

prüft eine kleine Einheit isoliert.

Ein Test, der:

Spieler erzeugt
Welt aufbaut
Gegenstand aufnimmt
Spielstand schreibt
Spielstand neu lädt

prüft das Zusammenspiel mehrerer Teile.

Beides ist wertvoll.

Eine Testsuite besteht häufig aus vielen schnellen kleinen Tests und weniger größeren Integration Tests.

Der Fehler wäre, jede Kleinigkeit nur über den kompletten Programmablauf zu testen.

Tests sollten sich nicht auf ihre Reihenfolge verlassen

Dieser Test ist problematisch:

def test_01_spieler_erzeugen():
    global spieler
    spieler = Spieler("Karl")


def test_02_spieler_verwunden():
    spieler.erleide_schaden(20)

    assert spieler.hp == 80

test_02 funktioniert nur, wenn test_01 vorher gelaufen ist.

Das macht die Tests fragil.

pytest darf Tests einzeln ausführen:

pytest tests/test_modelle.py::test_02_spieler_verwunden

Jeder Test sollte deshalb seine benötigte Ausgangslage selbst herstellen oder über Fixtures bekommen.

Was sollte man testen?

Ein guter Ausgangspunkt sind die Regeln, bei denen ein Fehler tatsächlich etwas kaputtmachen würde.

Für unseren Dungeon etwa:

Schadensgrenzen
Heilungsgrenzen
Input Parsing
Raumübergänge
Inventar
Speichern und Laden
Fehlerfälle
Randfälle

Weniger interessant ist ein Test, der lediglich die Implementierung wiederholt:

spieler = Spieler("Karl")

assert spieler.name == "Karl"

Der kann sinnvoll sein, wenn Normalisierung oder Validierung Teil der Constructor-Semantik ist.

Wenn der Constructor aber wirklich nur:

self.name = name

macht, sagt der Test wenig aus.

Teste Verhalten und Verträge, nicht jede einzelne Codezeile.

Tests und Refactoring

Tests werden besonders wertvoll, wenn Du Code umbaust.

Angenommen, Du ersetzt:

SPEICHERDATEI = Path(
    "spielstand.json"
)

durch einen expliziten Parameter.

Oder Du zerlegst eine 150-Zeilen-Funktion in mehrere kleinere Funktionen.

Oder aus:

dict

wird eine Dataclass.

Das gewünschte Verhalten soll gleich bleiben.

Nach dem Umbau:

uv run python -m pytest

Wenn die Tests weiterhin grün sind, hast Du deutlich mehr Sicherheit, dass Du beim Refactoring nicht versehentlich eine bestehende Regel verändert hast.

Keine Testsuite beweist, dass ein Programm fehlerfrei ist.

Sie kann aber sehr konkret beweisen:

Diese getesteten Erwartungen gelten weiterhin.

Einen einzelnen Test starten

Beim Entwickeln musst Du nicht immer die gesamte Suite ausführen.

Eine Datei:

pytest tests/test_modelle.py

Ein einzelner Test:

pytest \
    tests/test_modelle.py::test_hp_faellt_nicht_unter_null

Nach Namen filtern:

pytest -k schaden

Das führt Tests aus, deren Node ID beziehungsweise Namen zum Ausdruck passen.

Mehrere Bedingungen sind möglich:

pytest -k "schaden and not negativ"

Beim ersten Fehler stoppen

Wenn gerade zehn Tests wegen desselben Bugs scheitern, ist oft:

pytest -x

angenehm.

pytest beendet den Lauf nach dem ersten Fehler.

Oder:

pytest --maxfail=3

nach drei Fehlern.

Nur die zuletzt fehlgeschlagenen Tests

pytest besitzt einen Cache zwischen Testläufen.

Dadurch kannst Du schreiben:

pytest --lf

für:

last failed

Oder:

pytest --ff

für:

failed first

--ff führt die zuletzt fehlgeschlagenen Tests zuerst aus und danach den Rest.

Bei einer größeren Suite spart das während der Fehlersuche Zeit.

Kurze und ausführliche Ausgabe

Weniger Ausgabe:

pytest -q

Mehr Details:

pytest -v

Mit -v siehst Du die einzelnen Testnamen beziehungsweise Node IDs wesentlich deutlicher.

Langsame Tests finden

Wenn eine Suite irgendwann nicht mehr schnell ist:

pytest --durations=10

zeigt die langsamsten zehn Testabschnitte.

Mit einer Mindestdauer:

pytest \
    --durations=10 \
    --durations-min=0.1

kannst Du Kleinkram ausblenden.

Das ist besser, als nach Gefühl zu raten, welcher Test Zeit verbraucht.

Testsuite mit uv

In unserem uv-Projekt:

uv add --dev pytest

Dann:

uv run python -m pytest

Mit Ruff:

uv run ruff check .
uv run ruff format --check .
uv run python -m pytest

Damit prüfen wir drei unterschiedliche Dinge:

Linting
Formatierung
Verhalten

Ruff ersetzt pytest nicht.

pytest ersetzt Ruff ebenfalls nicht.

Ein Programm kann perfekt formatiert und trotzdem fachlich falsch sein:

def addiere(a, b):
    return a - b

Und ein Test kann hervorragenden fachlichen Code prüfen, obwohl der Code stilistisch chaotisch ist.

In der CI

In einer CI-Pipeline könnte ein uv-Projekt beispielsweise ausführen:

uv sync --locked
uv run --locked ruff check .
uv run --locked ruff format --check .
uv run --locked python -m pytest

Der wichtigste Punkt ist:

Die CI führt dieselben Tests aus wie Du lokal.

Ein Test, der nur auf dem Laptop eines Entwicklers existiert oder nur über einen manuellen Ablauf geprüft wird, schützt das Repository nicht zuverlässig.

Eine sinnvolle erste Dungeon-Testsuite

Wir müssen nicht sofort jede Zeile testen.

Ein guter Anfang:

tests/
├── conftest.py
├── test_befehle.py
├── test_eingabe.py
├── test_modelle.py
└── test_speicher.py

tests/conftest.py

import pytest

from modelle import Spieler


@pytest.fixture
def spieler():
    return Spieler(
        "Test",
        max_hp=100,
    )

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_stoppt_bei_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_ungueltige_richtung_wirft():
    raum = Raum(
        name="Halle",
        beschreibung="Eine dunkle Halle.",
        ausgaenge={
            "norden": "bibliothek",
        },
    )

    with pytest.raises(
        UnmoeglicheAktionError
    ):
        raum.ziel("unten")


def test_aufnehmen_entfernt_gegenstand():
    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_befehl_ohne_argument():
    verb, argument = teile_befehl(
        "inventar"
    )

    assert verb == "inventar"
    assert argument == ""


def test_befehl_mit_argument():
    verb, argument = teile_befehl(
        "nimm fackel"
    )

    assert verb == "nimm"
    assert argument == "fackel"


def test_leerer_befehl_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"]
    )

Das ist noch keine vollständige Testsuite.

Sie schützt aber bereits mehrere Regeln, bei denen eine spätere Änderung leicht einen Fehler verursachen könnte.

Häufige Stolperfallen

  • pytest findet keine Tests: Prüfe zuerst Datei- und Funktionsnamen sowie testpaths.

  • Importprobleme mit einer leeren conftest.py oder pythonpath = ["."] kaschieren: Bei einem echten Package ist ein korrekt installierbares Projektlayout langfristig robuster.

  • assertEqual(...) aus unittest übernehmen: In normalen pytest-Tests genügt assert.

  • Regex bei pytest.raises(match=...) vergessen: match sucht per regulärem Ausdruck. Für Literaltexte mit Sonderzeichen hilft re.escape().

  • Nur prüfen, dass irgendeine Exception kommt: Wenn Deine API einen konkreten Fehlertyp verspricht, teste diesen Typ.

  • Fixtures zu früh mit großem Scope versehen: Eine mutable Session-Fixture kann unbemerkt State zwischen Tests teilen.

  • Fixtures aus conftest.py importieren: pytest stellt sie über seinen Fixture-Mechanismus bereit. Normale wiederverwendbare Hilfsfunktionen gehören besser in ein echtes Modul.

  • Zu viel Setup in jeden Test kopieren: Wiederkehrende fachliche Ausgangslagen können Fixtures sein.

  • Jede Kleinigkeit in eine Fixture verwandeln: Ein direktes spieler = Spieler("Test") ist oft verständlicher als eine Fixture, die nur an einer Stelle gebraucht wird.

  • Teardown-Code einfach ans Ende des Tests schreiben: Eine vorher fehlgeschlagene Assertion kann ihn überspringen. Für Resources eignen sich Context Manager oder yield-Fixtures.

  • tmp_path für einen fest bekannten Pfad halten: Jeder Test bekommt einen eigenen temporären Bereich, und pytest darf alte Testlauf-Verzeichnisse noch eine Weile aufbewahren.

  • Echte Dateien im Repository verändern: Dateitests gehören normalerweise in tmp_path.

  • JSON als formatierten String testen: Wenn die Formatierung nicht Teil des Vertrages ist, parse das JSON und prüfe seine Werte.

  • capsys und capfd verwechseln: capsys arbeitet auf sys.stdout/sys.stderr, capfd auf den Betriebssystem-File-Descriptors.

  • Logging über capsys testen, obwohl caplog existiert: Für Log-Records ist caplog meistens die passendere Fixture.

  • Beim Monkeypatching das falsche Objekt ersetzen: Patche den Namen, den der Code unter Test tatsächlich verwendet.

  • Zu viele globale Dinge patchen: Eine explizit übergebene Dependency ist oft einfacher und robuster.

  • Tests voneinander abhängig machen: Jeder Test sollte einzeln ausführbar sein.

  • Nur den Happy Path testen: Fehler- und Randfälle sind häufig die interessanteren Tests.

  • xfail als dauerhaften Ablageort für kaputte Tests verwenden: Ein erwarteter Fehlschlag sollte einen konkreten Grund haben und später wieder verschwinden.

  • Ein unerwartetes XPASS ignorieren: strict=True beziehungsweise pytest-9- Strict-Mode macht sichtbar, wenn ein xfail plötzlich erfolgreich ist.

  • Implementation statt Verhalten testen: Ein Refactoring sollte Tests nicht zerstören, nur weil eine private Hilfsfunktion anders aufgebaut wurde.

  • Eine grüne Testsuite mit Fehlerfreiheit verwechseln: Sie beweist nur die Erwartungen, die tatsächlich getestet wurden.

Übungen

1. Einen ersten Test schreiben

Schreibe:

def heile(
    hp,
    menge,
    max_hp,
):
    return min(
        hp + menge,
        max_hp,
    )

und teste, dass nicht über max_hp hinaus geheilt wird.

Lösung
def heile(
    hp,
    menge,
    max_hp,
):
    return min(
        hp + menge,
        max_hp,
    )


def test_heilung_stoppt_bei_max_hp():
    assert heile(
        90,
        20,
        100,
    ) == 100

2. Eine Exception testen

Teste, dass:

int("viel")

einen ValueError auslöst.

Lösung
import pytest


def test_text_ist_keine_ganze_zahl():
    with pytest.raises(ValueError):
        int("viel")

3. Eine Fixture verwenden

Erzeuge eine Fixture spieler mit max_hp=100 und teste damit Schaden.

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

Prüfe mehrere Schadenswerte mit einem Test.

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. stdout und stderr testen

Schreibe:

def begruesse(name):
    print(f"Hallo, {name}!")

und prüfe die Ausgabe mit capsys.

Lösung
def begruesse(name):
    print(f"Hallo, {name}!")


def test_begruessung(capsys):
    begruesse("Karl")

    ausgabe = capsys.readouterr()

    assert (
        ausgabe.out
        == "Hallo, Karl!\n"
    )
    assert ausgabe.err == ""

6. Eine temporäre Datei verwenden

Schreibe mit tmp_path eine Datei und lies sie wieder ein.

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"
    )

7. Eine Environment Variable patchen

Setze im Test:

DUNGEON_MODUS=schwer

ohne die echte Environment dauerhaft zu verändern.

Lösung
import os


def test_dungeon_modus(
    monkeypatch,
):
    monkeypatch.setenv(
        "DUNGEON_MODUS",
        "schwer",
    )

    assert (
        os.getenv("DUNGEON_MODUS")
        == "schwer"
    )
Nach dem Test stellt `monkeypatch` die vorherige Environment wieder her.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!