Unit-Tests sind die Grundlage für jede Änderung ohne Angst. Der entscheidende Punkt: Ein Test, der beim kleinsten Umbau bricht, ist kein Test, sondern ein Bremsklotz. Das Ziel ist nicht Abdeckung um der Zahl willen, sondern das Vertrauen, dass Änderungen sicher sind.
Was ein guter Unit-Test ist
SCHNELL Millisekunden, nicht Sekunden
Nur dann laufen Tests bei jeder Aenderung.
ISOLIERT Keine Datenbank, kein Netz, kein Dateisystem.
Alles Aussenkontakt wird ersetzt (Attrappe).
WIEDERHOLBAR Gleiches Ergebnis bei jedem Lauf.
Keine Zeitabhaengigkeit, keine Zufallswerte,
keine Reihenfolgeabhaengigkeit.
EINDEUTIG Ein Fehlschlag zeigt die Ursache, ohne
Debugging. Die Meldung nennt Eingabe und
Erwartung.
UNABHAENGIG Keine Reihenfolge, keine gemeinsame Zustandsobjekte.
Struktur (gegeben/wenn/dann):
GEGEBEN: Ein Zustand oder eine Eingabe
WENN: Die zu pruefende Handlung
DANN: Das erwartete Ergebnis
Name des Tests = was geprueft wird, nicht wie:
GUT: test_kostenlos_ab_50_euro
GUT: test_mail_ohne_at_wird_abgelehnt
FALSCH: test_methode_1, testFall2
Praxis in Python
import pytest
from datetime import date, timedelta
def versandkosten(warenkorb_summe: float, land: str = "DE") -> float:
# Versandkosten nach Bestellwert und Zielland.
if land not in {"DE", "AT", "CH"}:
raise ValueError(f"Land nicht unterstuetzt: {land}")
if warenkorb_summe >= 50.0:
return 0.0
return 4.99
# Einfacher Test mit mehreren Faellen
@pytest.mark.parametrize("summe,erwartet", [
(0.0, 4.99),
(49.99, 4.99),
(50.0, 0.0), # Grenzwert! Der wichtigste Fall.
(1000.0, 0.0),
])
def test_versandkosten_de(summe, erwartet):
assert versandkosten(summe) == erwartet
def test_versandkosten_unbekanntes_land():
with pytest.raises(ValueError, match="nicht unterstuetzt"):
versandkosten(100.0, land="XX")
# Grenzwerte sind die wichtigsten Tests
def test_versandkosten_grenze_genau():
assert versandkosten(49.999) == 4.99
assert versandkosten(50.0) == 0.0
# Zeit muss kontrollierbar sein - sonst ist der Test nicht wiederholbar
def test_passwort_ablauf():
import freezegun
with freezegun.freeze_time("2026-09-26"):
token = token_erzeugen(gueltig_tage=30)
with freezegun.freeze_time("2026-10-25"):
assert token_ist_gueltig(token) is True
with freezegun.freeze_time("2026-10-27"):
assert token_ist_gueltig(token) is False
Attrappen: der wichtigste Teil
# Ohne Attrappen testet man nicht die Einheit, sondern
# die Abhaengigkeiten - und der Test wird langsam, unzuverlaessig
# und schwer zu deuten.
from unittest.mock import Mock, patch
import pytest
# 1) Einfache Attrappe (Stub): gibt feste Werte zurueck
def test_rabatt_bei_stammkunde():
kunden_api = Mock()
kunden_api.ist_stammkunde.return_value = True
preis = endpreis(100.0, kunde_id=7, kunden_api=kunden_api)
assert preis == 95.0
kunden_api.ist_stammkunde.assert_called_once_with(7)
# Auch das AUFRUFVERHALTEN pruefen - oft der
# eigentliche Fehler.
def test_bei_api_fehler_wird_standardpreis_genommen():
kunden_api = Mock()
kunden_api.ist_stammkunde.side_effect = TimeoutError()
# Fehler muss behandelt werden, nicht durchschlagen
with pytest.raises(TimeoutError):
endpreis(100.0, 7, kunden_api)
# 2) Ersetzen einer importierten Funktion (sparsam verwenden!)
@patch("app.mail.senden")
def test_bestaetigung_wird_verschickt(senden):
bestellung_abschliessen(bestellung_id=42)
senden.assert_called_once()
betreff = senden.call_args.kwargs["betreff"]
assert "Bestellbestaetigung" in betreff
# WARNUNG: @patch koppelt den Test an den Importpfad.
# Wer "from app.mail import senden" nutzt, muss
# "app.xyz.senden" patchen. Sauberer: Abhaengigkeit
# uebergeben (wie in Fall 1) statt zu patchen.
# 3) Datenbank durch eine Attrappe ersetzen (kein Test-DB!)
@pytest.fixture
def speicher():
# Ersetzt die Datenbank durch ein Woerterbuch.
return Mock() # oder eine kleine Klasse
def test_bestellung_speichern(speicher):
bestellung_speichern({"id": 1}, speicher)
speicher.anlegen.assert_called_once_with({"id": 1})
# 4) Fixture mit Aufraeumen
@pytest.fixture
def temp_datei(tmp_path): # tmp_path ist eingebaut
datei = tmp_path / "test.txt"
datei.write_text("inhalt", encoding="utf-8")
yield datei
# Aufraeumen geschieht automatisch - nichts noetig
# Explizites Aufraeumen:
# yield ...
# if datei.exists(): datei.unlink()
Testaufbau mit Fixtures
import pytest
@pytest.fixture(scope="session")
def konfiguration():
# Einmal fuer die gesamte Testsuite.
return {"db_url": "postgresql://test", "debug": True}
@pytest.fixture
def kunde(konfiguration):
# Fuer jeden Test neu.
return Kunde(id=1, name="Testkunde", land="DE")
@pytest.fixture
def warenkorb(kunde):
return Warenkorb(kunde=kunde, positionen=[])
def test_leerer_warenkorb(warenkorb):
assert warenkorb.summe() == 0.0
def test_artikel_hinzufuegen(warenkorb):
warenkorb.hinzufuegen(Artikel(id=1, preis=19.99))
assert warenkorb.summe() == 19.99
# Umgebungsvariablen isolieren
@pytest.fixture(autouse=True)
def saubere_umgebung(monkeypatch):
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.delenv("DB_PASS", raising=False)
# Ausnahmen
@pytest.fixture
def fehlerhafte_datei(tmp_path):
datei = tmp_path / "kaputt.json"
datei.write_text("{kein json", encoding="utf-8")
return datei
def test_kaputte_konfiguration(fehlerhafte_datei):
with pytest.raises(ValueError):
konfiguration_lesen(fehlerhafte_datei)
Testabdeckung: das richtige Maß
# Abdeckung sagt, welcher Code AUSGEFUEHRT wurde - nicht,
# ob er RICHTIG ist. 100 % Abdeckung mit einem Test, der
# nichts prueft, ist wertlos.
# Sinnvolle Ziele:
# Reine Logik, Rechenregeln, Validierung 90 %+
# Aussenkontakt, Glue-Code 50-70 %
# Anzeige-Code, Konfiguration 20-40 %
# Insgesamt (Dienst) 70-85 %
# NICHT sinnvoll:
# Eine Zahl als Ziel vorgeben - dann werden Tests
# geschrieben, die die Zahl erhoehen, nicht die Qualitaet.
# Abdeckung messen
pytest --cov=app --cov-report=term-missing --cov-report=html
# Die "Missing"-Spalte zeigt ungelesene Zeilen -
# das ist der eigentliche Nutzen.
# Regel fuer die CI: Die Abdeckung darf nicht FALLEN.
# Ein fester Schwellwert ist besser als kein Wert,
# aber die Entwicklungstendenz ist entscheidend.
# Eigenschaften statt Beispiele (Property-Based Testing)
from hypothesis import given, strategies as st
@given(st.floats(min_value=0, max_value=10000, allow_nan=False))
def test_versandkosten_geben_nie_negativ(summe):
assert versandkosten(summe) >= 0
# Findet Grenzfaelle, an die niemand gedacht hat.
# Sehr wirksam bei Rechenregeln und Parser.
Prüfliste
- Laufen die Unit-Tests in unter 30 Sekunden?
- Sind Dienste (Datenbank, Netz, Zeit) ersetzt?
- Sind Grenzwerte getestet (0, Maximum, gerade noch zulässig)?
- Prüfen die Tests auch Verhalten (Aufrufe), nicht nur Rückgabewerte?
- Sind die Testnamen sprechend genug, dass eine Fehlermeldung die Ursache verrät?
- Gibt es keine Abhängigkeit von der Ausführungsreihenfolge?
- Sind Fixtures mit dem passenden Geltungsbereich gewählt (nicht session, wo function nötig ist)?
- Wird die Abdeckung gemessen und fällt sie nicht?
- Werden Fehlerkorrekturen IMMER von einem Test begleitet (sonst kehrt der Fehler zurück)?
Häufige Fehler
- Tests, die die Implementierung nachbilden. Wenn der Test die Interna kennt, bricht er bei jedem Umbau. Verhalten testen, nicht Aufbau.
- Zeitabhängigkeit. Tests, die um Mitternacht oder am Monatsende fehlschlagen. Zeit muss einfrierbar sein.
- Test-Datenbank für Unit-Tests. Dann ist es kein Unit-Test, sondern ein Integrationstest – langsam und unzuverlässig. Beides trennen.
- Zu viel
@patch. Jede Attrappe koppelt an einen Pfad. Abhängigkeiten übergeben statt patchen. - Abdeckung als Ziel. Führt zu Tests ohne Aussage.
- Keine Grenzwerte. Die Fehler stecken fast immer an den Rändern: 0, 1, Maximum, leer, nicht vorhanden.
- Testreihenfolge-abhängiger Zustand. Ein Test, der nur nach einem anderen läuft, verdeckt Fehler.
- Langsame Suite. Wenn Tests fünf Minuten brauchen, laufen sie nicht mehr bei jeder Änderung – und dann sind sie wertlos.
Verwandte Grundlagen: Unit-Test, pytest-Befehle, Testabdeckung, CI/CD.