pytest ist das Standardwerkzeug zum Testen in Python. Gegenüber dem eingebauten unittest schreibt man deutlich weniger Code: einfache Funktionen statt Klassen, assert statt eigener Prüfmethoden, und aussagekräftige Fehlermeldungen ohne Zusatzarbeit. Dieser Artikel zeigt die Grundlagen und die Mittel, die den Unterschied machen.

Der Einstieg

# Rechnung in der Anwendung
# app/rechnung.py
def rabatt(betrag: float, prozent: float) -> float:
    if not 0 <= prozent <= 100:
        raise ValueError("Prozent muss zwischen 0 und 100 liegen")
    return round(betrag * (1 - prozent / 100), 2)

# Test in tests/test_rechnung.py
from app.rechnung import rabatt

def test_rabatt_basis():
    assert rabatt(100.0, 10) == 90.0

def test_rabatt_rundet():
    assert rabatt(19.99, 33) == 13.39

def test_rabatt_null():
    assert rabatt(100.0, 0) == 100.0

def test_rabatt_grenzen():
    assert rabatt(100.0, 100) == 0.0

# fehlerhafter Aufruf muss eine Ausnahme ausloesen
import pytest

def test_rabatt_ungueltig():
    with pytest.raises(ValueError, match="zwischen 0 und 100"):
        rabatt(100.0, 150)
# Ausfuehren
pytest                                    # alles im Verzeichnis
pytest tests/test_rechnung.py             # eine Datei
pytest tests/test_rechnung.py::test_rabatt_basis   # ein Test
pytest -v                                 # ausfuehrlich
pytest -x                                 # beim ersten Fehler aufhoeren
pytest --lf                               # nur die zuletzt fehlgeschlagenen
pytest -k "rabatt and not grenzen"        # nach Namen filtern
pytest -m "not langsam"                   # nach Markierung filtern

# Fehlermeldung im Detail
pytest -vv --tb=long                      # voller Traceback
pytest --tb=short
pytest -s                                 # print-Ausgaben zeigen

# Ausgabe fuer die Maschine
pytest --junitxml=bericht.xml
pytest --cov=app --cov-report=term-missing  # Abdeckung (pytest-cov)

Fixtures: Testdaten vorbereiten

Fixtures sind der Kern von pytest. Sie bereiten etwas vor und räumen danach auf; ein Test fordert sie über den Parameternamen an.

import pytest
from pathlib import Path

@pytest.fixture
def temp_verzeichnis(tmp_path):
    # pytest stellt tmp_path bereit und raeumt es automatisch auf.
    (tmp_path / "config.ini").write_text("[main]\nport=8080\n", encoding="utf-8")
    return tmp_path

def test_konfiguration_lesen(temp_verzeichnis):
    inhalt = (temp_verzeichnis / "config.ini").read_text(encoding="utf-8")
    assert "port=8080" in inhalt

# Fixture mit Aufraeumen: alles vor dem yield ist Aufbau, danach Abbau
@pytest.fixture
def datenbank():
    verbindung = verbinde_mit_testdatenbank()
    verbindung.migriere()
    yield verbindung               # hier laeuft der Test
    verbindung.schliessen()        # Aufraeumen - auch bei Fehler

# Geteilte Fixtures: in conftest.py ablegen (wird automatisch gefunden)
# tests/conftest.py
@pytest.fixture(scope="session")
def api_client():
    # Einmal fuer alle Tests - spart Zeit bei teurem Aufbau.
    with starte_testserver() as server:
        yield server

Die Reichweite (scope) bestimmt, wie oft aufgebaut wird: function (Standard, je Test), class, module, session (einmal für alle). Für teure Aufbauten ist session richtig – solange die Tests sich nicht gegenseitig beeinflussen.

Parametrisierte Tests

Ein häufiger Fehler ist es, für jeden Fall eine eigene Testfunktion zu schreiben. Parametrisierte Tests sind kürzer und decken mehr ab.

import pytest

@pytest.mark.parametrize("betrag,prozent,erwartet", [
    (100.0,   0, 100.00),
    (100.0,  10,  90.00),
    (100.0,  50,  50.00),
    (100.0, 100,   0.00),
    ( 19.99, 33,  13.39),
    (  0.0,  50,   0.00),
])
def test_rabatt_faelle(betrag, prozent, erwartet):
    assert rabatt(betrag, prozent) == erwartet

