DoMe Dynamics
AI Agent Systems
Zurueck zum Log
testingpytestdatensicherheitpython

pytest-Test-Isolation: Wie ein zur Import-Zeit eingefrorener DB-Pfad die Produktionsdatenbank leert

Wie ich zweimal meine eigene Datenbank löschte — und was Test-Isolation wirklich heißt

Zweimal hat eine Test-Suite meine produktive Datenbank geleert, obwohl ich sicher war, isoliert zu testen. Die Diagnose: ein eingefrorener Pfad zur Import-Zeit. Was daraus folgt.

Zweimal hat eine Test-Suite meine produktive Datenbank geleert. Beide Male war ich sicher, isoliert zu testen. Beide Male lag ich falsch — und zwar auf unterschiedliche Arten. Das ist das Unbehaglichste daran: der zweite Verlust passierte, nachdem ich aus dem ersten gelernt hatte. Ich hatte eine Lösung gebaut. Die Lösung hatte eine Lücke.

Der erste Verlust

Der erste Vorfall war im Nachhinein offensichtlich. Die Test-Suite startete, und kein einziger Test hatte einen Pfad-Override gesetzt. conftest.py existierte, aber ohne Isolation-Mechanismus — keine Umgebungsvariable, keine monkeypatch-Zuweisung, kein tmp_path. Die Tests liefen einfach gegen die echte Datendatenbank, weil nirgendwo ein anderer Pfad stand.

DELETE FROM im Teardown. Tabellen sauber. Daten weg.

Dass ich das nicht sofort gesehen hatte, lag an einem mentalen Kurzschluss: ich hatte Testcode gelesen und war davon ausgegangen, dass Testcode automatisch eine andere Umgebung meint. Das stimmt nicht. Tests laufen in derselben Python-Umgebung wie Produktionscode, greifen auf dieselben Pfade zu, sofern niemand etwas überschreibt — und ob irgendjemand etwas überschreibt, steht nicht auf der Stirn des conftest geschrieben.

Die Reaktion war die naheliegende: Isolation nachrüsten. Umgebungsvariable für den Datenbankpfad, monkeypatch.setenv im conftest, Teardown gegen eine temporäre Datei. Beim nächsten Testlauf: isoliert, sauber, kein Datenverlust. Ich hielt das Problem für gelöst.

Der zweite, raffiniertere Verlust

Der zweite Vorfall war subtiler. Das conftest sah diesmal korrekt aus. Der Override war gesetzt. Beim Überfliegen der Datei hätte ich unterschrieben, dass die Isolation stimmt.

Trotzdem war calls.db danach leer.

Die Diagnose dauerte länger als sie sollte, weil ich an der falschen Stelle gesucht hatte: ich hatte die Testinfrastruktur geprüft und die Produktionsmodule nicht in Frage gestellt. Der Fehler lag dort.

Ein Modul hatte auf Modulebene eine Konstante definiert:

DB_PATH = str(DATA_DIR / "calls.db")

Das klingt harmlos. Es ist es nicht. Python friert diesen Wert beim Import ein — in dem Moment, in dem das Modul das erste Mal geladen wird. Was danach passiert — delenv, reload(config), reload(module) im Teardown — kommt zu spät. Der String ist ein String. Er zeigt weiterhin auf ./data/calls.db, die echte Datenbank. Das Modul hat den Override nie gesehen.

Der conftest hatte korrekt die Umgebungsvariable gesetzt. Das Modul hatte sie korrekt ignoriert, weil es sie zum Zeitpunkt der Pfadauflösung nicht mehr gab — dieser Zeitpunkt lag Millisekunden vor dem ersten Testaufruf, im Import-Schritt, bevor irgendein Test-Setup lief.

Lazy statt eingefroren

Das Anti-Pattern ist die Modulkonstante. Die einzige sichere Alternative ist eine Funktion, die config.DATA_DIR bei jedem Aufruf frisch liest:

def _db_path() -> str:
    return str(config.DATA_DIR / "calls.db")

