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

  1. Laufen die Unit-Tests in unter 30 Sekunden?
  2. Sind Dienste (Datenbank, Netz, Zeit) ersetzt?
  3. Sind Grenzwerte getestet (0, Maximum, gerade noch zulässig)?
  4. Prüfen die Tests auch Verhalten (Aufrufe), nicht nur Rückgabewerte?
  5. Sind die Testnamen sprechend genug, dass eine Fehlermeldung die Ursache verrät?
  6. Gibt es keine Abhängigkeit von der Ausführungsreihenfolge?
  7. Sind Fixtures mit dem passenden Geltungsbereich gewählt (nicht session, wo function nötig ist)?
  8. Wird die Abdeckung gemessen und fällt sie nicht?
  9. Werden Fehlerkorrekturen IMMER von einem Test begleitet (sonst kehrt der Fehler zurück)?

Häufige Fehler

  1. Tests, die die Implementierung nachbilden. Wenn der Test die Interna kennt, bricht er bei jedem Umbau. Verhalten testen, nicht Aufbau.
  2. Zeitabhängigkeit. Tests, die um Mitternacht oder am Monatsende fehlschlagen. Zeit muss einfrierbar sein.
  3. Test-Datenbank für Unit-Tests. Dann ist es kein Unit-Test, sondern ein Integrationstest – langsam und unzuverlässig. Beides trennen.
  4. Zu viel @patch. Jede Attrappe koppelt an einen Pfad. Abhängigkeiten übergeben statt patchen.
  5. Abdeckung als Ziel. Führt zu Tests ohne Aussage.
  6. Keine Grenzwerte. Die Fehler stecken fast immer an den Rändern: 0, 1, Maximum, leer, nicht vorhanden.
  7. Testreihenfolge-abhängiger Zustand. Ein Test, der nur nach einem anderen läuft, verdeckt Fehler.
  8. 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.