Test-Coverage und Datenbereinigung: Von 220 auf 279 Tests und die Jagd nach unsichtbaren Zeichen
Test-Coverage und Datenbereinigung: Von 220 auf 279 Tests und die Jagd nach unsichtbaren Zeichen
Einleitung
Nachdem der große Debugging-Marathon der YNAB-Integration abgeschlossen war, stand eine wichtige Frage im Raum: Wie verhindern wir, dass diese Bugs wieder auftreten? Die Antwort liegt in zwei Säulen: umfassende Test-Coverage und ein systematischer Prozess für Bug-Fixes.
In diesem Post dokumentiere ich, wie ich die Test-Suite von 220 auf 279 Tests erweitert habe, warum der SyncSessionManager vorher NULL Tests hatte, und wie ich einen subtilen Bug entdeckte, bei dem Comdirect Zeilennummern-Präfixe in den Memos versteckte.
Ausgangslage
Nach dem Debugging-Marathon am Wochenende hatte ich mehrere kritische Bugs gefunden und gefixt:
- JSON-Encoding Bug (
Encode.int64→Encode.int) - Stale Reference Bug in
completeSession() - Memo-Truncation, die die Referenz abschnitt
Alle diese Bugs hatten eines gemeinsam: Sie wären vermeidbar gewesen, wenn der ursprüngliche Code Tests gehabt hätte.
Herausforderung 1: SyncSessionManager ohne Tests
Das Problem
Der SyncSessionManager ist das Herzstück der Sync-Logik. Er verwaltet:
- Session-Lifecycle (Start, Complete, Fail, Clear)
- Transaktions-Storage während des Syncs
- Status-Transitions (AwaitingBankAuth → FetchingTransactions → AwaitingTan → …)
- Zähler für importierte/übersprungene Transaktionen
Und dieser zentrale Code hatte ZERO Tests. Der QA-Milestone-Reviewer identifizierte das als kritische Lücke.
Warum war das so?
Der SyncSessionManager nutzt globalen mutablen State:
let private currentSession : SessionState option ref = ref None
Das machte Testing auf den ersten Blick schwierig - wie testet man globalen State isoliert?
Optionen, die ich betrachtet habe
- State-Refactoring zu funktionalem Ansatz
- Pro: Sauberer, testbarer Code
- Contra: Massive Änderungen am gesamten Backend nötig
- Dependency Injection für Session-State
- Pro: State kann pro Test injiziert werden
- Contra: Overhead für Single-User-App unnötig
- Sequenzielle Tests mit Reset (gewählt)
- Pro: Funktioniert mit existierendem Code
- Contra: Tests müssen sequenziell laufen
Die Lösung: testSequenced und resetSession()
Expecto bietet testSequenced, das Tests nacheinander ausführt statt parallel:
[<Tests>]
let sessionLifecycleTests =
testSequenced <| testList "Session Lifecycle Tests" [
test "startNewSession creates session with AwaitingBankAuth status" {
resetSession () // Wichtig: Isolation vor jedem Test!
let session = startNewSession ()
Expect.equal session.Status AwaitingBankAuth
"Session should start with AwaitingBankAuth status"
}
// ... weitere Tests
]
Die Erkenntnis: Manchmal ist die pragmatische Lösung besser als die “reine” Lösung. Ein Refactoring des gesamten Session-Managements hätte Wochen gedauert und neue Bugs eingeführt.
Was ich getestet habe
Ich schrieb 38 neue Tests in vier Kategorien:
- Session Lifecycle (11 Tests)
startNewSessionerstellt korrekten initialen StategetCurrentSessiongibt None zurück wenn keine Session existiertcompleteSessionsetzt Status UND Timestamp (nicht nur Status!)failSessionspeichert Fehlermeldung- Unique Session IDs
- Transaction Operations (14 Tests)
addTransactionsspeichert Transaktionen korrektgetTransactionfindet einzelne TransaktionenupdateTransactionmodifiziert nur die richtige Transaktion- Status-Counts sind akkurat
- Session Validation (7 Tests)
validateSessionerkennt fehlende SessionsvalidateSessionStatusprüft erwarteten State
- Edge Cases (6 Tests)
- Workflow-Simulation (kompletter Happy Path)
- State Transitions
- Transaction Overwrites
Regression Test für den Stale Reference Bug
Besonders wichtig war dieser Test:
test "completeSession sets Completed status and timestamp" {
resetSession ()
let _ = startNewSession ()
completeSession ()
let session = getCurrentSession ()
Expect.isSome session "Session should still exist"
Expect.equal session.Value.Status Completed "Status should be Completed"
Expect.isSome session.Value.CompletedAt "CompletedAt should be set"
}
Dieser Test hätte den Bug gefangen, wo completeSession() einen stale Reference verwendete und das Update ins Leere ging.
Herausforderung 2: Das Mandatory Bug Fix Protocol
Das Problem
Zwei Bugs an einem Tag waren vermeidbar gewesen:
- Stale Reference in
completeSession() Encode.int64→ String-Serialisierung
Beide wären mit Tests aufgefallen. Wie stelle ich sicher, dass das in Zukunft nicht passiert?
Die Lösung: CLAUDE.md Update
Ich habe ein “Bug Fix Protocol (MANDATORY)” in die Projekt-Dokumentation aufgenommen:
## Bug Fix Protocol (MANDATORY)
**CRITICAL**: Every bug fix MUST include a regression test. No exceptions.
### When Fixing a Bug:
1. **Understand the root cause** - Don't just fix symptoms
2. **Write a failing test FIRST** that reproduces the bug
3. **Fix the bug** - Make the test pass
4. **Verify no regressions** - Run full test suite
5. **Document in diary** - Include what test was added
Architekturentscheidung: Warum in CLAUDE.md?
CLAUDE.md ist die zentrale Instruktionsdatei für Claude Code. Jeder KI-Agent, der an diesem Projekt arbeitet, liest diese Datei zuerst. Damit ist garantiert, dass:
- Keine Bug-Fixes ohne Tests durchkommen
- Die Rationale für jeden Fix dokumentiert wird
- Edge Cases mitbedacht werden
Beispiel: Der JSON Encoding Test
testCase "amount is serialized as JSON number, not string" <| fun () ->
// This test prevents regression of the bug where Encode.int64 serialized
// amounts as strings (e.g., "-50250" instead of -50250), causing YNAB
// to silently reject transactions.
let transaction = createTestTransaction -50.25m
let json = encodeTransaction transaction |> Encode.toString 0
// Must contain: "amount": -50250 (number, no quotes)
Expect.isTrue (json.Contains("\"amount\": -50250"))
"Amount must be a JSON number, not a string"
Der Kommentar erklärt warum dieser Test existiert - nicht nur was er testet. Zukünftige Entwickler verstehen sofort, welchen Bug dieser Test verhindert.
Herausforderung 3: Die unsichtbaren Zeilennummern
Das Problem
In der UI erschienen Memos wie:
01REWE Jens Wechsler oHG//OSNABRUECK/DE
Statt:
REWE Jens Wechsler oHG//OSNABRUECK/DE
Die “01” am Anfang war ein Comdirect-spezifisches Format, das ich nie bemerkt hatte.
Die Ursache
Comdirect formatiert den Verwendungszweck (remittanceInfo) mit Zeilennummern:
- “01” = erste Zeile
- “02” = zweite Zeile
- usw.
Diese Präfixe sind für interne Comdirect-Verarbeitung gedacht und sollten dem Endbenutzer nicht angezeigt werden.
Optionen, die ich betrachtet habe
- Frontend-Filtering
- Pro: Einfach zu implementieren
- Contra: Falscher Ort - die Daten sollten schon sauber ankommen
- Backend-Filtering beim Parsing (gewählt)
- Pro: Daten sind von Anfang an sauber
- Contra: Braucht Regex (Komplexität)
- Separate Display-Funktion
- Pro: Rohdaten bleiben erhalten
- Contra: Überall wo Memo angezeigt wird, muss gefiltert werden
Die Lösung: Regex im Decoder
/// Removes Comdirect line number prefixes from remittance info.
/// Comdirect formats memo lines as "01TEXT", "02TEXT", etc.
let internal removeLineNumberPrefixes (text: string) : string =
System.Text.RegularExpressions.Regex.Replace(
text,
@"(^|\n)\d{2}(?=[A-Za-zÄÖÜäöüß])",
"$1"
).Trim()
Architekturentscheidung: Warum internal?
Die Funktion ist internal statt private, damit ich sie direkt testen kann. Das ist ein bewusster Trade-off:
private: Bessere Kapselung, aber nur indirekt testbarinternal: Testbar, aber theoretisch von anderen Assemblies aufrufbar
Für eine Single-User-Self-Hosted-App ist das kein Problem - es gibt keine “anderen Assemblies”.
Die Tests
10 Unit-Tests für verschiedene Szenarien:
[<Tests>]
let lineNumberPrefixTests =
testList "Comdirect Line Number Prefix Removal" [
testCase "removes 01 prefix from memo start" <| fun () ->
let input = "01BARGELDEINZAHLUNG"
let result = removeLineNumberPrefixes input
Expect.equal result "BARGELDEINZAHLUNG" "Should remove 01 prefix"
testCase "handles German umlauts correctly" <| fun () ->
let input = "01Überweisung"
let result = removeLineNumberPrefixes input
Expect.equal result "Überweisung" "Should handle Ü"
testCase "real-world example: REWE payment" <| fun () ->
let input = "01REWE Jens Wechsler oHG//OSNABRUECK/DE"
let result = removeLineNumberPrefixes input
Expect.equal result "REWE Jens Wechsler oHG//OSNABRUECK/DE"
"Should handle real REWE memo"
]
Wichtig: Die Tests enthalten echte Beispiele aus Comdirect-Transaktionen. Das macht sie aussagekräftiger als synthetische Test-Daten.
Edge Cases
Ein paar knifflige Fälle, die ich bedacht habe:
- Zahlen im Text: “01Amazon 25 EUR” → “Amazon 25 EUR” (nicht “Amazon EUR”)
- Zahlen ohne Buchstaben: “25.50” → “25.50” (kein Prefix, bleibt)
- Mehrzeilige Memos: “01Zeile1\n02Zeile2” → “Zeile1\nZeile2”
Der Regex (?=[A-Za-zÄÖÜäöüß]) (Lookahead für Buchstaben) war der Schlüssel - er matcht nur Zahlen, denen direkt ein Buchstabe folgt.
Herausforderung 4: Whitespace-Kompression für Memos
Das Problem
Neben den Zeilennummern hatte ein anderes Problem die Memos aufgebläht: Comdirect sendet Memos mit vielen Leerzeichen und Zeilenumbrüchen. Bei einem 300-Zeichen-Limit (YNAB) zählt jedes Zeichen.
Die Lösung
let private compressWhitespace (text: string) =
System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim()
Diese Funktion:
- Ersetzt mehrere Spaces/Tabs/Newlines durch ein einzelnes Leerzeichen
- Entfernt führende/trailing Whitespace
Zusammenspiel mit Memo-Building:
let private buildMemoWithReference (memo: string) (reference: string) : string =
let compressedMemo = compressWhitespace memo // Erst komprimieren
let suffix = $", Ref: {reference}"
let fullMemo = $"{compressedMemo}{suffix}"
if fullMemo.Length <= memoLimit then
fullMemo
else
// Truncate from the beginning, keeping the reference intact
// ...
Reihenfolge ist wichtig: Erst komprimieren, dann Reference anhängen, dann (falls nötig) truncaten. So maximieren wir den nutzbaren Memo-Inhalt.
Herausforderung 5: YNAB Memo-Limit Testing
Das Problem
Die ursprüngliche Implementierung verwendete ein 200-Zeichen-Limit für Memos. Aber woher kam diese Zahl?
Ein GitHub-Issue von 2019 behauptete 100 Zeichen Limit. Die offizielle YNAB-Dokumentation war unklar. Ich wollte das testen.
Der Experiment-Ansatz
/// YNAB memo character limit (testing with 300, may need adjustment)
let private memoLimit = 300
Statt eine Annahme zu treffen, habe ich:
- Das Limit auf 300 erhöht
- Echte Transaktionen mit langen Memos importiert
- Beobachtet, was YNAB akzeptiert
Ergebnis: YNAB akzeptiert mindestens 300 Zeichen. Die 100-Zeichen-Behauptung war veraltet.
Die Tests
test "long memo is truncated from beginning, reference preserved" {
let longMemo = String.replicate 350 "x" // Way longer than limit
let reference = "COMDIRECT123456789"
let result = buildMemoWithReference longMemo reference
Expect.equal result.Length memoLimit
$"Result must be exactly {memoLimit} characters"
Expect.stringStarts result "..."
"Truncated memo should start with ..."
Expect.stringEnds result $", Ref: {reference}"
"Reference must be at the end"
// Most importantly: extractReference must work!
let extracted = extractReference (Some result)
Expect.equal extracted (Some reference)
"Reference must be extractable from truncated memo"
}
Der wichtigste Test: Nicht nur dass die Länge stimmt, sondern dass extractReference immer noch funktioniert. Das ist der eigentliche Zweck des Memos - Duplicate Detection.
Lessons Learned
1. Globaler State ist testbar - mit Pragmatismus
Man muss nicht alles refactoren um es testbar zu machen. testSequenced + explizites Reset ist ein valider Ansatz für Single-User-Apps mit globalem State.
2. “Internal” ist besser als “keine Tests”
Die Puristen würden sagen: Teste nur öffentliche APIs. Aber für Bug-Prevention sind direkte Unit-Tests oft wertvoller. internal ist ein guter Kompromiss.
3. Echte Daten in Tests verwenden
Synthetische Test-Daten wie "test" und "abc" finden Edge Cases nicht. Echte Comdirect-Memos und YNAB-Responses in den Tests machen sie aussagekräftiger.
4. Limits aktiv testen, nicht annehmen
Die 100-Zeichen-Annahme war falsch. Wenn eine externe API ein Limit hat, teste es - die Dokumentation ist oft veraltet.
5. Kommentare, die den Bug erklären
Ein Test ohne Erklärung wird irgendwann gelöscht (“was macht der eigentlich?”). Ein Test mit Bug-Beschreibung wird respektiert.
Fazit
Die Test-Suite wuchs von 220 auf 279 Tests:
- +38 SyncSessionManager-Tests
- +10 Comdirect Zeilennummern-Tests
- +6 Memo-Truncation Regression Tests
- +3 Whitespace-Kompression Tests
- +2 JSON-Encoding Tests
Statistiken:
- Alle 279 Tests bestehen
- 6 Integration-Tests übersprungen (brauchen echte Credentials)
- Build-Zeit: ~15 Sekunden
Der wichtigste Outcome ist nicht die Anzahl der Tests, sondern das Bug Fix Protocol. Jeder zukünftige Bug wird einen Regression-Test bekommen. Das ist nachhaltiger als jede einmalige Test-Sprint.
Key Takeaways für Neulinge
-
Tests für mutablen State:
testSequenced+ Reset vor jedem Test ermöglicht isoliertes Testing auch bei globalem State -
Regression-Tests > Feature-Tests: Ein Test, der einen Bug verhindert, ist wertvoller als zehn Tests, die offensichtliches Verhalten prüfen
-
Dokumentiere das Warum: Test-Kommentare sollten erklären, welchen Bug sie verhindern - nicht nur was sie testen