Integration Testing ohne UI: Wie ich YNAB und Comdirect APIs testbar machte

Einleitung

Als ich heute mit der Implementierung der BudgetBuddy-Integrations-Tests begann, stand ich vor einem klassischen Problem: Wie teste ich externe API-Integrationen, wenn das UI noch nicht fertig ist?

Die Herausforderung war konkret: Ich hatte zwei komplexe API-Integrationen implementiert:

Beide waren im Backend fertig implementiert, aber die Frontend-Views existierten noch nicht. Ein klassisches Henne-Ei-Problem: Ohne UI kann ich nicht manuell testen, aber ohne Tests weiß ich nicht, ob die APIs überhaupt funktionieren.

Die zweite Herausforderung war CI/CD-Freundlichkeit: Integration-Tests sollten nicht bei jedem dotnet test laufen und Push-TANs an mein Handy senden. Aber sie sollten trotzdem leicht ausführbar sein, wenn ich sie brauche.

In diesem Blogpost beschreibe ich, wie ich eine flexible Test-Infrastruktur gebaut habe, die zwei Szenarien unterstützt:

  1. Schnelle Unit-Tests (default, keine API-Calls)
  2. Opt-in Integration-Tests (mit echten API-Calls)

Für manuelle End-to-End-Tests (z.B. Comdirect OAuth mit Push-TAN) gibt es F# Scripts (scripts/test-comdirect.fsx), die interaktiv mit dotnet fsi ausgeführt werden können.

Ausgangslage

BudgetBuddy hatte bereits eine solide Test-Basis mit 88 Tests:

Was fehlte:

Die bestehenden Tests waren pure Unit-Tests: Sie testeten Decoder mit Mock-JSON, aber nie die tatsächliche API-Kommunikation. Das war gut für schnelle Feedback-Loops, aber ich wusste nicht, ob der Code wirklich funktioniert.

Herausforderung 1: Credentials ohne Umgebungsvariablen

Das Problem

Die Test-Scripts und Integration-Tests brauchten API-Credentials. Die naive Lösung wäre:

YNAB_TOKEN=xxx COMDIRECT_USERNAME=yyy dotnet test

Warum das nervig ist:

Optionen, die ich betrachtet habe

  1. Hardcoded in Test-Dateien
    • ✅ Pro: Einfach
    • ❌ Contra: NIEMALS Credentials committen!
    • ❌ Contra: Jeder Entwickler hat andere Credentials
  2. Umgebungsvariablen vor jedem Test setzen
    • ✅ Pro: Standard-Approach
    • ❌ Contra: Umständlich, fehleranfällig
    • ❌ Contra: Shell-History-Problem
  3. .env File (gewählt)
    • ✅ Pro: .gitignored, sicher
    • ✅ Pro: Einmal setzen, immer nutzen
    • ✅ Pro: Alle Tools (Scripts + Tests) nutzen die gleiche Quelle
    • ❌ Contra: Braucht einen Parser

Die Lösung: EnvLoader.fsx

Ich habe ein wiederverwendbares F# Script-Modul erstellt:

// scripts/EnvLoader.fsx
module EnvLoader

let loadEnv (envPath: string) =
    File.ReadAllLines(envPath)
    |> Array.filter (fun line ->
        not (String.IsNullOrWhiteSpace(line)) &&
        not (line.TrimStart().StartsWith("#"))
    )
    |> Array.choose (fun line ->
        match line.Split('=', 2) with
        | [| key; value |] -> Some (key.Trim(), value.Trim())
        | _ -> None
    )
    |> Map.ofArray

let getRequired (envVars: Map<string, string>) (key: string) =
    match envVars.TryFind key with
    | Some value -> value
    | None -> failwith $"Missing required: {key}"

Nutzung in Tests:

// src/Tests/YnabIntegrationTests.fs
let private loadEnv () =
    let envPath = Path.Combine(projectRoot, ".env")
    File.ReadAllLines(envPath)
    |> // ... gleiche Logik

Nutzung in Scripts:

// scripts/test-ynab.fsx
#load "EnvLoader.fsx"

let env = EnvLoader.loadProjectEnv()
let token = EnvLoader.getRequired env "YNAB_TOKEN"

Architekturentscheidung: Warum kein NuGet-Package?

