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:
- 163 Unit-Tests (plus 6 Integration-Tests)
- Vollständiges Design System mit Neon-Glow-Theme
- Split Transactions und Duplicate Detection
- MVU-Architektur mit klarer Komponentenstruktur
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:
- Dashboard/State.fs:
LoadCurrentSessionundLoadRecentSessions - 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:
- Konsistenz: Der Rest der Codebase nutzt dieses Pattern
- Einfachheit: Für UI-Fehlermeldungen reicht ein String
- 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:
- “Save” für YNAB Token klicken ohne Token einzugeben
- “Test Connection” klicken ohne konfigurierten Token
- “Import to YNAB” klicken ohne selektierte/kategorisierte Transaktionen
Optionen, die ich betrachtet habe
- Serverseitige Validation nur (abgelehnt)
- Pro: Weniger Frontend-Code
- Contra: Schlechte UX - warum einen Button klicken können, der garantiert fehlschlägt?
- Inline Error Messages (teilweise umgesetzt)
- Pro: Klares Feedback was fehlt
- Contra: Kann bei einfachen Fällen überladen wirken
- Disabled Buttons (gewählt)
- Pro: Visuell sofort klar, dass Aktion nicht möglich
- Contra: User weiß nicht immer warum
- 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:
validateYnabToken- Prüft Token-LängevalidateComdirectSettings- Prüft alle PflichtfeldervalidateSyncSettings- Prüft DaysToFetch-RangevalidateRuleCreateRequest- Prüft alle Rule-FeldervalidateRuleUpdateRequest- Prüft optionale Update-FeldervalidatePayeeOverride- Prüft Payee-Länge
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:
- Reusable Validators (14 Tests)
validateRequired: null, empty, whitespace, validvalidateLength: min, max, under, over, withinvalidateRange: min, max, under, over, within
- Settings Validation (16 Tests)
validateYnabToken: empty, too short, valid, minimum lengthvalidateComdirectSettings: jedes Pflichtfeld leer, optional AccountId, multiple errorsvalidateSyncSettings: min, max, zero, negative, too many
- Rules Validation (17 Tests)
validateRuleName: empty, too long, validvalidatePattern: empty, too long, validvalidateRuleCreateRequest: valid, empty name, empty pattern, invalid priority, multiple errorsvalidateRuleUpdateRequest: valid, all None, Some empty name, Some empty pattern, invalid priority
- 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:
- Alle API-Calls fangen jetzt Exceptions
- Backend hat typsichere Error-Konverter für jeden Bereich
- Frontend zeigt Fehler mit visuellen Indikatoren (Badges, Farben, Retry-Buttons)
2. Loading States - EXCELLENT:
- Alle async Operationen nutzen RemoteData (NotAsked, Loading, Success, Failure)
- Umfassende Loading-Indikatoren: Spinner, Skeleton-Loader, Progress-Messages
- Kontext-spezifische Loading-States für jeden Workflow-Schritt
- Buttons während async deaktiviert
3. Form Validation - GOOD:
- Input-Komponente hat vollständige Validation-Unterstützung
- Required-Field-Indikatoren mit roten Sternchen
- Buttons disabled wenn Pflichtfelder leer
- Serverseitige Validation mit Error-Accumulation
- Inline-Fehlermeldungen unterstützt (nicht überall genutzt)
4. Unit Tests - COMPREHENSIVE:
- 221 Tests total (163 + 52 neue + 6 skipped Integration)
- Coverage: Encryption, Persistence, YNAB Client, Comdirect Client, Rules Engine, Duplicate Detection, Split Transactions, und jetzt Validation
Was bleibt für die Zukunft?
Die Form Validation ist “GOOD” statt “EXCELLENT” weil:
- Inline-Fehlermeldungen bei Inputs nicht überall aktiviert
- Comdirect-Formular zeigt keine spezifischen Feldvalidierungen
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:
Result<'T, string list>für Error-AccumulationOptionfür optionale Validations- Starke Typen wie
RuleId,YnabCategoryId
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:
- 3 Komponenten mit verbessertem Error Handling
- 4 Buttons mit Form Validation
- 52 neue Tests für das Validation-Modul
- Systematische Dokumentation des Code-Zustands
Finale Statistiken:
- Build: 0 Warnings, 0 Errors
- Tests: 221/221 passed (215 Unit + 6 skipped Integration)
- Geänderte Dateien: 9
- Neue LOC: ~500 (hauptsächlich Tests)
Key Takeaways für Neulinge
-
Cmd.OfAsync.eitherimmer verwenden - Exceptions in async Code können subtile Bugs verursachen die schwer zu debuggen sind.eithermacht Fehlerbehandlung explizit. -
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.
-
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.