Symfony 7 + OpenAPI Contract Testing: Der Schema-First-Workflow gegen 'Funktioniert in Swagger, bricht in Prod'
Es gibt eine bestimmte Art von Produktionsvorfall, die im Nachhinein besonders vermeidbar wirkt: Swagger zeigte einen grünen Haken, die Postman-Collection lief durch, und das React-Frontend wurde gegen eine Spezifikation deployt, die längst nicht mehr zur tatsächlichen Symfony-Response passte. Symfony OpenAPI Contract Testing existiert genau dafür, diese Lücke zu schließen - nicht durch mehr Unit-Tests, sondern indem die Spezifikation selbst ausführbar wird und jede Abweichung von ihr als Build-Fehler gilt.
Dieser Beitrag führt durch die komplette Pipeline, die wir in Projekten mit API Platform 4 und Symfony 7 einsetzen: die OpenAPI-Spezifikation als CI-Artefakt generieren, Schemathesis-Property-Based-Tests gegen einen laufenden Symfony-Test-Kernel ausführen, Breaking-Change-Erkennung mit oasdiff in jedem Pull Request erzwingen und einen Prism-Mock-Server aufsetzen, damit Frontend-Teams gegen einen spezifikationstreuen Stub entwickeln können, bevor der echte Endpoint existiert. Alles ist in GitHub Actions verdrahtet, sodass die Feedback-Schleife automatisch läuft.
Warum die automatisch generierte Spec von API Platform nicht reicht
API Platform 4 ist bei der OpenAPI-Generierung wirklich gut. Richte es auf eine Symfony-Entity mit den passenden Attributen, und es erzeugt mit minimalem Aufwand eine 3.1-konforme Spezifikation. Das Problem ist nicht die Spec im Moment der Generierung - es ist die Spec drei Monate später, nach einem Dutzend inkrementeller Änderungen an Serialisierungsgruppen, nach der Einführung eines Custom State Providers, nachdem jemand ein Nullable-Feld ergänzt hat, ohne das DTO zu aktualisieren.
Zu diesem Zeitpunkt ist die Spec immer noch gültiges YAML, die Swagger UI rendert weiterhin wunderbar, und die CI-Pipeline zeigt Grün - weil niemand die CI so konfiguriert hat, dass sie prüft, ob die Spec dem entspricht, was die Anwendung zur Laufzeit tatsächlich zurückgibt.
Contract Testing ist die fehlende Schicht. Es behandelt die Spec als Quelle der Wahrheit und die laufende Anwendung als Implementierung, die ihr entsprechen muss.
Schritt 1: Die OpenAPI-Spec als CI-Artefakt exportieren
Bevor du gegen die Spec testen kannst, musst du sie deterministisch aus der Symfony-Anwendung selbst exportieren - nicht aus einem Editor, nicht aus einer handgepflegten YAML-Datei.
API Platform liefert dafür ein Konsolen-Kommando mit:
php bin/console api:openapi:export --output=openapi.json --format=json
Füge das in GitHub Actions als ersten Schritt deines Contract-Testing-Jobs hinzu:
- name: Export OpenAPI spec
run: |
APP_ENV=test php bin/console api:openapi:export \
--output=openapi.json \
--format=json
Führe das in APP_ENV=test aus, damit die Spec dieselbe Symfony-Konfiguration widerspiegelt, die auch die Tests nutzen. Lade openapi.json als Workflow-Artefakt hoch, damit andere Jobs - und dein Frontend-Team - sie weiterverwenden können.
Schritt 2: Property-Based Contract Tests mit Schemathesis
Schemathesis liest deine OpenAPI-Spec und generiert automatisch HTTP-Testfälle, die jeden Endpoint abklopfen. Anders als handgeschriebene Behat-Szenarien nutzt Schemathesis Property-Based Testing: Es mutiert Eingaben, erkundet Randfälle und prüft, ob jede Response dem Schema entspricht, das für den jeweiligen Statuscode deklariert ist.
Installiere es in der CI:
pip install schemathesis
Verdrahte es mit deinem Symfony-Testserver. Der sauberste Ansatz: Symfonys eingebauten Server im Hintergrund starten und Schemathesis darauf zeigen lassen:
- name: Start Symfony test server
run: symfony server:start --port=8001 --no-tls &
- name: Run Schemathesis contract tests
run: |
st run openapi.json \
--base-url=http://localhost:8001 \
--checks=all \
--stateful=links \
--junit-xml=schemathesis-results.xml
Die vier Schemathesis-Checks, die du explizit aktivieren solltest, sind not_a_server_error (jede 5xx-Response schlägt fehl), response_schema_conformance (der Response-Body muss dem deklarierten Schema entsprechen), content_type_conformance (der Content-Type-Header muss zur Spec passen) und status_code_conformance (nur in der Spec deklarierte Statuscodes sind zulässig). Zusammen fangen diese vier rund 90% der Serialisierungsregressionen ab - vergessene Nullable-Felder, entfernte Properties, geänderte Enum-Werte, neue Pflichtfelder ohne Default.
Wenn deine API JWT-Authentifizierung nutzt, übergib ein gültiges Test-Token via --header "Authorization: Bearer ${TEST_TOKEN}". Generiere das Token in einem Setup-Schritt über ein Symfony-Kommando oder eine Fixture, die einen Testnutzer anlegt.
Schritt 3: Breaking-Change-Erkennung mit oasdiff
Schemathesis prüft, ob die Implementierung zur aktuellen Spec passt. oasdiff prüft, ob die aktuelle Spec die vorherige Spec bricht - und fängt damit Consumer-Breaking-Changes ab, bevor sie gemergt werden.
pip install oasdiff
Lade in GitHub Actions die Spec des Base-Branches herunter und diffe sie gegen die Spec des PR-Branches:
- name: Download base spec
run: |
git fetch origin master
git show origin/master:openapi.json > openapi-base.json || echo '{}' > openapi-base.json
- name: Check for breaking changes
run: |
oasdiff breaking openapi-base.json openapi.json \
--fail-on ERR \
--format text
--fail-on ERR lässt den Job nur bei Breaking Changes fehlschlagen - entfernte Endpoints, geänderte Pflichtparameter, inkompatible Typänderungen - während additive Änderungen durchgehen. Warnungen kannst du schrittweise zu Fehlern hochstufen, wenn die Disziplin im Team wächst.
Dieser Schritt verhindert den "Ich habe nur dieses deprecated Feld entfernt"-Vorfall. Der PR kann nicht gemergt werden, bis der Breaking Change entweder beabsichtigt ist und die Version angehoben wird, oder die Änderung zurückgenommen wird.
Schritt 4: Prism-Mock-Server für Frontend-First-Entwicklung
Sobald die Spec ein vertrauenswürdiges, getestetes Artefakt ist, kann dein React- oder Next.js-Frontend-Team dagegen entwickeln, bevor der echte Endpoint existiert. Prism liest die OpenAPI-Spec und antwortet mit schemakonformen, mit Beispielen befüllten Mock-Responses.
Starte Prism in Docker innerhalb deines GitHub-Actions-Workflows:
- name: Start Prism mock server
run: |
docker run --rm -d \
-p 4010:4010 \
-v ${{ github.workspace }}/openapi.json:/openapi.json \
stoplight/prism:4 mock /openapi.json
- name: Run frontend smoke tests against Prism
run: |
cd frontend
npm ci
NEXT_PUBLIC_API_BASE=http://localhost:4010 npm run test:smoke
Der Wert liegt nicht darin, dass Prism ein perfekter Ersatz für die echte API wäre - das ist es nicht. Der Wert liegt darin, dass jeder Frontend-Code, der gegen Prism kompiliert und die Smoke-Tests besteht, bereits gegen die Spec validiert ist. Wenn der echte Endpoint ausgeliefert wird, bestätigen die Contract Tests zwischen Spec und Implementierung die Konformität, sodass die Integration zwischen Frontend und Backend ohne manuelle Testrunde funktioniert.
Schritt 5: Der komplette GitHub-Actions-Workflow
Alles zusammen als ein Job, der bei jedem PR läuft:
name: Contract Tests
on:
pull_request:
push:
branches: [master]
jobs:
contract:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: app_test
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, intl
- name: Install PHP dependencies
run: composer install --no-interaction --prefer-dist
- name: Run database migrations
run: php bin/console doctrine:migrations:migrate --no-interaction --env=test
- name: Load fixtures
run: php bin/console hautelook:alice:load --no-interaction --env=test
- name: Export OpenAPI spec
run: APP_ENV=test php bin/console api:openapi:export --output=openapi.json
- name: Upload spec artefact
uses: actions/upload-artifact@v4
with:
name: openapi-spec
path: openapi.json
- name: Download base spec for diff
run: |
git fetch origin ${{ github.base_ref || 'master' }}
git show origin/${{ github.base_ref || 'master' }}:openapi.json > openapi-base.json 2>/dev/null || echo '{}' > openapi-base.json
- name: Install Python tools
run: pip install schemathesis oasdiff
- name: Start Symfony test server
run: symfony server:start --port=8001 --no-tls --env=test &
- name: Wait for server
run: sleep 3
- name: Run Schemathesis
run: |
st run openapi.json \
--base-url=http://localhost:8001 \
--checks=all \
--stateful=links \
--junit-xml=schemathesis-results.xml
- name: Check for breaking changes
run: oasdiff breaking openapi-base.json openapi.json --fail-on ERR
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: schemathesis-results.xml
Was dieser Workflow findet, was Unit-Tests übersehen
Der obige Workflow deckt regelmäßig Probleme auf, die keine noch so hohe PHPUnit-Abdeckung finden würde:
Umbenannte Serialisierungsgruppen - Eine Symfony-Serialisierungsgruppe wurde umbenannt, wodurch ein Feld aus der API-Response verschwand, während die PHP-Methode intakt blieb. PHPUnit läuft durch, der Contract Test schlägt bei response_schema_conformance innerhalb von 30 Sekunden fehl.
Nullable-Randfälle - Ein Feld mit nullable: false in der Spec lieferte in einem bestimmten Randfall null zurück. Das Property-Based Fuzzing von Schemathesis fand die auslösende Eingabekombination in unter zwei Minuten. Ein handgeschriebener Test hätte diese Kombination nie erraten.
Entfernte Endpoints - Ein Entwickler entfernte einen GET /widgets/{id}-Endpoint, von dem der Mobile-Client noch abhing. oasdiff markierte das als Breaking Change, bevor der PR gemergt werden konnte. Kein Vorfall, kein nächtliches Rollback.
Neue Pflichtparameter ohne Defaults - Ein neuer Pflicht-Request-Parameter wurde ohne Default-Wert ergänzt. Die Stateful-Link-Traversierung von Schemathesis traf den Endpoint über eine realistische Aufrufsequenz und bekam einen 422 zurück - genau die Response, die ein Client in Produktion sehen würde.
Keiner dieser Fehler ist in der Swagger UI sichtbar. Keiner wird von einer Testsuite gefunden, die nur die PHP-Schicht isoliert prüft. Es ist genau die Klasse von Bugs, die in der Lücke zwischen Spec und Implementierung lebt - und Contract Testing ist das einzige Werkzeug, das diese Lücke automatisch findet.
Eine Anmerkung zum Aufwand
Den Spec-Export und die Schemathesis-Schritte aufzusetzen ist ein solider Nachmittag Arbeit für eine Person, die die Codebasis bereits kennt. Die oasdiff- und Prism-Integrationen kosten einen weiteren Tag, sobald die Basis läuft. Das schwierigere Problem ist die Kalibrierung, was für deine konkreten API-Consumer als Breaking Change zählt, und die entsprechende Abstimmung der oasdiff-Schweregrade - diese Kalibrierung lohnt es, einmal sorgfältig zu machen statt reaktiv nach einem Vorfall.
Unsere Beratungen zu Code-Qualität und individueller Softwareentwicklung beinhalten API Contract Testing regelmäßig als Teil einer breiteren Qualitätsbasis. Wenn du eine zweite Meinung zu deinem aktuellen Symfony-Testansatz möchtest oder praktische Hilfe beim Aufsetzen dieser Pipeline in deiner CI-Umgebung brauchst, schreib an hello@wolf-tech.io oder besuche wolf-tech.io.

