Contract-Testing für REST-APIs mit OpenAPI
AI generated
{ }
GET
Contract-Testing · OpenAPI · Schemathesis · PHPUnit · CI/CD
Contract-Testing für REST-APIs mit OpenAPI
Schemathesis, Dredd und PHPUnit im Einsatz

Eine OpenAPI-Spezifikation, die nicht mehr mit der Implementierung übereinstimmt, ist schlechter als gar keine Dokumentation – sie täuscht. Contract-Testing schließt diese Lücke automatisch: Tests prüfen bei jedem Commit, ob die Backend-Implementierung noch dem Vertrag entspricht, den die Spezifikation definiert.

20 Min. Lesezeit Schemathesis · Dredd · PHPUnit · Pact · OpenAPI Validator PHP 8.4 · Symfony 7 · GitHub Actions · Consumer-Driven Contracts

1. Was Contract-Testing ist – und was nicht

Contract-Testing prüft, ob eine API-Implementierung einem definierten Vertrag entspricht. Im Kontext von OpenAPI ist dieser Vertrag die Spezifikationsdatei: Jede Antwort, die die API zurückgibt, muss dem dort definierten Schema entsprechen. Jeder Endpoint, der in der Spec deklariert ist, muss existieren. Jeder Pflichtparameter muss akzeptiert werden. Jeder Status-Code muss die richtige Body-Struktur haben.

Was Contract-Testing nicht ist: kein Ersatz für Unit-Tests und kein Ersatz für End-to-End-Tests. Contract-Tests prüfen die Schnittstelle, nicht die Geschäftslogik. Ob die API bei einer Produktsuche die richtigen Produkte zurückgibt, ist eine Business-Logic-Frage, die Unit-Tests beantworten müssen. Ob die API bei der Produktsuche eine Antwort mit korrekter Struktur gemäß Spezifikation zurückgibt, ist eine Contract-Testing-Frage. Diese Trennung ist wichtig, um Contract-Tests nicht mit zu vielen Verantwortlichkeiten zu überladen und damit wartungsaufwändig zu machen.

Ein zweiter wichtiger Unterschied: Provider-seitige Contract-Tests (Spec-Driven) validieren, ob das Backend den eigenen Vertrag einhält. Consumer-Driven Contract-Tests validieren, ob das Backend die Anforderungen eines spezifischen Consumers (Frontend, Mobile App, anderer Service) erfüllt. Beide Ansätze lösen verschiedene Probleme und können kombiniert werden. In den meisten Teams mit OpenAPI ist der Provider-seitige Ansatz der einfachere Einstieg.

2. Spec-Driven Contract-Testing mit Schemathesis

Schemathesis ist das leistungsfähigste Tool für spec-driven Contract-Testing. Es liest eine OpenAPI-Spezifikation und generiert automatisch Testfälle durch eigenschaftsbasiertes Testen (Property-Based Testing): Für jeden Endpoint werden gültige und ungültige Inputs generiert, die API aufgerufen und die Antwort gegen das definierte Schema validiert. Das deckt Randbedingungen auf, die manuell geschriebene Tests selten abdecken: Was passiert bei einem Integer-Wert von MAX_INT? Was bei einem leeren String in einem Pflichtfeld? Was bei Unicode-Zeichen in einem URL-Parameter?

Schemathesis unterstützt stateful-Testmodi, in denen es aus den API-Links und Abhängigkeiten zwischen Endpoints automatisch Testsequenzen erzeugt: ein User wird zuerst erstellt, dann abgerufen, dann geändert, dann gelöscht. Diese Sequenzen testen den gesamten Lebenszyklus einer Ressource, ohne dass ein Test-Entwickler die Sequenz manuell aufschreiben muss. Das --checks-Flag steuert, welche Checks aktiviert sind: not_a_server_error prüft, dass keine 5xx-Fehler auftreten; response_schema_conformance validiert jede Antwort gegen das Schema; content_type_conformance prüft den Content-Type-Header.


# Install Schemathesis
pip install schemathesis

# Basic spec-driven contract test against running API
schemathesis run api/openapi.yaml \
  --url http://localhost:8080 \
  --checks all \
  --report .reports/schemathesis.html

# Stateful testing: follow API links between operations
schemathesis run api/openapi.yaml \
  --url http://localhost:8080 \
  --stateful=links \
  --checks not_a_server_error,response_schema_conformance

# Filter specific endpoints for focused testing
schemathesis run api/openapi.yaml \
  --url http://localhost:8080 \
  --endpoint "/users/{id}" \
  --method GET,PUT \
  --checks all

# Output JUnit XML for CI reporting
schemathesis run api/openapi.yaml \
  --url http://localhost:8080 \
  --checks all \
  --junit-xml .reports/schemathesis.xml

# With authentication (Bearer Token)
schemathesis run api/openapi.yaml \
  --url http://localhost:8080 \
  --checks all \
  --header "Authorization: Bearer $API_TOKEN"

# Reproduce a specific failing case (from schemathesis output)
schemathesis replay .schemathesis/case-abc123.yaml \
  --url http://localhost:8080

3. Dredd: Beispielbasierte Contract-Tests aus der Spec

Dredd ist ein weiteres Tool für OpenAPI-basiertes Contract-Testing mit einem anderen Ansatz: Es führt jeden in der Spezifikation definierten Request-Beispiel gegen die echte API aus und validiert die Response. Statt property-basierter Testgenerierung wie Schemathesis nutzt Dredd die expliziten Beispiele aus der Spec – was bedeutet, dass die Qualität der Dredd-Tests direkt von der Qualität der Beispiele in der Spec abhängt. Das macht Dredd ideal für Teams, die ihre Spec mit vollständigen Beispielen pflegen und exakt diese Beispiele als Tests ausführen wollen.

Dredd unterstützt Hooks in JavaScript und Python für Setup- und Teardown-Logik zwischen Tests. Das ermöglicht, authentifizierte Tokens zu holen, Testdaten in der Datenbank anzulegen und nach dem Test aufzuräumen. Ein Hook kann auch Responses dynamisch validieren, die von den statischen Beispielen abweichen – etwa wenn IDs und Timestamps dynamisch sind. Dredd ist einfacher zu konfigurieren als Schemathesis und erzeugt für jedes Beispiel aus der Spec genau einen Testfall, was die Testausgabe vorhersehbar macht.

4. PHPUnit-basierte Contract-Tests in Symfony

Für PHP-Projekte sind PHPUnit-basierte Contract-Tests eine natürliche Ergänzung zu Schemathesis und Dredd. Sie laufen in derselben Testumgebung wie alle anderen Tests, können auf Fixtures und Datenbankzustand zugreifen und werden mit denselben CI-Tools ausgeführt. Der Ansatz: Eine abstrakte Contract-Testklasse lädt die OpenAPI-Spezifikation, instanziiert einen JSON-Schema-Validator und stellt Assert-Methoden bereit, die jeden Response gegen das entsprechende Schema validieren. Konkrete Tests erben von dieser Klasse und rufen API-Endpoints auf.

In Symfony integrieren sich PHPUnit-Contract-Tests ideal mit dem WebTestCase: Tests erstellen einen Symfony-Client, führen Requests aus und validieren Responses sowohl auf Geschäftslogik- als auch auf Schema-Ebene. Das bedeutet, dass derselbe Testfall prüft: Hat die API die richtigen Daten zurückgegeben (Business-Logic-Assertion) und entspricht die Response-Struktur der Spec (Contract-Assertion). Diese Kombination verhindert, dass Business-Logic-Tests implizit von der Spec abweichen.


<?php
// tests/Contract/AbstractContractTestCase.php
declare(strict_types=1);

namespace App\Tests\Contract;

use JsonSchema\Validator;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Yaml\Yaml;

/**
 * Base class for OpenAPI contract tests.
 * Validates that API responses conform to the OpenAPI specification schema.
 */
abstract class AbstractContractTestCase extends WebTestCase
{
    private static array $spec = [];
    private Validator $validator;

    protected function setUp(): void
    {
        parent::setUp();
        $this->validator = new Validator();

        if (empty(self::$spec)) {
            $specPath   = dirname(__DIR__, 2) . '/api/openapi.yaml';
            self::$spec = Yaml::parseFile($specPath);
        }
    }

    /**
     * Assert that the given response data conforms to the named schema.
     */
    protected function assertMatchesSchema(mixed $data, string $schemaName): void
    {
        $schema   = self::$spec['components']['schemas'][$schemaName]
            ?? throw new \LogicException("Schema '$schemaName' not found in OpenAPI spec");
        $jsonData = json_decode(json_encode($data, JSON_THROW_ON_ERROR));
        $jsonSchema = json_decode(json_encode($schema, JSON_THROW_ON_ERROR));

        $this->validator->validate($jsonData, $jsonSchema);

        if (!$this->validator->isValid()) {
            $errors = array_map(
                fn($e) => sprintf('[%s] %s', $e['property'], $e['message']),
                $this->validator->getErrors()
            );
            $this->fail(
                "Response does not match schema '$schemaName':\n" . implode("\n", $errors)
            );
        }
    }

    /**
     * Assert that the response status code and schema match the spec definition.
     */
    protected function assertApiResponse(
        int $expectedStatus,
        string $schemaName,
        string $responseBody,
    ): void {
        $data = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
        $this->assertMatchesSchema($data, $schemaName);
    }
}

<?php
// tests/Contract/UserContractTest.php
declare(strict_types=1);

namespace App\Tests\Contract;

use App\Tests\Contract\AbstractContractTestCase;

/**
 * Contract tests for the /users endpoint.
 * Validates that responses conform to the OpenAPI specification.
 */
final class UserContractTest extends AbstractContractTestCase
{
    public function testGetUserMatchesSchema(): void
    {
        $client = static::createClient();
        $client->request(
            'GET',
            '/users/1',
            [],
            [],
            ['HTTP_AUTHORIZATION' => 'Bearer ' . $this->getTestToken()]
        );

        $response = $client->getResponse();

        self::assertSame(200, $response->getStatusCode());
        self::assertResponseHeaderSame('content-type', 'application/json');

        // Business-logic assertion
        $data = json_decode($response->getContent(), true);
        self::assertSame(1, $data['id']);
        self::assertNotEmpty($data['email']);

        // Contract assertion: response must match UserResponse schema
        $this->assertApiResponse(200, 'UserResponse', $response->getContent());
    }

    public function testGetNonExistentUserReturns404WithSchema(): void
    {
        $client = static::createClient();
        $client->request('GET', '/users/99999', [], [], [
            'HTTP_AUTHORIZATION' => 'Bearer ' . $this->getTestToken(),
        ]);

        $response = $client->getResponse();

        self::assertSame(404, $response->getStatusCode());

        // Contract assertion: 404 response must match ErrorResponse schema
        $this->assertApiResponse(404, 'ErrorResponse', $response->getContent());
    }

    private function getTestToken(): string
    {
        return 'test-token'; // Or generate from test JWT factory
    }
}

5. Response-Validierung als Middleware in Symfony

Eine besonders elegante Form von Contract-Testing ist die automatische Response-Validierung als Symfony-Event-Listener im Testumfeld. Der Listener wird nur für die Testumgebung registriert und validiert jede ausgehende Response gegen die OpenAPI-Spezifikation – ohne dass einzelne Tests explizit Validierungsaufrufe enthalten müssen. Jeder Test, der die API aufruft, führt automatisch eine Contract-Prüfung durch. Das bedeutet: Ein Business-Logic-Test deckt gleichzeitig Contract-Verstöße auf, ohne dass der Test selbst Code zur Spec-Validierung enthält.

Der Nachteil dieses Ansatzes ist die höhere Komplexität: Der Event-Listener muss den aktuellen Request-Pfad kennen, das entsprechende Schema in der Spec finden und die Response validieren. Das erfordert einen JSON-Schema-Resolver, der $ref-Referenzen in der Spec auflöst. Die Bibliothek league/openapi-psr7-validator bietet diese Funktionalität vollständig und kann als Middleware oder als Event-Listener in Symfony integriert werden. Der Aufwand für das Setup amortisiert sich schnell, wenn Contract-Compliance in vielen Tests implizit geprüft werden soll.

6. Consumer-Driven Contracts mit Pact

Consumer-Driven Contracts kehren die Richtung des Tests um: Nicht der Provider (Backend) definiert den Vertrag, sondern der Consumer (Frontend, Mobile App). Der Consumer schreibt in seiner Testumgebung Pact-Tests, die definieren, welche Requests er an welchen Endpoint sendet und welche Response-Struktur er erwartet. Pact erzeugt aus diesen Tests eine Pact-Datei, die der Provider gegen seine Implementierung verifiziert. Das stellt sicher, dass Backend-Änderungen, die das Frontend brechen würden, erkannt werden – bevor sie in Produktion gehen.

Pact ist besonders wertvoll in Microservice-Architekturen, wo viele Services voneinander abhängen und Änderungen an einem Service andere Services brechen können. Für monolithische PHP-Applikationen mit einem Frontend-Client ist Pact oft ein höherer Aufwand als notwendig – hier reichen spec-driven Contract-Tests mit Schemathesis oder Dredd in Kombination mit PHPUnit-Contract-Tests. Die Entscheidung zwischen Consumer-Driven und Spec-Driven Contract-Testing hängt von der Systemarchitektur und der Anzahl der Consumer ab.

7. Contract-Tests in der CI-Pipeline integrieren

Contract-Tests in der CI-Pipeline zu integrieren ist entscheidend dafür, dass sie tatsächlich genutzt werden. Der typische Workflow: Schemathesis und PHPUnit-Contract-Tests laufen bei jedem Pull Request als eigener Job, parallel zu Unit-Tests und Integration-Tests. Bei Fehlschlag blockieren sie den Merge. Das Signal ist klar: Eine Spec-Abweichung wird genau wie ein Unit-Test-Fehler behandelt und muss behoben werden, bevor der Code merged werden kann.

Für den CI-Workflow ist das Setup der Testdatenbank wichtig: Contract-Tests brauchen realistische Testdaten. Eine Datenbank-Seeding-Strategie mit fixen Seed-Daten – dieselbe Datenbankstruktur bei jedem CI-Lauf – stellt sicher, dass Contract-Tests deterministische Ergebnisse liefern. Schemathesis erzeugt zufällige Testfälle, was im Wiederholungsfall zu verschiedenen Testfällen führen kann. Das --seed-Flag von Schemathesis fixiert den Zufallsgenerator für reproduzierbare Runs in der CI-Pipeline.


# .github/workflows/contract-tests.yml
name: Contract Tests

on: [push, pull_request]

jobs:
  phpunit-contract-tests:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_DATABASE: api_test
          MYSQL_ROOT_PASSWORD: test
        ports: ["3306:3306"]
        options: --health-cmd="mysqladmin ping" --health-interval=5s

    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.4', extensions: pdo_mysql }
      - run: composer install --no-dev
      - run: php bin/console doctrine:migrations:migrate --no-interaction --env=test
      - run: php bin/console doctrine:fixtures:load --no-interaction --env=test
      - run: vendor/bin/phpunit tests/Contract/ --log-junit reports/phpunit-contract.xml

  schemathesis-contract-tests:
    runs-on: ubuntu-latest
    needs: phpunit-contract-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v4
        with: { python-version: '3.12' }
      - run: pip install schemathesis
      - name: Start API server (from previous job or Docker)
        run: |
          # Start the API server with test data loaded
          php -S localhost:8080 public/index.php &
          sleep 3
      - name: Run Schemathesis contract tests
        run: |
          schemathesis run api/openapi.yaml \
            --url http://localhost:8080 \
            --checks not_a_server_error,response_schema_conformance,content_type_conformance \
            --seed 42 \
            --junit-xml reports/schemathesis.xml \
            --header "Authorization: Bearer ${ { secrets.TEST_API_TOKEN } }"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: contract-test-reports
          path: reports/

8. Vergleich: Schemathesis vs. Dredd vs. PHPUnit-Contract-Tests

Alle drei Ansätze prüfen die Übereinstimmung zwischen API-Implementierung und Spezifikation, aber mit verschiedenen Stärken und Schwächen. Die Wahl hängt von der gewünschten Testtiefe, der Spec-Qualität und dem Team-Stack ab.

Kriterium Schemathesis Dredd PHPUnit-Contract
Testgenerierung Property-Based (automatisch) Beispiel-basiert (aus Spec) Manuell geschrieben
Randfall-Abdeckung Sehr hoch (Fuzzing) Niedrig (nur Beispiele) Manuell definiert
Business-Logic Nicht geprüft Nicht geprüft Kombinierbar
Datenbankkontext Externer Server nötig Externer Server nötig In-Process, volle Kontrolle
Wartungsaufwand Niedrig (aus Spec) Niedrig (aus Spec) Höher (manuell)

Die empfohlene Kombination: Schemathesis für breite, automatische Schema-Validierung aller Endpoints in der CI-Pipeline; PHPUnit-Contract-Tests für kritische Endpoints, bei denen Business-Logic und Schema-Konformität gemeinsam geprüft werden sollen. Dredd als Ergänzung, wenn die Spec sehr vollständige Beispiele enthält und der Test-Output für jedes Beispiel einzeln sichtbar sein soll.

Mironsoft

REST-API Contract-Testing, OpenAPI-Beratung und CI/CD-Integration

Contract-Testing für eure REST-API einführen?

Wir implementieren Schemathesis, Dredd oder PHPUnit-Contract-Tests für eure OpenAPI-Spec und integrieren sie in die CI-Pipeline – damit Spec und Implementierung dauerhaft synchron bleiben.

Schemathesis-Setup

Property-Based Contract-Tests aus OpenAPI-Spec in CI integrieren

PHPUnit-Contract-Tests

Schema-Validierung in bestehende PHPUnit-Testsuite integrieren

CI/CD-Pipeline

Contract-Tests als Merge-Gate in GitHub Actions oder GitLab CI

9. Zusammenfassung

Contract-Testing für REST-APIs stellt automatisch sicher, dass Backend-Implementierung und OpenAPI-Spezifikation konsistent bleiben. Schemathesis erzeugt durch property-basiertes Testen automatisch Hunderte von Testfällen aus der Spec und deckt Randbedingungen auf, die manuell nicht geschrieben werden. Dredd führt jeden Beispiel aus der Spec gegen die echte API aus und ist ideal, wenn die Spec mit vollständigen Beispielen gepflegt wird. PHPUnit-Contract-Tests kombinieren Business-Logic-Assertions mit Schema-Validierung in einem Testlauf.

Die Integration in die CI-Pipeline als Merge-Gate stellt sicher, dass Contract-Verstöße nicht in Produktion gelangen. Eine OpenAPI-Spezifikation, die durch automatische Contract-Tests geschützt ist, ist eine verlässliche Grundlage für Mock-Server, TypeScript-Client-Generierung und API-Design-Reviews – weil alle wissen, dass sie die Realität korrekt abbildet.

Contract-Testing für REST-APIs — Das Wichtigste auf einen Blick

Schemathesis

Property-Based Testing aus OpenAPI-Spec. Automatisch Hunderte Testfälle. --checks all, --stateful=links, --seed für reproduzierbare CI-Runs.

PHPUnit-Contract-Tests

AbstractContractTestCase mit JSON-Schema-Validator. Business-Logic und Schema-Validierung im selben Test. In-Process, volle Datenbankhoheit.

Response-Validierung

league/openapi-psr7-validator als Middleware oder Event-Listener im Testumfeld. Alle Tests prüfen implizit Schema-Konformität.

CI-Integration

Als Merge-Gate in GitHub Actions. Eigener Job parallel zu Unit-Tests. Fehlschlag blockiert Merge. Reports als JUnit-XML.

10. FAQ: Contract-Testing für REST-APIs

1Contract-Testing vs. Unit-Tests?
Contract-Tests prüfen Schnittstelle: Entspricht die Antwort dem Schema? Unit-Tests prüfen Geschäftslogik: Gibt die API die richtigen Daten zurück? Beide notwendig, beide ergänzen sich.
2Was ist Schemathesis?
Property-Based-Testing-Tool für OpenAPI. Generiert automatisch Testfälle aus der Spec, ruft die API auf und validiert Antworten. Findet Randbedingungen: MAX_INT, leere Strings, Unicode in URLs.
3Schemathesis vs. Dredd?
Schemathesis generiert automatisch durch Property-Based Testing. Dredd führt Spec-Beispiele aus. Schemathesis findet mehr Randbedingungen, Dredd ist vorhersehbarer und braucht vollständige Beispiele.
4Schema-Validierung in PHPUnit?
league/openapi-psr7-validator oder justinrainbow/json-schema. Abstrakte Testklasse lädt OpenAPI-Spec, assertMatchesSchema() validiert Response-Daten. Konkrete Tests erben davon.
5Consumer-Driven Contracts – wann sinnvoll?
In Microservice-Architekturen mit vielen Services und Consumers. Für Monolithe mit einem Frontend sind spec-driven Contract-Tests einfacher und ausreichend.
6Schemathesis in CI reproduzierbar machen?
--seed 42 fixiert den Zufallsgenerator. Gleiche Testfälle bei jedem CI-Run. Ohne --seed können verschiedene Runs verschiedene Fehler finden – nützlich für explorative Tests, nicht für deterministische Pipelines.
7Welche Schemathesis-Checks aktivieren?
Minimal: not_a_server_error, response_schema_conformance, content_type_conformance. Erweitert: status_code_conformance und --stateful=links für Lebenszyklussequenzen.
8Contract-Tests ohne laufende Datenbank?
Ja, gegen Mock-Server. Aber das prüft nur Spec-Konsistenz, nicht API-Contract-Testing. Echte Contract-Tests brauchen die Implementierung gegen eine Testdatenbank.
9Was zeigt Schemathesis bei einem Fehler?
Exakter Request (Method, URL, Headers, Body) und fehlerhafte Response. Mit schemathesis replay ist der Testfall reproduzierbar. Zeigt ob 5xx oder Schema-Abweichung der Fehler ist.
10Testinfrastruktur minimal halten?
Fixe Seed-Datenbankstrategie: Migrationen und Fixtures zu Beginn des CI-Jobs laden. PHPUnit-Contract-Tests mit Symfony WebTestCase laufen in-process ohne externe Infrastruktur.