Test-Isolation: Warum meine Persistence-Tests 236 Rules in die Production-DB geschrieben haben
Test-Isolation: Warum meine Persistence-Tests 236 Rules in die Production-DB geschrieben haben
Einleitung
Heute habe ich einen klassischen Bug gefunden, der zeigt, warum Test-Isolation so fundamental wichtig ist: Meine Persistence-Tests haben bei jedem Testlauf Daten in die echte Produktions-Datenbank geschrieben. Das Ergebnis? 236 identische “Test Rule”-Einträge in der SQLite-Datenbank.
Der Bug war subtil, weil die Tests alle grün waren. Erst als der Benutzer bemerkte, dass seine Rules-Liste hunderte von Duplikaten enthielt, wurde das Problem sichtbar. Die Lektion: Grüne Tests bedeuten nicht, dass alles korrekt ist – sie bedeuten nur, dass die Assertions erfüllt sind.
In diesem Post erkläre ich, wie ich das Problem diagnostiziert, welche Lösungsansätze ich versucht habe (und warum einige scheiterten), und wie die finale Lösung mit In-Memory SQLite aussieht.
Ausgangslage
BudgetBuddy verwendet SQLite als Datenbank, mit einer Datei unter ~/my_apps/budgetbuddy/budgetbuddy.db. Die Persistence-Schicht in Persistence.fs verwaltet alle Datenbankoperationen – Rules, Settings, Sync-Sessions und Transactions.
Für die Test-Coverage hatte ich PersistenceTypeConversionTests.fs erstellt, die sicherstellen, dass F#-Typen (wie PatternType, TargetField, TransactionStatus) korrekt in die Datenbank geschrieben und wieder gelesen werden können (“Roundtrip-Tests”).
Das Problem: Diese Tests riefen echte Persistence-Funktionen auf:
testCase "Regex roundtrip" <| fun () ->
let rule = createTestRule Regex Payee
Rules.insertRule rule |> Async.RunSynchronously // <- Schreibt in echte DB!
let retrieved = Rules.getRuleById rule.Id |> Async.RunSynchronously
// ...
Herausforderung 1: Die Diagnose – Warum sind da 236 Rules?
Das Problem
Der Benutzer meldete: “Kann es sein, dass Rules bei jedem Testdurchlauf erstellt werden? Ich habe jetzt in meinem System hunderte Rules.”
Meine erste Reaktion war: “Das kann nicht sein, die Tests erstellen doch In-Memory-Objekte.” Aber ein schneller Check bewies das Gegenteil:
sqlite3 ~/my_apps/budgetbuddy/budgetbuddy.db \
"SELECT name, pattern, COUNT(*) FROM rules GROUP BY name, pattern HAVING COUNT(*) > 1"
# Ergebnis:
Test Rule|test|236
236 identische Rules mit dem Namen “Test Rule” und dem Pattern “test”. Genau die Werte aus meiner Test-Helper-Funktion.
Warum ist das passiert?
Die Persistence.fs hatte eine statische Konfiguration:
// So war es vorher:
let private dbPath = Path.Combine(dataDir, "budgetbuddy.db")
let private connectionString = $"Data Source={dbPath}"
let private getConnection () = new SqliteConnection(connectionString)
Das bedeutet: Jeder Aufruf von getConnection() verbindet zur Produktions-DB – auch in Tests.
Lektion gelernt: Statische Datenbank-Konfiguration ohne Test-Override ist ein Rezept für Datenbank-Pollution.
Herausforderung 2: Der erste Lösungsversuch – Environment Variable
Die Idee
Der naheliegende Ansatz: Eine Environment-Variable USE_MEMORY_DB=true setzen, die den Connection-String auf In-Memory SQLite umschaltet:
let private connectionString =
if isTestMode then
"Data Source=:memory:;Mode=Memory;Cache=Shared"
else
$"Data Source={dbPath}"
Das Problem: F# Module-Initialisierung
Ich wollte die Variable in Main.fs setzen:
[<EntryPoint>]
let main args =
Environment.SetEnvironmentVariable("USE_MEMORY_DB", "true")
runTestsInAssemblyWithCLIArgs [] args
Das funktionierte nicht! Warum? F# initialisiert Module beim Assembly-Load, bevor der Entry-Point aufgerufen wird. Die Persistence-Konfiguration wurde also bereits mit dem Production-Connection-String initialisiert.
Der zweite Versuch: TestSetup-Modul
Ich erstellte ein TestSetup.fs als erstes Modul in der fsproj:
module TestSetup
open System
do Environment.SetEnvironmentVariable("USE_MEMORY_DB", "true")
Und importierte es in den Tests:
open TestSetup // Soll zuerst ausgeführt werden
open Persistence
Auch das funktionierte nicht! F# initialisiert Module nicht nach open-Reihenfolge, sondern nach Abhängigkeitsgraph. Da PersistenceTypeConversionTests direkt open Persistence hat, wurde das Persistence-Modul vor TestSetup initialisiert.
Lektion gelernt: Man kann die Modul-Initialisierungsreihenfolge in F# nicht über open steuern.
Herausforderung 3: Die Lösung – Lazy Loading
Der Durchbruch
Die Lösung war, die Datenbank-Konfiguration lazy zu laden – also erst beim ersten tatsächlichen Zugriff:
type private DbConfig = {
IsTestMode: bool
ConnectionString: string
SharedConnection: SqliteConnection option
}
let private dbConfig = lazy (
let isTestMode =
match Environment.GetEnvironmentVariable("USE_MEMORY_DB") with
| "true" | "1" -> true
| _ -> false
let connectionString =
if isTestMode then
"Data Source=:memory:;Mode=Memory;Cache=Shared"
else
let dbPath = Path.Combine(dataDir, "budgetbuddy.db")
$"Data Source={dbPath}"
// Für In-Memory: Shared Connection halten
let sharedConnection =
if isTestMode then
let conn = new SqliteConnection(connectionString)
conn.Open()
Some conn
else
None
{ IsTestMode = isTestMode; ConnectionString = connectionString; SharedConnection = sharedConnection }
)
Warum lazy?
- Die Konfiguration wird erst evaluiert, wenn
dbConfig.Force()aufgerufen wird - Das passiert beim ersten
getConnection()-Aufruf - Zu diesem Zeitpunkt hat der Test bereits die Environment-Variable gesetzt
Der Test-Code muss nur vor dem ersten Persistence-Zugriff die Variable setzen:
module PersistenceTypeConversionTests
open System
// CRITICAL: Set test mode BEFORE importing Persistence module
do Environment.SetEnvironmentVariable("USE_MEMORY_DB", "true")
open Persistence // Jetzt wird dbConfig.Force() noch nicht aufgerufen
Herausforderung 4: In-Memory SQLite und Connection-Lifecycle
Das Problem
In-Memory SQLite hat eine Besonderheit: Die Datenbank existiert nur solange die Connection offen ist. Wenn die Connection geschlossen oder disposed wird, verschwindet die komplette Datenbank.
Der originale Code verwendete use:
let getAllRules () =
async {
use conn = getConnection() // <- Disposed am Ende!
let! rows = conn.QueryAsync<RuleRow>("SELECT * FROM rules")
return rows |> Seq.map rowToRule |> Seq.toList
}
Mit use wird die Connection am Ende des Scopes disposed. Bei In-Memory SQLite bedeutet das: Die Tabellen verschwinden sofort wieder!
Die Lösung: Shared Connection + kein Dispose
Für den Test-Modus verwende ich eine geteilte Connection, die nie disposed wird:
let private getConnection () =
let config = dbConfig.Force()
match config.SharedConnection with
| Some conn -> conn // Shared Connection – nicht disposen!
| None -> new SqliteConnection(config.ConnectionString)
Und alle use conn = getConnection() wurden zu let conn = getConnection() geändert:
let getAllRules () =
async {
let conn = getConnection() // <- Kein use = kein Dispose
let! rows = conn.QueryAsync<RuleRow>("SELECT * FROM rules")
return rows |> Seq.map rowToRule |> Seq.toList
}
Ist das nicht ein Memory-Leak?
Theoretisch ja – Connections werden nicht mehr disposed. Aber:
- Im Test-Modus wird die shared Connection beim Prozess-Ende automatisch aufgeräumt
- Im Production-Modus könnte man Connection-Pooling einführen (SQLite managed das intern eh)
- Für eine Single-User Self-Hosted App ist das vertretbar
Trade-off: Einfachheit vs. pedantisches Resource-Management. Für BudgetBuddy habe ich mich für Einfachheit entschieden.
Herausforderung 5: Der gescheiterte Wrapper-Ansatz
Die Idee
Bevor ich zur let-Lösung kam, versuchte ich einen Wrapper:
type private NonDisposableConnection(conn: SqliteConnection) =
inherit SqliteConnection()
override _.Dispose(_disposing) = () // Ignoriere Dispose
override _.CreateCommand() = conn.CreateCommand()
// ... weitere Overrides
Warum es scheiterte
SqliteCommand greift intern direkt auf die Connection zu – nicht über die überschriebenen Methoden. Der Wrapper funktionierte für einfache Operationen, aber Dapper’s ExecuteAsync brach mit kryptischen Fehlern ab:
SafeHandle cannot be null. (Parameter 'pHandle')
Lektion gelernt: Manchmal ist die einfache Lösung (kein Wrapper, einfach let statt use) besser als die “clevere” Lösung.
Das Ergebnis
Nach dem Fix:
# Vor Tests:
sqlite3 ~/my_apps/budgetbuddy/budgetbuddy.db "SELECT COUNT(*) FROM rules"
# -> 0
# Tests laufen:
dotnet test src/Tests/Tests.fsproj
# Nach Tests:
sqlite3 ~/my_apps/budgetbuddy/budgetbuddy.db "SELECT COUNT(*) FROM rules"
# -> 0 (unverändert!)
215 Tests bestanden, davon 20 Persistence-Tests die komplett isoliert in einer In-Memory-Datenbank laufen.
Lessons Learned
1. Statische Datenbank-Konfiguration ist gefährlich
Wenn die Datenbank-Connection beim Module-Load konfiguriert wird, ist Test-Isolation praktisch unmöglich. Lazy Loading ist der Schlüssel.
2. F# Module-Initialisierung ist nicht trivial
Module werden nicht nach open-Reihenfolge initialisiert, sondern nach Abhängigkeitsgraph. Man kann nicht “vor” einer Dependency Code ausführen, außer durch Lazy-Patterns.
3. In-Memory SQLite braucht Connection-Management
Die Datenbank lebt nur solange die Connection. Für Tests muss man entweder:
- Eine Shared Connection verwenden (mein Ansatz)
- Pro Test eine neue Connection + Schema erstellen (aufwändiger)
4. Grüne Tests garantieren nichts
Die Tests waren die ganze Zeit grün! Sie haben korrekt geprüft, dass geschriebene Daten wieder gelesen werden können. Dass sie das in der Production-DB taten, war kein Testfehler – es war ein Konfigurations-Fehler.
Fazit
Was als “hunderte doppelte Rules” begann, führte zu einer fundamentalen Verbesserung der Test-Infrastruktur:
- CLAUDE.md wurde um den Anti-Pattern “Tests writing to production database” erweitert
- Persistence.fs unterstützt jetzt In-Memory SQLite via
USE_MEMORY_DB=true - Tests sind komplett isoliert von der Produktions-Datenbank
Die 236 Test-Rules wurden gelöscht, und ab jetzt wird das nie wieder passieren.
Key Takeaways für Neulinge
-
Test-Isolation ist nicht optional: Tests müssen unabhängig von Production-Daten sein. Verwende In-Memory-Datenbanken, Mocks, oder dedizierte Test-Datenbanken.
-
Lazy Loading für Konfiguration: Wenn Tests andere Konfiguration brauchen als Production, verwende
lazyoder Factory-Pattern um den Zeitpunkt der Konfiguration zu kontrollieren. -
Verstehe deine Runtime: F# Module-Initialisierung, SQLite In-Memory-Semantik, Connection-Lifecycle – diese Details machen den Unterschied zwischen funktionierenden und subtil kaputten Tests.