abshoff.dev: Web, Mobile & Cloud!

Contract-Testing mit Prism und jest-openapi

Bücher in einer Bibliothek

Mit Contract-Testing erhöhst Du die Softwarequalität bei der Entwicklung von REST-APIs und überprüfst die Einhaltung der Schnittstellenbeschreibung. In diesem Beitrag zeige ich Dir, wie Du mit dem Werkzeug Prism und dem Plugin jest-openapi für das Testing-Framework Jest automatisiert gegen eine OpenAPI-Spezifikation testen und so mit wenig Aufwand frühzeitig Fehler entdecken kannst.

Was ist Contract-Testing?

Eine OpenAPI-Definition spezifiziert eine REST-Schnittstelle zwischen Schnittstellenanbieter und -konsument in Bezug auf die Ein- und Ausgabeformate. Der Anbieter stellt sicher, dass die Schnittstelle der Spezifikation entspricht, und erwartet seinerseits, dass der Konsument die Schnittstelle in der definiterten Weise anspricht. Die API-Definition wird so Teil einer verbindlichen Vereinbarung, zu einem Kontrakt, zwischen Anbieter und Konsument. Unter Contract-Testing versteht man die Prüfung der beidseitigen Einhaltung dieses Kontrakts.

In der Test-Pyramide befinden wir uns thematisch irgendwo zwischen der Service- bzw. Integrationstest- und der Unit-Tests-Ebene.

Test-Pyramide

API-Definition und Beispiel-Server

Um beide Testansätze zu zeigen, habe ich ein kleines Beispiel für eine Invoice-API erstellt. Diese habe ich in der Datei openapi.yaml abgelegt. Es gibt einen Endpunkt, der über HTTP-POST eine Rechnung mit Adresse und Rechnungspositionen annimmt und anschließend die Rechnung mit einer fortlaufenden Rechnungsnummer zurückgeben soll.

openapi: 3.1.0
info:
  title: contract-testing
  version: "1.0"
  contact:
    name: Sebastian Abshoff
  description: API for contract testing
servers:
  - url: "http://localhost:3000"
paths:
  /invoices:
    post:
      summary: ""
      operationId: post-invoices
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
      description: Example endpoint to create an invoice.
      tags:
        - example
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Invoice"
    parameters: []
components:
  schemas:
    Invoice:
      type: object
      title: Invoice
      additionalProperties: false
      properties:
        id:
          type: string
          minLength: 10
          maxLength: 10
          example: "5100000000"
          pattern: "^[0-9]{10}$"
          readOnly: true
        address:
          $ref: "#/components/schemas/Address"
        positions:
          type: array
          minItems: 1
          items:
            $ref: "#/components/schemas/Position"
      required:
        - id
        - address
        - positions
    Address:
      type: object
      title: Address
      description: ""
      additionalProperties: false
      properties:
        name:
          type: string
          example: Max Mustermann
        supplement:
          type: string
          example: c/o Musterverein
        street:
          type: string
          example: Heidestraße 17
        zip:
          type: string
          example: "51147"
          pattern: "^[0-9]{5}$"
          minLength: 5
          maxLength: 5
        city:
          type: string
          example: "Köln"
      required:
        - name
        - street
        - zip
        - city
    Position:
      type: object
      title: Position
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          example: Software as a Service
        quantity:
          type: integer
          minimum: 0
          example: 1
        unit:
          type: string
          example: Monat
        price:
          type: number
          example: 1000
        currency:
          type: string
          example: EUR
        tax:
          type: integer
          example: 19
          minimum: 0
      required:
        - name
        - quantity
        - unit
        - price
        - currency
        - tax
tags:
  - name: example

Diese API kannst Du zum Beispiel mit Express wie folgt implementieren.

npm init
npm install express body-parser

Der API in app.js nimmt die Anfrage Rechnung entgegen und vergibt zu Demonstrationszwecken die gleiche Rechnung mit einer festen Rechnungsnummer zurück.

const express = require("express");
const bodyParser = require("body-parser");

const app = express();
const jsonParser = bodyParser.json();

app.post("/invoices", jsonParser, (req, res) => {
  res.status(201).json({
    ...req.body,
    id: "5100000000",
  });
});

module.exports = app;

Der Server lässt sich über die Datei server.js mit node server.js starten.

const app = require("./app");
const port = 3000;

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});

Prism

Prism ist neben Stoplight Studio ein weiteres Werkzeug für die Arbeit mit OpenAPI, das von Stoplight.io entwickelt worden ist. Es verfügt über zwei Funktionen, einem Mock-Server und einem Validation-Proxy. Der Mock-Server stellt Dir allein auf Basis der OpenAPI-Definition einen einfachen Demo-Service bereit, gegen den Du entwickeln kannst.

npx @stoplight/prism-cli mock openapi.yaml --port 3000

Wenn Du den Mock-Server wie oben angegeben startest, erhältst Du mit dem folgenden Kommandozeilenprogram curl eine Beispielantwort.

curl \
  -X POST 'http://127.0.0.1:3000/invoices' \
  -H 'Content-Type: application/json' \
  -d '{
  "address": {
    "name": "Max Mustermann",
    "street": "Heidestraße 17",
    "zip": "51147",
    "city": "Köln"
  },
  "positions": [
    {
      "name": "Software as a Service",
      "quantity": 1,
      "unit": "Monat",
      "price": 1000,
      "currency": "EUR",
      "tax": 19
    }
  ]
}'

Die Antwort sieht so aus und wird von Prism allein anhand der Beispiele der API-Definition zusammengesetzt.

{
  "id": "5100000000",
  "address": {
    "name": "Max Mustermann",
    "supplement": "c/o Musterverein",
    "street": "Heidestraße 17",
    "zip": "51147",
    "city": "Köln"
  },
  "positions": [
    {
      "name": "Software as a Service",
      "quantity": 1,
      "unit": "Monat",
      "price": 1000,
      "currency": "EUR",
      "tax": 19
    }
  ]
}

Die Proxy-Funktion funktioniert ähnlich. Als Argument übergibst Du wieder die zugrundelegende OpenAPI-Definition gefolgt von der Adresse des implementierenden Services (in diesem Fall der Mock-Server oder auch die Express-Beispielimplmentierung). Mit --error sorgst Du dafür, dass fehlerhafte Aufrufe bzw. Antworten im Proxy mit einem Fehler abgewiesen werden. Durch Angabe von --port 3001 definierst Du den Port, unter dem der Proxy erreichbar ist.

npx @stoplight/prism-cli proxy openapi.yaml http://127.0.0.1:3000 \
  --errors --port 3001

Übergibt man z. B. keine Rechnungspositionen wie es in der API-Defintion gefordert ist, quittiert der Proxy die Anfrage mit einem Fehler.

curl \
  -X POST 'http://127.0.0.1:3001/invoices' \
  -H 'Content-Type: application/json' \
  -d '{
  "address": {
    "name": "Max Mustermann",
    "street": "Heidestraße 17",
    "zip": "51147",
    "city": "Köln"
  },
  "positions": []
}'

Die Antwort sieht bei dieser Anfrage so aus:

{
  "type": "https://stoplight.io/prism/errors#UNPROCESSABLE_ENTITY",
  "title": "Invalid request",
  "status": 422,
  "detail": "Your request is not valid and no HTTP validation response was found in the spec, so Prism is generating this error for you.",
  "validation": [
    {
      "location": ["body", "positions"],
      "severity": "Error",
      "code": "minItems",
      "message": "must NOT have fewer than 1 items"
    }
  ]
}

Jest und jest-openapi

Jest ist ein Framework, mit dem sich Unit-Tests realisieren lassen. Das Plugin jest-openapi integriert die Prüfung von OpenAPI. Das Modul supertest ermöglicht es Express-basierte Server zu testen.

npm install jest jest-openapi supertest --save-dev

Ein einfacher Test, den wir unter app.test.js ablegen, sieht z. B. so aus:

const request = require("supertest");
const jestOpenAPI = require("jest-openapi").default;

const app = require("./app.js");

jestOpenAPI(`${process.cwd()}/openapi.yaml`);

describe("Invoice API", () => {
  it("creates an invoice", async () => {
    const res = await request(app)
      .post("/invoices")
      .send({
        address: {
          name: "Max Mustermann",
          street: "Heidestraße 17",
          zip: "51147",
          city: "Köln",
        },
        positions: [
          {
            name: "Software as a Service",
            quantity: 1,
            unit: "Monat",
            price: 1000,
            currency: "EUR",
            tax: 19,
          },
        ],
      });
    expect(res).toSatisfyApiSpec();
  });
});

Den Test kannst Du mit npx jest ausführen:

 PASS  ./app.test.js
  Invoice API
    ✓ creates an invoice (88 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.543 s, estimated 2 s
Ran all test suites.

Entfernt man beispielsweise die Rückgabe der Rechnungsnummer in der Express-Implementierung, erhält man folgendes Testresultat.

 FAIL  ./app.test.js
  Invoice API
    ✕ creates an invoice (94 ms)

  ● Invoice API › creates an invoice

    expect(received).toSatisfyApiSpec() // Matches 'received' to a response defined in your API spec, then validates 'received' against it

    expected received to satisfy the '201' response defined for endpoint 'POST /invoices' in your API spec

    received did not satisfy it because: response must have required property 'id'

    received contained: {
      body: {
        address: {
          name: 'Max Mustermann',
          street: 'Heidestraße 17',
          zip: '51147',
          city: 'Köln'
        },
        positions: [
          {
            name: 'Software as a Service',
            quantity: 1,
            unit: 'Monat',
            price: 1000,
            currency: 'EUR',
            tax: 19
          }
        ]
      }
    }

    The '201' response defined for endpoint 'POST /invoices' in API spec: {
      '201': {
        description: 'Created',
        content: {
          'application/json': { schema: { '$ref': '#/components/schemas/Invoice' } }
        }
      }
    }

      28 |                 ]
      29 |             });
    > 30 |         expect(res).toSatisfyApiSpec();
         |                     ^
      31 |     });
      32 | });
      33 |

      at Object.<anonymous> (app.test.js:30:21)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        1.55 s, estimated 2 s
Ran all test suites.

Fazit

Prism lässt sich sehr leicht und komplett unabhängig von der verwendeten Programmiersprache einbinden, sodass es nur sinnvoll ist diese Möglichkeit in automatisierten Test-Pipelines zu nutzen. Es ist sogar möglich, Prism in Integrationsumgebungen einzubauen und die Nutzung von APIs laufend zu analysieren.

Das Plugin jest-openapi erleichtert die Prüfung im Unit-Test und Du sparst den Aufwand das Schema der Antworten manuell zu prüfen.

Mit Contract-Testing kannst Du mit geringem Aufwand und früh im Entwicklungsprozess Fehler und Diskrepanzen feststellen. Meiner Meinung nach gehört Contract-Testing in jede Test-Pipeline einer REST-API.

Tags: