Milestone 15: Polish & Testing - Der finale Schliff

Einleitung

Nach 14 Milestones intensiver Entwicklung war es Zeit für den finalen Schliff. Milestone 15 hatte ein scheinbar einfaches Ziel: Die Anwendung auf Herz und Nieren prüfen. Error Handling, Loading States, Form Validation und Tests - alles sollte einmal durchleuchtet werden.

Was als “nur noch aufräumen” begann, wurde zu einer wertvollen Lektion darüber, wie wichtig konsistente Patterns sind - und wie sehr sich frühe Architekturentscheidungen auszahlen.

In diesem Post zeige ich, was ich bei der Analyse gefunden habe, welche Änderungen nötig waren, und warum 52 neue Tests entstanden sind.

Ausgangslage

BudgetBuddy hatte nach 14 Milestones bereits eine solide Basis:

Die Frage war: Ist das wirklich “production ready”? Oder gibt es versteckte Inkonsistenzen?

Herausforderung 1: Inkonsistentes Error Handling in Elmish

Das Problem

Bei der systematischen Analyse der Frontend-Komponenten fiel mir auf, dass nicht alle async API-Aufrufe das gleiche Pattern verwendeten. Manche nutzten Cmd.OfAsync.perform, andere Cmd.OfAsync.either.

Der Unterschied ist kritisch:

// perform - SCHLECHT: Exceptions werden verschluckt!
Cmd.OfAsync.perform Api.sync.getCurrentSession () CurrentSessionLoaded

// either - GUT: Exceptions werden gefangen und als Error behandelt
Cmd.OfAsync.either
    Api.sync.getCurrentSession
    ()
    (Ok >> CurrentSessionLoaded)
    (fun ex -> Error ex.Message |> CurrentSessionLoaded)

Mit perform würde ein Netzwerkfehler die gesamte Elmish-Update-Loop crashen oder - noch schlimmer - stillschweigend ignoriert werden. Der User sieht dann einfach… nichts.

Die betroffenen Komponenten

Ich fand drei Stellen mit diesem Problem:

  1. Dashboard/State.fs: LoadCurrentSession und LoadRecentSessions
  2. Rules/State.fs: LoadRules

Interessanterweise war der Rest der Codebase bereits korrekt implementiert - Settings, SyncFlow und die meisten anderen Calls nutzten bereits either.

Die Lösung

Die Änderungen waren minimal, aber ihre Auswirkung groß. Hier am Beispiel des Dashboard:

Vorher (Types.fs):

type Msg =
    | LoadCurrentSession
    | CurrentSessionLoaded of SyncSession option
    | LoadRecentSessions
    | RecentSessionsLoaded of SyncSession list

Nachher (Types.fs):

type Msg =
    | LoadCurrentSession
    | CurrentSessionLoaded of Result<SyncSession option, string>
    | LoadRecentSessions
    | RecentSessionsLoaded of Result<SyncSession list, string>

Vorher (State.fs):

| LoadCurrentSession ->
    let cmd = Cmd.OfAsync.perform Api.sync.getCurrentSession () CurrentSessionLoaded
    { model with CurrentSession = Loading }, cmd

| CurrentSessionLoaded session ->
    { model with CurrentSession = Success session }, Cmd.none

Nachher (State.fs):

| LoadCurrentSession ->
    let cmd =
        Cmd.OfAsync.either
            Api.sync.getCurrentSession
            ()
            (Ok >> CurrentSessionLoaded)
            (fun ex -> Error ex.Message |> CurrentSessionLoaded)
    { model with CurrentSession = Loading }, cmd

| CurrentSessionLoaded (Ok session) ->
    { model with CurrentSession = Success session }, Cmd.none

| CurrentSessionLoaded (Error err) ->
    { model with CurrentSession = Failure err }, Cmd.none

Architekturentscheidung: Warum Result<’T, string>?

Ich habe mich bewusst für Result<'T, string> statt eines custom Error-Types entschieden:

  1. Konsistenz: Der Rest der Codebase nutzt dieses Pattern
  2. Einfachheit: Für UI-Fehlermeldungen reicht ein String
  3. Flexibilität: Die Error-Message kommt direkt aus der Exception

Herausforderung 2: Form Validation UX

Das Problem

Buttons, die zu invaliden API-Calls führen würden, waren nicht disabled. Der User konnte:

Optionen, die ich betrachtet habe

  1. Serverseitige Validation nur (abgelehnt)
    • Pro: Weniger Frontend-Code
    • Contra: Schlechte UX - warum einen Button klicken können, der garantiert fehlschlägt?
  2. Inline Error Messages (teilweise umgesetzt)
    • Pro: Klares Feedback was fehlt
    • Contra: Kann bei einfachen Fällen überladen wirken
  3. Disabled Buttons (gewählt)
    • Pro: Visuell sofort klar, dass Aktion nicht möglich
    • Contra: User weiß nicht immer warum
  4. Kombination aus 2 und 3 (ideal)
    • Das Input-System hat bereits Error-State-Support
    • Buttons zusätzlich disabled

Die Lösung

Settings/View.fs - YNAB Token Save:

let isTokenEmpty = System.String.IsNullOrWhiteSpace(model.YnabTokenInput)
Button.view {
    Button.defaultProps with
        Text = "Save"
        OnClick = (fun () -> dispatch SaveYnabToken)
        Variant = Button.Primary
        IsDisabled = isTokenEmpty
}

Settings/View.fs - Test Connection:

let hasYnabToken =
    match model.Settings with
    | Success s -> s.Ynab.IsSome
    | _ -> false

if hasYnabToken then
    Button.secondary "Test Connection" (fun () -> dispatch TestYnabConnection)
else
    Button.view {
        Button.defaultProps with
            Text = "Test Connection"
            Variant = Button.Secondary
            IsDisabled = true
    }

SyncFlow/View.fs - Import Button:

let canImport =
    match model.SyncTransactions with
    | Success transactions ->
        let readyTransactions =
            transactions
            |> List.filter (fun tx ->
                model.SelectedTransactions.Contains(tx.Transaction.Id) &&
                tx.Status <> Skipped &&
                tx.CategoryId.IsSome)
        not readyTransactions.IsEmpty
    | _ -> false

Button.view {
    Button.defaultProps with
        Text = "Import to YNAB"
        Variant = Button.Primary
        Icon = Some (Icons.upload Icons.SM Icons.Primary)
        OnClick = fun () -> dispatch ImportToYnab
        IsDisabled = not canImport
}

Rationale für die Import-Logik: Die Validation ist bewusst strikt: Eine Transaktion muss selektiert sein, darf nicht “Skipped” sein, UND muss eine Kategorie haben. Das verhindert, dass unvollständige Daten an YNAB gesendet werden.

Herausforderung 3: Die fehlenden Validation-Tests

Das Problem

Bei der Analyse der Test-Coverage fiel auf: Das Validation-Modul hatte null Tests. Dieses Modul enthält aber kritische Business-Logik:

Ohne Tests für diese Funktionen könnte eine Änderung an der Validation unbemerkt Business-Regeln brechen.

Die Test-Strategie

Ich habe mich für Boundary Value Testing entschieden - bei jedem Validator werden die Grenzen getestet:

// Beispiel: validateRange "Days" 1 90 value

test "returns Ok for minimum days" {
    let settings = { DaysToFetch = 1 }  // Untere Grenze
    let result = validateSyncSettings settings
    Expect.isOk result "Should return Ok for minimum days"
}

test "returns Ok for maximum days" {
    let settings = { DaysToFetch = 90 }  // Obere Grenze
    let result = validateSyncSettings settings
    Expect.isOk result "Should return Ok for maximum days"
}

test "returns Error for zero days" {
    let settings = { DaysToFetch = 0 }  // Unter der Grenze
    let result = validateSyncSettings settings
    Expect.isError result "Should return Error for zero days"
}

test "returns Error for too many days" {
    let settings = { DaysToFetch = 100 }  // Über der Grenze
    let result = validateSyncSettings settings
    Expect.isError result "Should return Error for too many days"
}

Die resultierende Test-Suite

52 neue Tests in ValidationTests.fs:

  1. Reusable Validators (14 Tests)
    • validateRequired: null, empty, whitespace, valid
    • validateLength: min, max, under, over, within
    • validateRange: min, max, under, over, within
  2. Settings Validation (16 Tests)
    • validateYnabToken: empty, too short, valid, minimum length
    • validateComdirectSettings: jedes Pflichtfeld leer, optional AccountId, multiple errors
    • validateSyncSettings: min, max, zero, negative, too many
  3. Rules Validation (17 Tests)
    • validateRuleName: empty, too long, valid
    • validatePattern: empty, too long, valid
    • validateRuleCreateRequest: valid, empty name, empty pattern, invalid priority, multiple errors
    • validateRuleUpdateRequest: valid, all None, Some empty name, Some empty pattern, invalid priority
  4. Transaction Validation (5 Tests)
    • validatePayeeOverride: None, valid, empty, whitespace, too long

Test-Struktur

let tests =
    testList "Validation Tests" [
        reusableValidatorTests    // 14 Tests
        settingsValidationTests   // 16 Tests
        rulesValidationTests      // 17 Tests
        transactionValidationTests // 5 Tests
    ]

Rationale für die Gruppierung: Die Tests sind nach dem Modul organisiert, das sie testen. Innerhalb jeder Gruppe sind die Tests nach Funktion unterteilt. Das macht es einfach, fehlgeschlagene Tests dem betroffenen Code zuzuordnen.

Herausforderung 4: Was ist “gut genug”?

Die Assessment-Ergebnisse

Nach der systematischen Analyse konnte ich die Bereiche bewerten:

1. Error Handling - EXCELLENT:

2. Loading States - EXCELLENT:

3. Form Validation - GOOD:

4. Unit Tests - COMPREHENSIVE:

Was bleibt für die Zukunft?

Die Form Validation ist “GOOD” statt “EXCELLENT” weil:

Diese Verbesserungen sind nice-to-have, aber nicht kritisch für den Launch.

Lessons Learned

1. Konsistente Patterns zahlen sich aus

Der Großteil der Codebase nutzte bereits Cmd.OfAsync.either. Nur drei Stellen nutzten perform. Das zeigt: Wenn ein Pattern einmal etabliert ist, werden Abweichungen zu Bugs.

Takeaway: Bei Code Reviews auf Pattern-Konsistenz achten.

2. Frühe Type-Safety spart späte Tests

Das Validation-Modul nutzt F#-Types konsequent:

Das macht viele Tests fast trivial - wenn der Code kompiliert, ist er wahrscheinlich korrekt.

Takeaway: Investiere in Domain Types, nicht in defensive Coding.

3. RemoteData ist Gold wert

Das RemoteData<'T>-Pattern hat sich durch die gesamte Codebase als wertvoll erwiesen:

type RemoteData<'T> = NotAsked | Loading | Success of 'T | Failure of string

Jeder UI-State ist explizit. Kein if loading then ... else if error then ... else ... mehr.

Takeaway: Investiere früh in State-Patterns.

Fazit

Milestone 15 war der “langweilige” Milestone - kein neues Feature, keine spektakuläre UI. Aber genau diese Art von Arbeit unterscheidet eine Demo von einer echten Anwendung.

Was wurde erreicht:

Finale Statistiken:

Key Takeaways für Neulinge

  1. Cmd.OfAsync.either immer verwenden - Exceptions in async Code können subtile Bugs verursachen die schwer zu debuggen sind. either macht Fehlerbehandlung explizit.

  2. Boundary Value Testing - Bei Validatoren die Grenzen testen: genau auf der Grenze, knapp darunter, knapp darüber. Das findet die meisten Off-by-One-Errors.

  3. UI-Feedback vor API-Calls - Disabled Buttons für invalide Zustände sind besser als Fehlermeldungen nach dem Klick. Der User sollte gar nicht erst versuchen können, was nicht funktionieren wird.