Du hast 90% Code-Coverage und wurdest trotzdem um 2 Uhr nachts gerufen.

Die Unit Tests liefen durch. Die CI war grün. Der Bug kam trotzdem in die Produktion. Coverage hat nicht gelogen, aber die Wahrheit auch nicht gesagt. Sie hat gemessen, welche Zeilen ausgeführt wurden – nicht, welche Verhaltensweisen tatsächlich geprüft wurden.

Die meisten Teams lernen das auf die harte Tour. Sie schreiben hunderte Unit Tests, sehen das Coverage-Badge grün werden und glauben, die Festung sei sicher. Die Festung hat Mauern. Sie hat nur kein Dach.

Unit Tests testen nur das, was du dir als Fehlerquelle vorstellen kannst

Unit Tests validieren deine Annahmen über deinen Code. Das Problem ist, dass Bugs sich nicht für deine Annahmen interessieren.

Betrachte eine einfache Preisberechnungsfunktion:

def calculate_total(items, tax_rate):
    subtotal = sum(item["price"] * item["quantity"] for item in items)
    tax = subtotal * tax_rate
    return round(subtotal + tax, 2)

Eine typische Unit-Test-Suite sieht solide aus:

def test_calculate_total_with_tax():
    items = [{"price": 10.00, "quantity": 2}]
    assert calculate_total(items, 0.08) == 21.60

def test_calculate_total_empty_cart():
    assert calculate_total([], 0.08) == 0.00

Beide laufen durch. Die Coverage ist 100%. Die Funktion wird ausgeliefert.

Dann kauft ein Kunde in Japan drei Artikel zu je ¥100, ¥100 und ¥100. Der Steuersatz ist 0,10. Der erwartete Gesamtbetrag ist ¥330. Die Funktion gibt ¥330,00 zurück. In Ordnung.

Ein Kunde in der Schweiz kauft einen Artikel für CHF 12,35 mit 7,7% Mehrwertsteuer. Erwartet: CHF 13,30. Tatsächlich: CHF 13,30. Immer noch in Ordnung.

Dann kauft ein Kunde zwei Artikel zu je $0,01 in Oregon, wo der Steuersatz 0,0 ist. Erwartet: $0,02. Tatsächlich: $0,02. Bestanden.

Der Bug taucht auf, wenn ein Kunde in einer Region mit einem Steuersatz von None eincheckt (weil der Steuerdienst für eine nicht erkannte Postleitzahl null zurückgegeben hat). Die Funktion multipliziert subtotal * None und wirft einen TypeError. Deine Unit Tests haben None nie als Steuersatz übergeben, weil du angenommen hast, es wäre immer ein float.

Das ist die grundlegende Einschränkung. Unit Tests durchlaufen die Pfade, die du testen wolltest. Bugs leben in den Pfaden, die du nicht bedacht hast.

Die vier Bereiche, die Unit Tests nicht erreichen können

Integration Boundaries

Unit Tests ersetzen external dependencies durch Mocks. Mocks sind höflich. Sie tun genau das, was du ihnen sagst. Echte APIs sind nicht höflich.

Deine Mock-Datenbank gibt Zeilen in Millisekunden zurück. Die Produktion gibt sie in Sekunden zurück, oder sie läuft in einen Timeout, oder gibt doppelte Zeilen zurück wegen eines Read-Replica-Lags, von dem du nichts wusstest.

Dein Mock-HTTP-Client gibt sauberes JSON zurück. Der echte Dienst gibt an manchen Tagen einen 200 mit einem leeren Body zurück.

Mocks testen deinen Code gegen deine Annahmen über andere Systeme. Die Produktion testet deinen Code gegen die Realität. Das sind unterschiedliche Test-Suites mit unterschiedlichen Pass-Rates.

Stateful and Temporal Bugs

Unit Tests laufen isoliert. Jeder Test bekommt einen frischen Zustand. Die Produktion ist ein lang laufender Prozess, in dem sich Zustand ansammelt, leaked und mit sich selbst interagiert.

Ein Memory-Cache, der Einträge unter Last entfernt. Ein Connection-Pool, der sich nach 10.000 Requests erschöpft. Ein Timestamp-Vergleich, der fehlschlägt, wenn der Test über eine Zeitumstellung läuft. Diese Bugs brauchen Zeit, Volumen oder eine bestimmte Sequenz, um sich zu manifestieren. Unit Tests haben keines davon.

Concurrency and Race Conditions

Zwei User aktualisieren denselben Datensatz gleichzeitig. Ein Request liest einen Kontostand, ein anderer belastet ihn, der erste schreibt den veralteten Wert zurück. Geld verschwindet. Deine Unit Tests laufen sequentiell in einem einzigen Thread. Sie können das nicht abfangen.

Du kannst Unit Tests für einzelne Locking-Primitives schreiben. Du kannst keinen Unit Test schreiben, der beweist, dass dein gesamtes System race-free ist. Der Zustandsraum ist zu groß und das Timing zu nicht-deterministisch.

The Environment Itself

Deine Tests laufen auf Ubuntu 22.04 mit Python 3.11, 4 GB RAM und keine Firewall-Regeln. Die Produktion läuft auf Alpine Linux mit Python 3.11, 512 MB RAM und einer Security Group, die inaktive TCP-Verbindungen nach 60 Sekunden abbricht.

Das socket-Modul verhält sich anders. Die mmap-Limits sind niedriger. Die Locale-Einstellungen führen dazu, dass strftime Daten in einem Format ausgibt, das dein Parser nicht erwartet. Das sind keine Code-Bugs. Das sind Context-Bugs. Unit Tests haben keinen Context.

