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:
- Programm starten.
- Namen und Gold eingeben.
nimm fackelausprobieren.- Inventar kontrollieren.
- Speichern.
- Programm neu starten.
- Spielstand laden.
- 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.pyoderpythonpath = ["."]kaschieren: Bei einem echten Package ist ein korrekt installierbares Projektlayout langfristig robuster. -
assertEqual(...)ausunittestübernehmen: In normalen pytest-Tests genügtassert. -
Regex bei
pytest.raises(match=...)vergessen:matchsucht per regulärem Ausdruck. Für Literaltexte mit Sonderzeichen hilftre.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.pyimportieren: 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_pathfü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.
-
capsysundcapfdverwechseln:capsysarbeitet aufsys.stdout/sys.stderr,capfdauf den Betriebssystem-File-Descriptors. -
Logging über
capsystesten, obwohlcaplogexistiert: Für Log-Records istcaplogmeistens 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.
-
xfailals 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=Truebeziehungsweise pytest-9- Strict-Mode macht sichtbar, wenn einxfailplö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"
)
Weiterlesen
- 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
- Logging statt print
- pytest-Dokumentation
- pytest: Getting Started
- pytest: Good Integration Practices
- pytest: Import Modes
- pytest: Konfiguration
- pytest: Fixtures
- pytest: Parametrisierung
- pytest: Assertions
- pytest:
tmp_path - pytest:
monkeypatch - pytest: stdout und stderr erfassen
- pytest: Logging testen
- pytest: skip und xfail
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.