# Mehrere Parameter-Kombinationen
@pytest.mark.parametrize("zahl,erwartet", [(2,True),(3,True),(4,False)])
@pytest.mark.parametrize("name", ["a", "b"])
def test_kombinationen(zahl, erwartet, name):
    pass   # laeuft 6 Mal (3 x 2)

# Ausnahmen bei bestimmten Eingaben
@pytest.mark.parametrize("prozent", [-1, 101, 1000])
def test_rabatt_ungueltig(prozent):
    with pytest.raises(ValueError):
        rabatt(100.0, prozent)

Markierungen

# pytest.ini / pyproject.toml: Markierungen bekannt machen
# [tool.pytest.ini_options]
# markers = [
#   "langsam: braucht laenger als 1 Sekunde",
#   "integration: braucht externe Dienste",
# ]

@pytest.mark.langsam
def test_grosse_datenmenge():
    ...

@pytest.mark.integration
def test_datenbank_verbindung():
    ...

@pytest.mark.skip(reason="noch nicht umgesetzt")
def test_zukunft():
    ...

@pytest.mark.skipif(sys.version_info < (3, 12), reason="braucht 3.12")
def test_neue_syntax():
    ...

@pytest.mark.xfail(reason="bekannter Fehler, siehe #1234")
def test_bekannt_kaputt():
    ...
# Aufrufe
pytest -m "not langsam and not integration"   # schnelle Tests im Alltag
pytest -m integration                          # vor der Auslieferung

Attrappen (Mocks)

Wenn ein Test nicht von einem externen Dienst abhängen soll, ersetzt man ihn durch eine Attrappe. Das eingebaute unittest.mock reicht dafür aus.

from unittest.mock import patch, MagicMock

# Funktion ersetzen
def test_wetter_bei_fehler():
    with patch("app.wetter.hole_von_api") as mock:
        mock.side_effect = TimeoutError("keine Antwort")
        ergebnis = app.wetter.anzeigen("Luebeck")
        assert "nicht verfuegbar" in ergebnis
        mock.assert_called_once()

# Rueckgabewert steuern
def test_wetter_erfolg():
    with patch("app.wetter.hole_von_api", return_value={"temp": 18.5}):
        assert "18.5" in app.wetter.anzeigen("Luebeck")

# Pruefen, ob eine Methode mit bestimmten Argumenten aufgerufen wurde
def test_mail_versand():
    with patch("app.mailer.smtplib.SMTP") as smtp:
        app.mailer.senden("Diese E-Mail-Adresse ist vor Spambots geschützt! Zur Anzeige muss JavaScript eingeschaltet sein.", "Betreff", "Text")
        smtp.return_value.__enter__.return_value.send_message.assert_called_once()

# Fuer Zeitabhaengigkeiten: die Uhr ersetzen, nicht warten
def test_ablauf():
    with patch("app.token.datetime") as dt:
        dt.now.return_value = datetime(2026, 1, 1, 12, 0)
        assert app.token.ist_gueltig("abc") is True

Grundsatz zu Attrappen: So sparsam wie möglich. Ein Test, der die halbe Anwendung ersetzt, prüft nur noch die Attrappen. Externe Grenzen (Netzwerk, Uhr, Zufall, Dateisystem) sind die richtigen Stellen – interne Funktionsaufrufe sind es selten.

Wie ein guter Test aussieht

  1. Ein Verhalten je Test. Der Testname beschreibt, was geprüft wird: test_rabatt_wirft_fehler_bei_negativem_prozent.
  2. Aufbau, Aktion, Prüfung – drei erkennbare Abschnitte, gern durch Leerzeilen getrennt.
  3. Unabhängig und wiederholbar. Kein Test darf von der Reihenfolge abhängen; kein Test darf bei erneutem Lauf ein anderes Ergebnis liefern.
  4. Schnell. Die gesamte Suite sollte in Sekunden laufen. Langsame Tests werden nicht ausgeführt – und dann nutzlos.
  5. Aussagekräftige Prüfungen. assert ergebnis == 90.0 statt assert ergebnis. pytest zeigt bei Fehlschlag beide Werte.
  6. Grenzfälle abdecken. Null, leer, Maximum, ungültige Eingabe, sehr große Werte, Sonderzeichen (Umlaute!), leere Liste.
  7. Fehlerhafte Eingaben testen, nicht nur den Erfolgsfall. Gerade dort stecken die Fehler.
  8. Abdeckung als Hinweis, nicht als Ziel. Hundert Prozent Abdeckung mit schwachen Prüfungen ist wertlos.

Verwandte Grundlagen: Python: Exceptions, Python: Funktionen und Module, Code-Review, python-Befehle.