Erst das macht den Pfad überschreibbar. Wenn der Test DATA_DIR via Umgebungsvariable auf ein temporäres Verzeichnis zeigt und das Modul _db_path() bei jedem Datenbankzugriff aufruft, landet der Zugriff auch tatsächlich auf der Test-DB. Keine Import-Zeitkonstante, keine eingefrorene Referenz.

Dazu kommen zwei harte Guards, die ich danach eingebaut habe:

Erstens ein RuntimeError im conftest-Header: wenn der aufgelöste Override-Pfad mit dem echten Produktionspfad identisch ist, bricht das Setup sofort ab. Nicht warnen — abbrechen. Das fängt den Fall ab, in dem der Override-Mechanismus selbst nicht greift.

Zweitens eine Poison-Guard nach jedem Test: ein Assertion, die den von _db_path() aufgelösten Pfad gegen ./data prüft. Wenn das Modul trotz allem auf den echten Pfad zeigt — vielleicht weil ein Reload fehlgeschlagen ist, vielleicht weil ein neues Modul die Konstante wieder eingeführt hat — schlägt die Guard an. Kein stilles Übergehen, kein Hoffnungstest.

Die beiden Guards zusammen machen einen dritten Vorfall unwahrscheinlicher. Aber nur, wenn der Code sie nicht umgeht — und das können neue Module, die das Anti-Pattern wieder einführen, jederzeit tun.

Der conftest-Header beweist nichts

Das ist die Lektion, die ich am meisten gebraucht hätte, bevor der zweite Vorfall passierte: das Lesen des conftest-Headers beweist keine Isolation. Es beweist, dass ein Override-Mechanismus beschrieben ist. Ob er tatsächlich greift, steht da nicht.

Der Trugschluss lautet: ich habe den conftest gelesen und gesehen, dass eine Test-DB gesetzt wird, also ist die Isolation in Ordnung. Dieser Trugschluss hat den zweiten Verlust mitverursacht. Ich hatte nach dem ersten Vorfall den conftest erweitert und das als ausreichend betrachtet.

Ausreichend ist es nicht. Ausreichend ist ein Row-Count-Snapshot der echten Datenbanken vor und nach einem echten Suite-Lauf. Wenn die Zahlen übereinstimmen, war die Isolation in Ordnung — nicht weil der conftest so aussieht, als wäre sie in Ordnung, sondern weil die Daten das bestätigen. Ein Mismatch nach dem Lauf ist ein Bug. Sofort melden, Suite nicht weiterführen.

Das kostet zwei SQL-Abfragen. Es lohnt sich.

Was offen ist

Die Guards, die ich jetzt habe, sind reaktiv. Sie fangen den Fehler, nachdem die Architektur ihn zugelassen hat. Der RuntimeError im conftest-Header greift erst, wenn die Test-Session startet. Die Poison-Guard greift, nachdem ein Test gelaufen ist, der möglicherweise bereits geschrieben hat.

Was fehlt, ist eine strukturelle Lösung: dass Produktionscode in Testmodus schlicht keinen Zugriff auf ./data haben kann — nicht weil ein Guard dagegen anfeuert, sondern weil der Pfad in diesem Modus physisch nicht existiert oder auf ein Read-only-Mount zeigt. Das wäre Isolation auf Betriebssystem-Ebene statt auf Python-Ebene.

Ich habe das noch nicht gebaut. Die Guards halten den Betrieb aufrecht, und der Aufwand für eine echte Sandbox ist real. Aber ich habe jetzt zweimal erlebt, was passiert, wenn der Schutz auf Konvention beruht statt auf Konstruktion — und das macht mich vorsichtiger als ich es vor diesen Vorfällen war.

Ein eingefrorener Pfad sieht aus wie ein gesetzter Pfad. Der Unterschied ist unsichtbar, bis er nicht mehr ist.


Annahmen über Test-Isolation stellen sich oft erst im Ernstfall als falsch heraus. Wenn du ähnliche Vorfälle erlebt hast oder andere Ansätze zur strukturellen Isolation kennst, schreib mir.