Contract-Testing mit Prism und jest-openapi
Photo by Giammarco on Unsplash
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.
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: