Anfangs haben wir nur drei Tests geschrieben. Einer davon hat die echte HTTP-Verbindung zu einem RSS-Feed benutzt. Das ging so lange gut, bis der Feed-Betreiber ein neues SSL-Zertifikat aktiviert hat und unser Test rot wurde. Ein zweiter Test hat die echte Ollama-Instanz auf dem Server angesprochen und war bei voller Last instabil. Der dritte Test war ein End-to-End-Smoketest, der bei jeder kleinen Änderung an der LLM-Prompt-Datei flackerte.
Das Problem war klar: Wir hatten Tests, aber keine Test-Strategie. Externe Abhängigkeiten waren überall im Testcode hartkodiert. Die SQLite-Datenbank wurde zwischen den Tests geteilt. LLM-Antworten wurden nicht deterministisch behandelt. Wir konnten den Test-Lauf nicht reproduzieren, wenn etwas fehlschlug. Beim Refactoring haben wir mehr Zeit mit Debugging von Tests verbracht als mit dem eigentlichen Code.
Also haben wir das Testsetup komplett umgebaut. Heute läuft die Unit-Test-Suite in rund 12 Sekunden durch, die Integration-Tests brauchen etwa 90 Sekunden. Wir haben null flaky Tests in den letzten drei Monaten gemessen. Coverage auf der Daten-Layer liegt bei 94 Prozent. Diese Zahlen sind nicht magisch entstanden, sondern durch fünf konkrete Maßnahmen, die ich dir gleich zeige.
Anforderungen
Bevor wir Lösungen vergleichen, müssen wir klären, was die Tests überhaupt leisten müssen. Wir haben vier Anforderungen definiert, die für jede Pipeline-Testsuite sinnvoll sind.
Erstens: Schnelles Feedback. Unit-Tests müssen unter 100 Millisekunden pro Test laufen. Wer auf einen einzelnen Test länger als eine Sekunde wartet, fängt an, den Test zu skippen oder zu deaktivieren. Beide Verhaltensweisen sind Gift für eine Suite.
Zweitens: Reproduzierbarkeit. Ein Test, der heute grün und morgen rot ist, ist kein Test, sondern ein Ratespiel. Das bedeutet: Keine echten HTTP-Calls in Unit-Tests, keine echten LLM-Antworten, keine Abhängigkeit von Systemzeit oder Dateisystem-Ordnung.
Drittens: Realistische Daten. Wenn die Tests nur mit Dummy-Strings arbeiten, decken sie die echten Edge-Cases nicht ab. Wir hatten zum Beispiel den Fall, dass ein Feed published_parsed als None liefert, obwohl published gefüllt ist. Solche Edge-Cases findet man nur, wenn man mit echten Feed-Snapshots testet.
Viertens: Klare Trennung. Unit-Tests testen eine Funktion isoliert. Integration-Tests testen die Pipeline als Ganzes mit echter SQLite, aber gemockten externen Services. Beide haben unterschiedliche Laufzeiten und sollten getrennt ausführbar sein.
Aus diesen vier Anforderungen ergeben sich fast automatisch die Tools, die wir brauchen: pytest mit tmp_path-Fixtures, pytest-httpx für HTTP-Mocking, Hypothesis für Property-Tests und pytest-Marker für die Trennung.
Drei Optionen im Vergleich
Wir haben drei Ansätze ernsthaft evaluiert, bevor wir uns entschieden haben.
Option A war der Pure-Mock-Ansatz: Alles wird gemockt, sogar die SQLite-Datenbank. Vorteil: Tests sind superschnell und komplett isoliert. Nachteil: Wir mocken dann das, was wir eigentlich testen wollen. Bei einer Daten-Pipeline ist die Datenbank das Herzstück. Wenn wir sqlite3 durch ein Mock ersetzen, testen wir nicht mehr, ob unsere Queries unter Last funktionieren oder ob unsere Indices richtig gewählt sind. Diese Variante haben wir nach einem Prototyp verworfen.
Option B war der Realistic-Everything-Ansatz: Wir verwenden echte SQLite-Dateien, echte HTTP-Responses aus dem Cache, echte Ollama-Antworten aus Snapshots. Vorteil: maximale Realitätsnähe. Nachteil: das Setup war komplex. Wir mussten Hunderte von HTTP-Response-Dateien pflegen. Bei jeder kleinen Änderung am Parser mussten wir alle Snapshots aktualisieren. Außerdem waren die Snapshots groß und unübersichtlich.
Option C war der Hybrid-Ansatz und ist unsere aktuelle Lösung. Die Idee: Unit-Tests nutzen SQLite :memory: und gemockte HTTP-Antworten. Integration-Tests nutzen tmp_path für eine echte SQLite-Datei auf Disk und aufgezeichnete HTTP-Responses für genau die Endpunkte, die wir brauchen. LLM-Aufrufe werden in beiden Testarten mit einem deterministischen Stub ersetzt.
Warum Option C gewonnen hat: Sie gibt uns die Geschwindigkeit von Option A für den Entwicklungsalltag und die Realitätsnähe von Option B für die Pipeline-Verifikation. Der Pflegeaufwand ist deutlich geringer als bei Option B, weil wir nur HTTP-Responses für die wirklich kritischen Pfade aufzeichnen müssen.
Ein wichtiger Trade-off: Wir testen die Ollama-Antworten nicht gegen die echte Modell-Version. Das ist eine bewusste Entscheidung. Wenn wir ein neues Modell deployen, haben wir einen separaten Smoke-Test, der das echte Modell anspricht und die Verteilung der Klassifikations-Ergebnisse prüft. Aber dieser Test läuft nur nachts und nicht bei jedem Commit.
Die Lösung
Konkret sieht das so aus. Wir haben vier Bausteine, die zusammenspielen. Jeder Baustein adressiert eine konkrete Schwachstelle aus unserer alten Suite.
Baustein 1: SQLite-Fixture. In unserer conftest.py definieren wir eine Fixture, die für jeden Test eine frische In-Memory-Datenbank erzeugt und das Schema anlegt. Das dauert rund 3 Millisekunden pro Test, was vernachlässigbar ist. Der Clou: Wir verwenden sqlite3.Row als row_factory, damit wir im Test auf Spalten wie bei einem Dict zugreifen können. Production-Code nutzt dieselbe Bibliothek, sodass kein Mapping-Mismatch zwischen Tests und Realität entsteht.
import pytest
import sqlite3
from watchalert.db.schema import apply_schema
@pytest.fixture
def db() -> sqlite3.Connection:
"""Frische SQLite-DB pro Test."""
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
apply_schema(conn)
yield conn
conn.close()
Für Integration-Tests gibt es eine zweite Fixture, die eine Datei-Datenbank in tmp_path anlegt. Damit können wir auch Multi-Process-Szenarien testen, etwa wenn die Watch-&-Alert-Pipeline und ein paralleler Heartbeat-Checker gleichzeitig schreiben. Die Datei wird nach dem Test automatisch aufgeräumt.
Baustein 2: Feed-Snapshots als Fixture-Daten. Wir haben ungefähr 20 handverlesene RSS- und Atom-Snapshots im Repository unter tests/fixtures/feeds/. Jede Datei ist ein realistischer Ausschnitt eines echten Feeds, oft mit absichtlich eingebauten Edge-Cases. Wir haben einen Snapshot mit kaputten Datumsangaben, einen mit HTML-Entities im Title, einen mit leerem Body. Eine Fixture lädt diese Dateien je nach Bedarf.
from pathlib import Path
@pytest.fixture
def feed_snapshot(request: pytest.FixtureRequest) -> str:
"""Lädt einen Feed-Snapshot aus tests/fixtures/feeds/."""
path = (
Path(__file__).parent
/ "fixtures" / "feeds" / request.param
)
return path.read_text(encoding="utf-8")
Mit pytest.mark.parametrize können wir