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
- pytest + httpx (2026): httpx ist die Standard-Testklasse hinter FastAPI/
Starlette, funktioniert aber genauso gegen eine externe URL (
httpx.Client( base_url=...)) — das ist der Modus, den wir hier wollen (echte HTTP-Schicht, nicht der In-Process-TestClient!).pytest-xdistparallelisiert über alle Kerne.uv runist der schnelle, reproduzierbare Runner (uv run --isolated --with-editable '.[test]' pytest). - syrupy vs. inline-snapshot: syrupy legt Snapshots in separate
.ambr/__snapshots__-Dateien ab — ideal als versioniertes „Vertrags-Archiv", das man im Review als Datei-Diff sieht. inline-snapshot (Pydantic) schreibt die erwarteten Werte direkt in den Testcode (pytest --inline-snapshot=fix) — angenehm lesbar, aber bei ~120 großen JSON-Antworten bläht es die Testdateien. Empfehlung: syrupy als Haupt-Snapshotter (Vertrag = eigene Dateien), inline-snapshot optional für kleine, scharfe Einzel-Asserts. - Schemathesis (2026, aktiv gepflegt, Release 06.06.2026): Stateful-Phase ist
per Default an; State-Machine über
schema.as_state_machine(). Aber: die Zustandsketten entstehen aus OpenAPI-Links (explizit gepflegt oder aus Response-Schema/Location-Header inferiert) — ohne Links „hat die stateful-Phase nichts auszuführen". Lädt Schema standardmäßig per URL; ein gespeicherter Snapshot (openapi.json-Datei) ist überfrom_path/from_filenutzbar — wichtig, weil der Rewrite kein/openapi.jsonmehr garantiert. Stärke: findet 1,4–4,5× mehr Defekte als andere Fuzzer; kein Snapshot-Vertrag, keine freie Rechen-Logik. - Hurl (2026): ein Rust-Binary (libcurl),
.hurl-Plaintext,[Captures]→ Variablen → Folge-Request,[Asserts]mit JSONPath/XPath,--test-Report (JUnit/TAP/HTML),retry/delay. Perfekt für Health-/Smoke-Ketten ohne Logik; keine Snapshot-Diffs, keine Schleifen/Berechnung — daher kein Kern. - Vitest/TS: schnell, gute Snapshots — aber eigenes Node-Projekt nur für Tests ist Reibung ohne Gegenwert, wenn der Python-Stack schon steht.
- Bruno-CLI / StepCI / Tavern / Karate: Bruno = Git-freundliche
.bru-Dateien, eher API-Client als stateful-Suite. StepCI „für simple CI-Checks, GitHub- Aktivität vorher prüfen". Tavern = pytest-YAML-Layer (würde unsere freie Python-Logik einschränken). Karate = mächtigste DSL, aber JVM+Gherkin = Fremdkörper hier. Keiner schlägt pytest+httpx für diesen Zweck.
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):
- Frische DB aus
seed/an einen Temp-Pfad kopieren (/tmp/ss-apitest-<pid>.db). - Backend mit
SS_DB_PATH=<temp>+AUTH_DISABLED=0+ bekanntemINTERNAL_API_TOKENauf z. B. Port 9399 starten (eigener compose-Servicesilverscale-apitestODER direktuvicornim venv). - Auf
GET /api/health(oder Root) pollen, bis 200 — dann Tests freigeben. - 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. Mitpytest-xdistje 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
- Alltag / CI-Gate / Rewrite-Verifikation:
uv run pytest -n auto(nurfast, alles lokal, in Sekunden). - 3rd-Party-Lauf (selten, manuell):
uv run pytest -m integration— darf langsam/flaky sein, eigene Retries, eigenes Zeitlimit, kein CI-Blocker. - KI-Endpoints: die meisten queuen nur einen Job in die DB → das sind
fast-Tests (POST → prüfe, dassai_jobs-Zeile/Status korrekt entsteht; der Host-Worker wird nicht mitgetestet). Nur die wenigen blocking-Aufrufe (Geminimap-suggest) bekommenintegration. - Die einzige stack-unabhängige Form von „Mock" wäre, dem Backend per ENV eine alternative Basis-URL für Fremddienste zu geben, die auf einen lokalen Stub-Server zeigt. Das ist sauber, ABER es setzt voraus, dass jede künftige Implementierung diese ENV respektiert → optional als Komfort-Vertrag vermerken, nicht als Pflicht. Default bleibt: Marker-Split.
4.5 Contract-Snapshots (der eigentliche Rewrite-Beweis)
Zwei sich ergänzende Vertrags-Ebenen:
- Struktur-Vertrag (OpenAPI-Snapshot):
contract/openapi-snapshot.json= eingefrorenes heutiges/api/openapi.json. Einfast-Test vergleicht das live gelieferte Schema (falls vorhanden) dagegen UND speist es in Schemathesis als Fuzz-Quelle. Nach dem Rewrite (kein/openapi.jsonmehr) bleibt die Snapshot-Datei der maßgebliche Schema-Vertrag. - Verhaltens-Vertrag (Golden Files via syrupy): für jeden Endpoint die
vollständige normalisierte Antwort als Snapshot. Beim Rewrite:
pytestrot, wenn auch nur ein Feld abweicht;--snapshot-updatenur 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):
- Zeitstempel → fester Token
"<TS>"(ISO-Regex), bzw. Zeit im Backend per ENV/Test-Clock einfrieren, wo möglich. - Auto-IDs →
"<ID>"bzw. auf stabile Reihenfolge sortieren; besser noch: Seed vergibt feste IDs, dann müssen nur neu erzeugte IDs maskiert werden. - Reihenfolge nicht-sortierter Listen → deterministisch sortieren vor dem Diff.
- Volatile Felder (Cache-Header, ETag, generierte Dateinamen) → entfernen.
- Seed mit festem Datum laden, damit „heute"-abhängige Endpoints stabil sind.
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)
- 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.) - 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().) - Wo lebt die Suite & läuft sie automatisch? Eigener compose-Service
silverscale-apitestauf Port 9399 mit Cron/Pre-Deploy-Gate (jedernpm build/Deploy erst nach grünemfast-Lauf) — oder reiner On-Demand-Lauf, den eine Claude-Session vor heiklen Änderungen anstößt? (Empfehlung: Pre-Deploy- Gate fürfast,integrationrein manuell.)
7. Quellen
- Schemathesis — Property-based API Testing
- Schemathesis — Customizing Stateful Testing (readthedocs)
- Schemathesis auf GitHub
- syrupy — pytest snapshot plugin
- Better Python tests with inline-snapshot (Pydantic)
- Snapshot testing with Syrupy — Simon Willison
- FastAPI Testing Guide: pytest Fundamentals & Best Practices
- Hurl — Run and Test HTTP Requests
- Hurl — Chaining Requests
- Hurl auf GitHub
- Best API Testing Tools in 2026 — TestGuild
- Definitive Guide to API Testing Tools 2026 — Tusk
- Catching API regressions with snapshot testing — Kreya
- Why Snapshot Testing Is the Secret Weapon for API Stability — DEV
- RefreshDatabase in pytest (DB-Reset-Patterns)