Ich hätte dotenv-net oder ähnliche Packages nutzen können. Warum nicht?

  1. Zero Dependencies für Scripts: F# Scripts sollen ohne komplexe Setup laufen
  2. Einfachheit: 30 Zeilen Code vs. NuGet-Abhängigkeit
  3. Lerneffekt: Parsing ist trivial, zeigt F#-Patterns
  4. Kontrolle: Ich kann Secret-Masking hinzufügen (zeige nur YNAB_...ff3f)

Trade-off: Kein vollständiger .env-Parser (z.B. keine Quotes, keine Variable-Substitution). Für BudgetBuddy ausreichend - wir haben simple Key=Value-Paare.

Herausforderung 2: ComdirectSettings hatte falsches Design

Das Problem

Als ich den OAuth-Test schrieb, bekam ich diesen Fehler:

❌ FAILED: Could not start OAuth flow
Error: AuthenticationFailed "Bad client credentials"

Beim Debuggen fand ich den Bug in ComdirectClient.fs:

// FALSCH! ❌
let body =
    sprintf "client_id=%s&client_secret=%s&username=%s&password=%s&grant_type=password"
        apiKeys.ClientId
        apiKeys.ClientSecret
        credentials.AccountId  // ← AccountID als Username! 🤦
        "password_placeholder" // ← Hardcoded! 🤦

Warum dieser Bug unentdeckt blieb

Der Code kompilierte type-safe, aber war semantisch falsch:

F# kann nicht wissen, dass ich hier das falsche Feld nutze - beide sind string.

Die Lösung: ComdirectSettings erweitert

Ich habe den Domain-Type gefixt:

// src/Shared/Domain.fs
type ComdirectSettings = {
    ClientId: string
    ClientSecret: string
    Username: string          // NEU ✅
    Password: string          // NEU ✅
    AccountId: string option  // JETZT OPTIONAL ✅
}

Warum AccountId: string option?

Die AccountId wird nur für Transaction-Fetching gebraucht, nicht für OAuth:

Das macht den Type ehrlich: Er zeigt, dass AccountId optional ist.

Lessons Learned: Type Safety ≠ Semantic Correctness

F# ist type-safe, aber ich muss trotzdem semantisch korrekte Types wählen:

Schlecht (beide string):

type Settings = {
    AccountId: string
    Username: string
}

Besser (Single-Case Unions):

type AccountId = AccountId of string
type Username = Username of string

type Settings = {
    AccountId: AccountId
    Username: Username
}

Dann kann ich nicht versehentlich AccountId und Username verwechseln!

Trade-off: Für BudgetBuddy habe ich die einfache Version gewählt (plain strings), weil:

In einer größeren Codebase würde ich Single-Case Unions nutzen.

Herausforderung 3: Transaction Decoder - Kann keine Functions serialisieren

Das Problem

Nach dem OAuth-Fix bekam ich einen kryptischen Runtime-Error:

Error: NetworkError (0, "Cannot generate auto encoder for
Microsoft.FSharp.Core.FSharpFunc`2[[...

Die volle Fehlermeldung war ~800 Zeichen lang - klassisches “F# Type System explodiert”-Problem.

Root Cause Analysis

Der Bug war in ComdirectClient.fs, Zeile 103:

let private transactionDecoder: Decoder<BankTransaction> =
    Decode.object (fun get ->
        // ... Felder dekodieren ...

        let rawData = Encode.Auto.toString(0, get.Required.Raw)  // ❌ BUG!

        { /* ... */ RawData = rawData }
    )

Was ist get.Required.Raw?

Raw ist ein internes Thoth.Json-Objekt mit Decoder-Functions:

Warum crasht Encode.Auto.toString?

Encode.Auto nutzt Reflection, um jeden F#-Type zu serialisieren. Aber Functions sind nicht serialisierbar!

Encode.Auto.toString(0, fun x -> x + 1)  // ❌ Crash!

Die Lösung: RawData als leerer String

{
    Id = transactionId
    BookingDate = bookingDate
    Amount = { Amount = amountValue; Currency = currency }
    Payee = payee
    Memo = memo
    Reference = reference
    RawData = ""  // ✅ TODO: Store raw JSON if needed
}

Warum nicht das rohe JSON speichern?

Ich könnte das JSON speichern:

// Option 1: JSON als String
let! content = response.Content.ReadAsStringAsync()
let rawData = content

Aber wo speichere ich es? Ich habe nur Zugriff auf das dekodierte Objekt, nicht auf den Response-String.

Bessere Lösung (für später):

let private handleResponse (decoder: Decoder<'T>) (response: HttpResponseMessage) =
    async {
        let! content = response.Content.ReadAsStringAsync()

        match Decode.fromString decoder content with
        | Ok value ->
            // Hier könnte ich content in 'value' speichern
            return Ok value
        | Error err -> return Error err
    }

Trade-off: Für jetzt speichere ich kein RawData. Wenn ich später Debugging brauche, kann ich es hinzufügen.

Lessons Learned: Thoth.Json Decoder sind nicht serialisierbar

Regel: Nutze nie Encode.Auto auf Decoder-internen Objekten!

// ❌ FALSCH
get.Required.Raw  // Enthält Functions

// ✅ RICHTIG
get.Required.Field "foo" Decode.string  // Dekodierter Wert

Herausforderung 4: Integration Tests stören CI/CD

Das Problem

Ich hatte Integration-Tests geschrieben:

testCase "can fetch budgets with real token" <| fun () ->
    match getEnvVar "YNAB_TOKEN" with
    | None -> Tests.skiptest "No token"
    | Some token ->
        async {
            let! result = getBudgets token
            // ... assertions ...
        } |> Async.RunSynchronously

Beim Ausführen:

$ dotnet test
# ⚠️ Sendet Push-TAN an mein Handy!
# ⚠️ Macht echte YNAB API-Calls!
# ⚠️ Konsumiert API Rate-Limits!

Warum das schlecht ist:

  1. CI/CD: GitHub Actions hat keine .env → Tests schlagen fehl
  2. Entwickler-Nerv: Bei jedem dotnet test Push-TAN bekommen
  3. Rate-Limits: YNAB API hat Limits (200 requests/hour)
  4. Performance: Integration-Tests sind langsam (~3 Sekunden)

Optionen, die ich betrachtet habe

  1. Separate Test-Projekte
    • ✅ Pro: Klare Trennung
    • ❌ Contra: Mehr Boilerplate, mehr Dateien
    • ❌ Contra: dotnet test läuft trotzdem alle
  2. Test-Categories/Tags
    • ✅ Pro: dotnet test --filter Category!=Integration
    • ❌ Contra: Expecto nutzt keine Test-Categories (ist kein xUnit)
    • ❌ Contra: Komplizierte Filter-Syntax
  3. Environment-Variable-Flag (gewählt)
    • ✅ Pro: Einfach zu verstehen
    • ✅ Pro: Funktioniert in allen Tools
    • ✅ Pro: Selbst-dokumentierend
    • ❌ Contra: Noch eine Umgebungsvariable

Die Lösung: RUN_INTEGRATION_TESTS Flag

// src/Tests/YnabIntegrationTests.fs
let private shouldRunIntegrationTests () =
    match getEnvVar "RUN_INTEGRATION_TESTS" with
    | Some value when value.ToLower() = "true" -> true
    | _ -> false

testCase "can fetch budgets with real token" <| fun () ->
    if not (shouldRunIntegrationTests()) then
        Tests.skiptest "RUN_INTEGRATION_TESTS not set"

    // ... Test-Code ...

Nutzung:

# Standard (keine Integration-Tests)
$ dotnet test
# ✅ 82 Tests passed, 6 skipped

# Mit Integration-Tests
$ RUN_INTEGRATION_TESTS=true dotnet test
# ✅ 88 Tests passed, 0 skipped (mit echten API-Calls!)

# Oder in .env setzen:
$ echo "RUN_INTEGRATION_TESTS=true" >> .env
$ dotnet test

Architekturentscheidung: Warum Skip statt Separate Projects?

Alternative: Ich hätte ein Tests.Integration.fsproj Projekt erstellen können:

dotnet test src/Tests              # Nur Unit-Tests
dotnet test src/Tests.Integration  # Integration-Tests

Warum ich das NICHT gemacht habe:

  1. Developer Experience: dotnet test soll “einfach funktionieren”
  2. Sichtbarkeit: Skipped Tests zeigen, dass Integration-Tests existieren
  3. Flexibilität: Ich kann einzelne Tests aktivieren
  4. Weniger Boilerplate: Keine duplizierte .fsproj, PackageReferences, etc.

Trade-off: Skip-Tests erscheinen im Output (6 skipped). Das ist gewollt - es erinnert mich, dass Integration-Tests existieren!

Herausforderung 5: Vollständiger OAuth Flow ist nicht automatisierbar

Das Problem

Push-TAN ist manuell - ich muss auf meinem Handy bestätigen. Aber automatisierte Tests können nicht auf menschliche Interaktion warten. Console.ReadLine() funktioniert in dotnet test nicht - die Tests laufen als Batch-Prozess ohne echte Konsolen-Interaktion.

Die Lösung: F# Scripts für manuelle Tests

Statt interaktiver Tests in der Test-Suite nutze ich F# Scripts:

$ dotnet fsi scripts/test-comdirect.fsx

Das Script hat eine echte Konsole und kann auf Benutzereingaben warten. Der automatisierte Integration-Test testet nur bis zur TAN-Challenge - alles darüber hinaus muss manuell mit dem Script getestet werden.

Fazit: Interaktive Tests gehören nicht in eine automatisierte Test-Suite. Sie gehören in Scripts, die explizit manuell ausgeführt werden.

Herausforderung 6: String-Interpolation in F# kann überraschend sein

Das Problem

Beim Schreiben der Tests bekam ich diesen Compiler-Error:

error FS3373: Invalid interpolated string. Single quote or verbatim
string literals may not be used in interpolated expressions

Der Code:

printfn $"Sample: {firstTx.BookingDate:yyyy-MM-dd}"
//                                     ^ Problem!

Warum crasht das?

F# String-Interpolation parsed den String und sieht:

F# denkt: “Du willst yyyy minus MM minus dd rechnen?”

Die Lösung: DateTime.ToString() in separate Variable

// ❌ FALSCH
printfn $"Sample: {firstTx.BookingDate:yyyy-MM-dd}"

// ✅ RICHTIG
let dateStr = firstTx.BookingDate.ToString("yyyy-MM-dd")
printfn $"Sample: {dateStr}"

Lessons Learned: F# String-Interpolation-Tricks

Regel: In $"...{expr}..." muss expr ein gültiger F#-Ausdruck sein.

// ✅ OK: Simple Expressions
$"Hello {name}"
$"Count: {items.Length}"
$"Sum: {x + y}"

// ❌ FEHLER: Format-Strings mit Sonderzeichen
$"Date: {date:yyyy-MM-dd}"  // `-` als Operator!
$"Price: {price:C}"         // `C` als Format-Specifier geht manchmal

// ✅ LÖSUNG: ToString() nutzen
let formatted = date.ToString("yyyy-MM-dd")
$"Date: {formatted}"

Alternative: Triple-Quoted Strings (für komplexe Fälle):

$"""Date: {date.ToString("yyyy-MM-dd")}"""

Herausforderung 7: Testing ohne UI - F# Scripts als Lösung

Das Problem

Ich brauchte einen Weg, die APIs interaktiv zu explorieren:

Unit-Tests sind dafür zu rigide:

Die Lösung: F# Scripts (.fsx)

F# Scripts sind wie REPL, aber mit Files:

// scripts/test-ynab.fsx
#r "nuget: FsHttp, 14.5.1"
#load "../src/Shared/Domain.fs"
#load "../src/Server/YnabClient.fs"

open Server.YnabClient

let token = EnvLoader.getRequired env "YNAB_TOKEN"

printfn "Fetching budgets..."
let budgetsResult = getBudgets token |> Async.RunSynchronously

match budgetsResult with
| Ok budgets ->
    printfn $"Found {budgets.Length} budgets:"
    for budget in budgets do
        printfn $"  - {budget.Name}"

Ausführen:

$ dotnet fsi scripts/test-ynab.fsx

Fetching budgets...
Found 3 budgets:
  - Haus
  - My Budget (Archived)
  - My Budget

Warum F# Scripts besser sind als Unit-Tests

Feature Unit-Tests F# Scripts
Explorieren ❌ Feste Assertions ✅ Frei experimentieren
Output ❌ Nur Pass/Fail ✅ Sehe echte Daten
Iteration ❌ Test schreiben, Build, Run ✅ Script ändern, Run
Setup ❌ Test-Framework nötig ✅ Nur dotnet fsi
Sharing ❌ In Tests “versteckt” ✅ Script = Doku

Architekturentscheidung: Scripts im Repo committen

Ich habe die Scripts im scripts/ Ordner committed:

scripts/
├── EnvLoader.fsx            # Shared helper
├── test-ynab.fsx            # YNAB API tester
├── test-comdirect.fsx       # Comdirect OAuth tester
├── debug-comdirect-auth.fsx # Credential debugger
└── README.md                # Documentation

Warum committen?

  1. Dokumentation: Scripts zeigen, wie die APIs benutzt werden
  2. Onboarding: Neue Entwickler können Scripts ausführen
  3. Regression-Testing: Manuell, aber schneller als UI
  4. Troubleshooting: Wenn etwas bricht, kann ich mit Scripts debuggen

Trade-off: Scripts können veralten. Lösung: Behandle sie wie Tests - wenn API ändert, Script updaten.

Lessons Learned

1. Type Safety ist nicht genug - Semantik zählt

F# verhindert Type-Errors, aber nicht Logik-Errors:

Lösung: Single-Case Unions für wichtige Domain-Concepts:

type AccountId = AccountId of string
type Username = Username of string

Dann kann ich sie nicht verwechseln!

2. Integration-Tests brauchen gute Developer Experience

Schlecht:

$ dotnet test
# ⚠️ Push-TAN an Handy!
# ⚠️ Macht echte API-Calls!
# Warum? 🤔

Gut:

$ dotnet test
# ✅ 82 tests passed, 6 skipped
# Hinweis: "Set RUN_INTEGRATION_TESTS=true for integration tests"

Best Practices:

3. F# Scripts sind unterschätzt

Scripts sind nicht nur für Prototyping! Sie sind auch:

Investiere in gute Scripts:

4. Interaktive Tests gehören in Scripts, nicht in die Test-Suite

Console.ReadLine() funktioniert nicht in dotnet test - Tests laufen als Batch-Prozess. Für manuelle Tests mit Benutzerinteraktion (z.B. Push-TAN-Bestätigung) nutze F# Scripts:

$ dotnet fsi scripts/test-comdirect.fsx

Scripts haben eine echte Konsole und können auf Eingaben warten.

5. .env Files sind King für lokale Entwicklung

Umgebungsvariablen sind gut für Produktion, aber nervig für Entwicklung:

.env Files lösen das:

Aber: Schreibe einen simplen Parser selbst (30 Zeilen) statt NuGet-Package!

Fazit

Heute habe ich eine vollständige Integration-Test-Infrastruktur für BudgetBuddy gebaut:

Was wurde erreicht:

Neue Dateien (7):

Geänderte Dateien (6):

Test-Statistiken:

Bug-Fixes:

  1. AccountID wurde fälschlicherweise als Username verwendet → Domain-Type gefixt
  2. Transaction Decoder versuchte F# Functions zu serialisieren → RawData entfernt
  3. Integration-Tests liefen by default → Opt-in Flag hinzugefügt
  4. String-Interpolation Bugs (yyyy-MM-dd) → DateTime.ToString() in Variable

Architektur-Verbesserungen:

Test-Coverage jetzt:

Kategorie Anzahl Beschreibung
Unit-Tests 82 Schnell, keine I/O, immer
Integration-Tests 7 Echte API-Calls, opt-in
Decoder-Tests 9 JSON-Struktur-Validierung
Property-Based 3 FsCheck Edge-Cases
Total 97 Vollständige Test-Suite

Nächste Schritte:

Was fehlt noch?

  1. Transaction Decoder Tests mit echten Comdirect-JSONs (momentan nur Struktur-Tests)
  2. RawData speichern (für Debugging, wenn API sich ändert)
  3. Retry-Logic für API-Calls (Comdirect kann 429 Too Many Requests returnen)
  4. Token Refresh (Access-Token läuft ab nach ~10 Minuten)

Key Takeaways für Neulinge

1. Integration-Tests müssen opt-in sein

Niemals Integration-Tests by default laufen lassen:

Lösung: Environment-Variable-Flag (RUN_INTEGRATION_TESTS=true)

2. F# Scripts sind mächtiger als du denkst

Scripts sind nicht nur für Prototyping! Nutze sie für:

Best Practice: Committe gute Scripts ins Repo!

3. Type Safety schützt nicht vor Logik-Errors

F# verhindert Type-Errors, aber nicht Semantik-Errors:

// Kompiliert, aber FALSCH:
credentials.AccountId  // Als Username verwendet! 🤦

Lösung:

Trade-off: Single-Case Unions = mehr Boilerplate. Nutze sie nur für wichtige Domain-Concepts!