Zuletzt aktualisiert:

Black-Box-API-Test-Suite als Rewrite-Versicherung — Stack-Wahl

Tiefenrecherche + Architektur-Konzept · 07.06.2026 · für Dennis (Silverscale Family Hub)

Frage: Welcher Tech-Stack testet die komplette Silverscale-Backend-API (~120 Endpoints, REST/JSON, stateful) zu 100 % rein über HTTP — so, dass die Suite einen kompletten Backend-Rewrite (heute FastAPI/Python/SQLite, morgen egal was) übersteht und beweist, dass die neue Implementierung nach außen exakt gleich antwortet? Anforderungen: strikt Tech-Stack-unabhängig, „in Sekunden" lokal durchlaufend, modern/sicher/Best-Practice, reibungsarm, gepflegt überwiegend von Claude-Code-Sessions. 3rd-Party-Endpoints (OFF, HF, eGYM, Garmin, Web-Push, OpenRouter/Gemini) = separater, langsamerer Lauf.


1. Executive Summary — Empfehlung

Empfehlung: pytest + httpx als alleiniger Träger der Suite, mit uv als Runner, pytest-xdist für Parallelität, syrupy für Contract-Snapshots und Markern fast/integration zur 3rd-Party-Trennung. Schemathesis kommt als optionale zweite Stufe dazu — als Fuzzer/Property-Checker gegen einen gespeicherten OpenAPI-Snapshot, nicht als Kern der Vertragstests.

Der scheinbare Widerspruch („stack-unabhängig" vs. „pytest ist Python, genau wie das aktuelle Backend") löst sich auf, weil pytest hier nur der Test-Runner ist, nicht der Test-Inhalt. Die Suite spricht das Backend ausschließlich über httpx gegen http://127.0.0.1 an — kein Import von backend.*, kein SQLAlchemy-Session-Sharing, kein In-Process-TestClient. Damit ist sie genauso gültig gegen ein Go-, Rust- oder Java-Backend; nur die Start- und Seed-Kommandos im conftest tauschen aus. Der entscheidende Stack-Unabhängigkeits-Hebel ist nicht die Sprache des Runners, sondern die Disziplin „nur HTTP rein, nur HTTP/Snapshot raus" plus ein expliziter Seed-/Reset-Vertrag statt DB-interner Rollback-Tricks.

Warum nicht das hübsche, deklarative Hurl oder eine .yaml/.bru-DSL als Kern? Weil Silverscale echte stateful Szenario-Ketten mit Berechnung hat (loggen → Vorrat sinkt → löschen → Vorrat zurück → Undo → wieder abgezogen). Solche Ketten mit Zwischen-Asserts, abgeleiteten Werten, Schleifen und Fixtures sind in einer vollwertigen Programmiersprache wartbar, in einer Request-DSL dagegen schnell ein Kartenhaus. Hurl/Bruno glänzen bei „Request → Assert"-Smoke-Tests, nicht bei Zustandsmaschinen. Und: Claude-Code pflegt Python/pytest deutlich zuverlässiger als eine Nischen-DSL — das ist hier ein erstrangiges Kriterium.


2. In einfachen Worten

Stell dir die Test-Suite wie einen Kunden vor, der nur durch die Ladentür kommt — nie durch den Hintereingang ins Lager. Er kennt nur die Theke (die HTTP-API): Er stellt Anfragen, schaut sich die Antworten an und prüft, ob alles stimmt. Ob im Lager FastAPI, Go oder Java werkelt, ist ihm egal — solange an der Theke dasselbe rauskommt. Genau das willst du: eine Versicherung, die sagt „der neue Laden bedient mich exakt wie der alte".

pytest ist dabei nur der Aufpasser, der die Checkliste abarbeitet — er schreibt nicht vor, womit der Laden gebaut ist. Wir benutzen ihn, weil er schnell, robust und für Claude bestens beherrschbar ist, aber er fasst das Backend nie von innen an. Jeder Test ist eine kleine Geschichte: „Ich logge ein Essen — sinkt der Vorrat? Ich lösche es — kommt der Vorrat zurück?". Diese Geschichten laufen in Sekunden, weil sie lokal gegen eine Wegwerf-Kopie der Datenbank fahren, die nach jedem Test frisch ist.

Für die Antworten benutzen wir Schnappschüsse („golden files"): Beim ersten Lauf merkt sich die Suite die komplette JSON-Antwort. Baust du das Backend neu und eine Antwort weicht ab, zeigt sie dir den Unterschied wie ein Foto-Vergleich — das ist der eigentliche „nach dem Rewrite exakt gleich"-Beweis. Wackelige Felder (Zeitstempel, IDs) werden vorher maskiert, damit nicht jeder Lauf falschen Alarm schlägt.

Die langsamen Tests, die echte Fremddienste anrufen (HelloFresh, Garmin …), kriegen einen eigenen Stempel (integration) und laufen nur, wenn du es willst — der schnelle Alltagslauf rührt sie nie an.


3. Vergleichstabelle

Bewertung 1–5 (5 = am besten) für den konkreten Silverscale-Zweck (stateful Vertragstest als Rewrite-Versicherung, gepflegt von Claude).

Kriterium pytest+httpx Schemathesis Vitest+fetch (TS) Hurl (.hurl) StepCI/Bruno/Tavern/Karate
Geschwindigkeit (lokal, parallel) 5 (xdist) 3 (Fuzzing dauert) 5 5 (Rust/libcurl) 3–4
Stack-Unabhängigkeit (nur HTTP, kein Backend-Import) 5 (bei Disziplin) 5 (rein HTTP) 5 5 5
Snapshot-/Golden-File-Support 5 (syrupy/inline-snapshot) 1 (keine) 4 (vitest snapshots) 2 (kein nativer Diff-Snapshot) 1–2
Szenario-Ketten / stateful 5 (volle Sprache, Fixtures) 3 (state machine, nur via OpenAPI-Links) 5 3 (Captures, aber keine Logik) 2–4 (Karate 4, Bruno 2)
Wartbarkeit durch LLM-Sessions 5 (Claude top in pytest) 4 (wenig Code) 4 3 (Nischen-DSL) 2–3
Reibung/Setup (Umgebung schon da?) 5 (Py3.12+venv vorhanden) 4 (uv add) 3 (Node, eigenes Projekt) 4 (1 Binary, kein npm) 2–3
Raw-Body/Bild-Uploads, HTML-Antworten 5 (httpx kann alles) 2 (Schema-getrieben) 4 4 3
Determinismus-Kontrolle (Felder maskieren) 5 (frei) 2 4 2 2

Lesart: pytest+httpx ist in jeder für dich kritischen Spalte vorne oder gleichauf, ohne irgendwo durchzufallen. Schemathesis ist exzellent in dem, was es tut (Schema-Fuzzing), deckt aber Snapshots und freie Szenario-Logik nicht ab — deshalb Ergänzung, nicht Kern. Vitest wäre technisch fast gleichwertig, scheitert aber an Reibung (eigenes Node-Projekt, kein Vorteil gegenüber dem schon installierten Python-Stack) und an LLM-Wartbarkeit relativ zu pytest in diesem Repo. Hurl ist großartig als Smoke-/Health-Layer, aber zu dünn für die Rechen-/Zustands-Ketten. Bruno/StepCI/Tavern/Karate bringen keinen Vorteil, der die zusätzliche Werkzeugkette rechtfertigt (Karate wäre die stärkste DSL, aber JVM + Gherkin = maximale Reibung für ein Solo-/Claude-Projekt).

3.1 Detail-Notizen aus der Recherche


4. Empfohlene Suite-Architektur

4.1 Verzeichnis-Layout

app/apitest/                       # eigenes, vom Backend getrenntes Test-Paket
  pyproject.toml                   # uv-managed; dep: pytest, httpx, pytest-xdist,
                                   #   syrupy, schemathesis (optional-group)
  conftest.py                      # Instanz-Start, Seed/Reset, http-Client, Marker
  contract/                        # OpenAPI-Snapshot als Vertrag
    openapi-snapshot.json          # eingefrorenes /api/openapi.json (Rewrite-Anker)
  seed/
    seed.sql                       # deterministischer Seed (IDs/Datum fix)
    seed.db                        # ODER: vorgebaute Fixture-DB (heute schnellster Weg)
  tests/
    fast/                          # @pytest.mark.fast — kein 3rd Party, läuft in s
      test_diary_cycle.py          #   Szenario-Ketten (loggen→Vorrat→undo…)
      test_pantry.py
      test_plan_shoppinglist.py
      test_catalog_crud.py
      test_contract_snapshots.py   #   syrupy-Golden-Files je Endpoint
      test_auth_contract.py        #   X-Internal-Token akzeptiert / 401 ohne
      test_uploads_raw.py          #   Raw-Body-Bild-Upload, HTML-Routen, SPA-catchall
    integration/                   # @pytest.mark.integration — echtes 3rd Party
      test_off.py  test_hellofresh.py  test_garmin.py
      test_egym.py test_push.py    test_gemini_mapsuggest.py
  __snapshots__/                   # syrupy-Vertragsschnappschüsse (versioniert!)
  schemathesis_run.py              # optionaler Fuzz-Lauf gegen contract/openapi-snapshot.json

Wichtig: liegt unter app/apitest/, importiert aber NIE backend.*. Der einzige Kontaktpunkt ist base_url + httpx. Das ist die mechanische Garantie für Stack-Unabhängigkeit — am besten per CI-/Lint-Regel „kein import backend in apitest" abgesichert.

4.2 Testinstanz-Start (lokal, isoliert, NIE gegen Prod)

Eine dedizierte Test-Instanz auf eigenem Port + eigener DB-Datei, gestartet vom conftest (session-scope fixture):

  1. Frische DB aus seed/ an einen Temp-Pfad kopieren (/tmp/ss-apitest-<pid>.db).
  2. Backend mit SS_DB_PATH=<temp> + AUTH_DISABLED=0 + bekanntem INTERNAL_API_TOKEN auf z. B. Port 9399 starten (eigener compose-Service silverscale-apitest ODER direkt uvicorn im venv).
  3. Auf GET /api/health (oder Root) pollen, bis 200 — dann Tests freigeben.
  4. Teardown: Prozess killen, Temp-DB löschen.

Der Start-Befehl ist die einzige stack-spezifische Zeile. Nach einem Rewrite ersetzt man genau diesen einen Hook (z. B. go run ./cmd/server statt uvicorn) — sonst nichts. Deshalb kapselt der conftest ihn hinter einer einzigen Funktion start_backend(port, db_path, token).

Performance-Hinweis: SQLite mit PRAGMA journal_mode=WAL + Tempfile-Kopie startet in Millisekunden; der Health-Poll dominiert. Mit pytest-xdist je Worker eigene DB-Kopie + eigener Port (Port aus Worker-ID ableiten) → echte Parallelität ohne SQLite-Lock-Krieg (die CLAUDE.md-Falle „SQLite-Locks ließen Grocy timeouten" gilt sinngemäß auch hier).

4.3 Seed / Reset — der kritische Vertrag

Das ist der heikelste Punkt für Stack-Unabhängigkeit. Der übliche schnelle Trick „Transaktion pro Test öffnen und am Ende rollbacken" verbietet sich hier: er braucht eine geteilte DB-Session/SQLAlchemy — also Backend-Interna, die der Rewrite nicht hat. Drei zulässige Strategien, in Reihenfolge der Empfehlung:

Strategie Wie Stack-unabhängig? Tempo Verdikt
A. Fresh-DB-Copy je Testdatei (heute) vor jeder Datei/jedem Worker seed.db→Tempfile kopieren, Backend neu zeigen lassen ⚠️ teilw. (kennt SQLite-Datei) sehr schnell (ms) Start hier — pragmatisch, da heute SQLite
B. Definierter Reset-/Seed-ENDPOINT Backend bietet POST /api/test/reset (nur bei X-Internal-Token/SS_TEST_MODE) → leert + lädt deterministischen Seed ✅ voll (reiner HTTP-Vertrag) schnell Ziel-Architektur — Teil des Rewrite-Vertrags
C. Seeding rein über die API jeder Test legt sich seine Welt per POSTs an ✅ voll langsamer; scheitert an Importer-only-Entitäten (Bons) nur ergänzend

Empfohlener Weg: Heute A (Fixture-DB, sofort lauffähig). Als Rewrite- Vertragsbestandteil B definieren: „Eine konforme Backend-Implementierung MUSS im Testmodus POST /api/test/reset anbieten, das den Zustand auf den dokumentierten Seed zurücksetzt." Damit wird Reset selbst Teil des HTTP-Vertrags und die Suite bleibt 100 % black-box — der Seed-Inhalt liegt als seed.sql/Fixtures versioniert daneben. C füllt die Lücke für Entitäten, die die API regulär erzeugen kann; das Seeding-Problem der Importer-only-Daten (Bons) löst nur A oder B.

Für die Bon-/Importer-Entitäten: deren Erzeugung gehört nicht in den HTTP-Vertrag (kein API-Weg) → sie kommen aus dem Seed (A/B). So testet die Suite die Folge-Endpoints (Bon-Archiv, „EAN fehlt"→scannen) gegen feste Seed-Daten, ohne den Importer nachzubauen.

4.4 Marker-Konzept fast vs. integration — 3rd-Party-Isolation OHNE Code-Eingriff

Die Backend-URLs zu OFF/HF/Garmin sind hardcoded → ein HTTP-Mock ließe sich nur durch Code-Eingriff einschleusen (verboten: stack-unabhängig, kein Interna- Touch). Lösung daher nicht Mocking, sondern Marker-Split:

# conftest.py
def pytest_configure(config):
    config.addinivalue_line("markers", "fast: nur lokal, kein 3rd Party (Sekunden)")
    config.addinivalue_line("markers", "integration: ruft echte Fremddienste (langsam, flaky)")
# pyproject.toml [tool.pytest.ini_options]
addopts = "-m fast"          # Default: nur der schnelle Lauf

4.5 Contract-Snapshots (der eigentliche Rewrite-Beweis)

Zwei sich ergänzende Vertrags-Ebenen:

  1. Struktur-Vertrag (OpenAPI-Snapshot): contract/openapi-snapshot.json = eingefrorenes heutiges /api/openapi.json. Ein fast-Test vergleicht das live gelieferte Schema (falls vorhanden) dagegen UND speist es in Schemathesis als Fuzz-Quelle. Nach dem Rewrite (kein /openapi.json mehr) bleibt die Snapshot-Datei der maßgebliche Schema-Vertrag.
  2. Verhaltens-Vertrag (Golden Files via syrupy): für jeden Endpoint die vollständige normalisierte Antwort als Snapshot. Beim Rewrite: pytest rot, wenn auch nur ein Feld abweicht; --snapshot-update nur bewusst. Das ist die wörtliche Umsetzung von „nach außen EXAKT gleich".
def test_get_diary_day(client, snapshot):
    r = client.get("/api/diary/2026-06-01", headers=TOKEN)
    assert r.status_code == 200
    assert normalize(r.json()) == snapshot     # syrupy

4.6 Determinismus-Behandlung (sonst false positives)

Snapshots sind nur dann ein Vertrag, wenn sie reproduzierbar sind. Vor jedem Snapshot eine zentrale normalize()-Funktion (ein einziger Ort, leicht von Claude pflegbar):

Der Trick: so viel wie möglich am Seed/Backend deterministisch machen (feste IDs/Datum), so wenig wie möglich im Client maskieren — je mehr maskiert wird, desto schwächer der Vertrag.


5. Aufwandsschätzung

Block Inhalt Aufwand
Grundgerüst app/apitest/ + pyproject (uv) + conftest mit start_backend, http-Client-Fixture, Marker, Health-Poll, Fresh-DB-Copy (Strategie A) ~2–3 h
Seed festzurren heutige Test-DB säubern/anonymisieren → seed.db + seed.sql, feste IDs/Datum ~1–2 h
Contract-Snapshots /openapi.json einfrieren + syrupy-Golden-Files für alle GET-Endpoints, normalize() schreiben ~3–4 h
Szenario-Ketten die echten stateful Flows (Diary↔Pantry↔Plan↔Liste, Undo, gegessen↔Tagebuch) — das Herzstück ~4–6 h
Mutations/Edge POST/PATCH/DELETE-Verträge, Auth (Token akzeptiert/401), Raw-Upload, HTML/SPA-catchall, 415/404/422 ~2–3 h
Marker-Integration integration-Tests für OFF/HF/eGYM/Garmin/Push/Gemini als langsamer Extra-Lauf ~2–3 h
Schemathesis-Stufe (optional) schemathesis_run.py gegen den Snapshot, in CI als separater non-blocking Job ~1–2 h
Reset-Endpoint (Ziel-B) POST /api/test/reset ins Backend + Umstellung conftest von A auf B ~2 h (eigene Session, Ein-Schreiber-Regel!)

Summe Kern (A, ohne Reset-Endpoint): ~14–21 h verteilbar über mehrere Sessions. Erster nützlicher Stand (Gerüst + Health + 3–4 Szenario-Ketten + erste Snapshots) nach ~6–8 h. Der Reset-Endpoint (B) ist ein Backend- Eingriff → strikt getrennte Session, nie parallel (ADR-15).


6. Offene Fragen an Dennis (max. 3)

  1. Reset-Vertrag jetzt oder später? Reicht dir der Start mit Fixture-DB (Strategie A) und wir definieren POST /api/test/reset (B) erst, wenn ein Rewrite konkret wird — oder soll der Reset-Endpoint gleich mit ins heutige Backend, damit der Vertrag von Tag 1 vollständig black-box ist? (Empfehlung: A jetzt, B als dokumentierten Rewrite-Vertrag festschreiben.)
  2. Snapshot-Granularität: Volle JSON-Antworten als syrupy-Golden-Files (maximaler „exakt gleich"-Beweis, aber viele Snapshot-Dateien im Repo) — oder nur Schema/ausgewählte Felder schnappschießen (weniger Rauschen, schwächerer Vertrag)? (Empfehlung: volle Antworten + scharfe normalize().)
  3. Wo lebt die Suite & läuft sie automatisch? Eigener compose-Service silverscale-apitest auf Port 9399 mit Cron/Pre-Deploy-Gate (jeder npm build/Deploy erst nach grünem fast-Lauf) — oder reiner On-Demand-Lauf, den eine Claude-Session vor heiklen Änderungen anstößt? (Empfehlung: Pre-Deploy- Gate für fast, integration rein manuell.)

7. Quellen