Warum Coverage-Prozente in die Irre führen

Coverage-Tools messen die Ausführung von Zeilen, nicht die Qualität von Assertions. Ein Test kann jede Zeile einer Funktion ausführen und nichts Bedeutsames prüfen.

def test_poor_coverage_quality():
    result = calculate_total([{"price": 1.0, "quantity": 1}], 0.0)
    # Executed 100% of lines. Verified almost nothing.
    assert result is not None

Dieser Test gibt dir 100% Line-Coverage und null Vertrauen. Viele Teams optimieren für die Metrik, weil sie leicht zu messen ist. Vertrauen ist schwer zu messen. Also messen sie stattdessen Coverage und hoffen, dass die beiden korrelieren.

Tun sie nicht.

Was stattdessen getestet werden sollte (oder zusätzlich)

Das ist kein Argument gegen Unit Tests. Unit Tests sind schnell, deterministisch und exzellent, um algorithmische Logik zu prüfen. Sie sind nur unvollständig.

Hier ist, was die Lücken schließt, ohne deine CI-Pipeline zu einer 45-minütigen Belastung zu machen.

An System Boundaries testen, nicht nur Internals

Statt die Datenbank zu mocken, schreibe Tests, die eine echte Testdatenbank ansprechen. Diese sind langsamer, also führe sie selektiv aus. Aber sie fangen den Mismatch zwischen deinen ORM-Queries und dem tatsächlichen Verhalten des Query-Planners ab.

Statt den HTTP-Client zu mocken, starte den Downstream-Service in einem Container. Das fängt Schema-Drift, Timeout-Verhalten und Retry-Logic ab, die nur bei echten Verbindungsfehlern ausgelöst wird.

Contract Tests für External Services hinzufügen

Wenn du die echte dependency in der CI nicht laufen lassen kannst, nutze Contract Tests. Diese prüfen, ob deine Consumer-Expectations mit dem tatsächlichen API-Schema des Providers übereinstimmen.

Tools wie Pact zeichnen die Interaktionen zwischen deinem Service und seinen dependencies auf. Wenn der Provider einen Feldtyp ändert oder einen Endpoint entfernt, schlägt der Contract Test fehl, bevor der Code deployed wird. Es ist nicht so gut wie Integration Testing, aber viel besser, als zu hoffen, dass deine Mocks akkurat sind.

Property-Based Testing für Edge Cases nutzen

Property-Based-Testing-Tools wie Hypothesis (Python) oder fast-check (JavaScript) generieren tausende zufällige Inputs und prüfen, ob deine Invariants eingehalten werden.

from hypothesis import given, strategies as st

@given(
    st.lists(st.fixed_dictionaries({
        "price": st.decimals(min_value=0, max_value=10000, places=2),
        "quantity": st.integers(min_value=0, max_value=1000)
    })),
    st.one_of(st.none(), st.decimals(min_value=0, max_value=1, places=4))
)
def test_calculate_total_invariants(items, tax_rate):
    if tax_rate is None:
        with pytest.raises(TypeError):
            calculate_total(items, tax_rate)
        return

    result = calculate_total(items, tax_rate)
    assert result >= 0
    assert result == round(result, 2)

Dieser Test hätte den None-Steuersatz-Bug gefunden, ohne dass du daran gedacht hättest, diesen spezifischen Fall zu schreiben. Er generiert Inputs, die du nie in Betracht ziehen würdest: leere Listen, riesige Listen, Null-Preise, Dezimalzahlen mit maximaler Präzision. Er findet die Grenzen deiner Logik, ohne dass du sie dir zuerst vorstellen musst.

Die Produktion wie ein Test-Environment überwachen

Die ehrlichste Test-Suite ist der Produktiv-Traffic. Wenn du einen Bug nicht vor dem Ausliefern fangen kannst, fange ihn, bevor er Schaden anrichtet.

Nutze Feature Flags, um Änderungen zuerst an 1% der User auszurollen. Beobachte Error-Rates, Latenz-Perzentile und Business-Metriken. Ein Unit Test sagt dir, ob sich der Code isoliert wie erwartet verhält. Ein Production Monitor sagt dir, ob sich der Code in der Realität wie erwartet verhält.

Richte Alerts auf Anomalien ein, nicht nur auf harte Fehler. Ein 5%-iger Anstieg von 500-Fehlern nach einem Deploy ist oft das einzige Signal, dass eine Race Condition oder ein Resource Leak begonnen hat. Unit Tests werden dir das nie zeigen.

Das ehrliche Trade-off

Unit Tests sind billig, schnell und gut für Developer-Feedback-Loops. Integration Tests sind teuer, langsam und gut, um die Bugs zu finden, die zählen.

Du brauchst beides. Die Falle ist zu denken, dass 100% Unit-Test-Coverage bedeutet, du kannst den Rest überspringen. Es bedeutet, dass du die einfachen Teile gründlich getestet hast. Die schwierigen Teile, die dich nachts wecken, leben dort, wo deine Tests nicht hinschauen.

Beginne mit Unit Tests für Logik und Algorithmen. Füge Integration Tests an jeder System Boundary hinzu. Nutze Property-Based Testing, um die Inputs zu finden, an die du nicht gedacht hast. Überwache die Produktion, um das abzufangen, was jeder Test verpasst hat.

Coverage ist eine Vanity Metric. Die einzige Metrik, die zählt, ist, ob du die Nacht durchschläfst.


Wenn du versuchst, die Bugs zu finden, die deine Tests verpassen, fang damit an, deine Error-Daten anzusehen. Sentry zeigt dir, was in der Produktion kaputtgeht – mit den Stack Traces und dem Context, den deine Unit Tests nie hatten.