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.int64Encode.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

  1. State-Refactoring zu funktionalem Ansatz
    • Pro: Sauberer, testbarer Code
    • Contra: Massive Änderungen am gesamten Backend nötig
  2. Dependency Injection für Session-State
    • Pro: State kann pro Test injiziert werden
    • Contra: Overhead für Single-User-App unnötig
  3. 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:

  1. Session Lifecycle (11 Tests)
    • startNewSession erstellt korrekten initialen State
    • getCurrentSession gibt None zurück wenn keine Session existiert
    • completeSession setzt Status UND Timestamp (nicht nur Status!)
    • failSession speichert Fehlermeldung
    • Unique Session IDs
  2. Transaction Operations (14 Tests)
    • addTransactions speichert Transaktionen korrekt
    • getTransaction findet einzelne Transaktionen
    • updateTransaction modifiziert nur die richtige Transaktion
    • Status-Counts sind akkurat
  3. Session Validation (7 Tests)
    • validateSession erkennt fehlende Sessions
    • validateSessionStatus prüft erwarteten State
  4. 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:

  1. Stale Reference in completeSession()
  2. 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

  1. Frontend-Filtering
    • Pro: Einfach zu implementieren
    • Contra: Falscher Ort - die Daten sollten schon sauber ankommen
  2. Backend-Filtering beim Parsing (gewählt)
    • Pro: Daten sind von Anfang an sauber
    • Contra: Braucht Regex (Komplexität)
  3. 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 testbar
  • internal: 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:

  1. Zahlen im Text: “01Amazon 25 EUR” → “Amazon 25 EUR” (nicht “Amazon EUR”)
  2. Zahlen ohne Buchstaben: “25.50” → “25.50” (kein Prefix, bleibt)
  3. 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:

  1. Das Limit auf 300 erhöht
  2. Echte Transaktionen mit langen Memos importiert
  3. 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

  1. Tests für mutablen State: testSequenced + Reset vor jedem Test ermöglicht isoliertes Testing auch bei globalem State

  2. Regression-Tests > Feature-Tests: Ein Test, der einen Bug verhindert, ist wertvoller als zehn Tests, die offensichtliches Verhalten prüfen

  3. Dokumentiere das Warum: Test-Kommentare sollten erklären, welchen Bug sie verhindern - nicht nur was sie testen