TDD und BDD für APIs: Webanwendungen testen, die miteinander reden
Kurz gesagt
Niveau: Fortgeschritten
- Zwei Apps können jede für sich alle Tests bestehen und zusammen trotzdem brechen, weil niemand die Vereinbarung zwischen ihnen testet.
- Mocks sind schnell, driften aber. Tests von Ende zu Ende sind echt, aber langsam und wackelig. Contract Tests liegen dazwischen.
- Mit Pact schreiben die Tests des Consumers einen Vertrag über das, was er wirklich nutzt, und der Provider spielt ihn vor jedem Deploy gegen seine echte App ab.
- Request Specs (Rails) und APITestCase (Django) legen deine eigene API fest, einschließlich Autorisierung und Fehlercodes. rswag (Rails) und der Schemagenerator für DRF machen aus der API eine OpenAPI Dokumentation.
- In Schichten denken: Unit Tests, Request Specs, Verträge, ein paar Integrationstests, Monitoring.
Zwei Anwendungen können jede für sich jeden einzelnen ihrer Tests bestehen und trotzdem scheitern, sobald sie miteinander reden. Hier lernst du, die Vereinbarung zwischen ihnen zu testen: Request Specs in Rails und Django, OpenAPI Dokumentation aus Tests und Consumer Driven Contract Testing mit Pact, durchgespielt an einem erfundenen Kölner Shop und seinem Lager in Leipzig.
Zwei Apps, ein Vertrag, und niemand prüft ihn
Wer schon an mehr als einer Anwendung gearbeitet hat, kennt diese Lage. Zwei Webanwendungen müssen miteinander reden. Vielleicht ein Shop mit einem Zahlungsdienst. Vielleicht eine Hauptplattform, die Bestellungen an ein Lagersystem schickt. Wie auch immer es aufgebaut ist: Es gibt zwei getrennte Codebasen, oft zwei getrennte Teams, und dazwischen einen API Vertrag. Bricht dieser Vertrag, brechen beide Seiten mit.
Ich habe jahrelang Systeme gebaut, in denen sich mehrere Anwendungen über APIs abstimmen. In diesem Artikel geht es darum, warum Testen zwischen Anwendungen etwas anderes ist als Testen innerhalb einer, um die vier Ansätze, die es dafür gibt, darum, wie du die Tests in Ruby on Rails und in Django schreibst, wie Contract Testing mit Pact funktioniert und warum gute API Tests am Ende die verlässlichste Dokumentation sind, die du hast. Er baut auf TDD und BDD in Ruby on Rails auf, wo die Grundlagen der testgetriebenen Entwicklung stehen.
Warum Testen zwischen Anwendungen anders ist
Testest du eine Rails oder Django App für sich, hast du alles in der Hand. Models, Controller, Views und Tests liegen in einer Codebasis. Du änderst etwas, und die Testsuite sagt dir sofort, ob du etwas kaputt gemacht hast.
Ruft App A die API von App B auf, ist diese Gewissheit weg:
- Getrennte Releases. App A geht am Montag raus, App B am Donnerstag. Was am Montag lief, kann am Donnerstag brechen.
- Getrennte Teams. Die Leute an App A wissen vielleicht nicht, dass die Leute an App B etwas geändert haben. Die Nachricht kommt per Chat, per Mail oder als Fehler in der Produktion.
- Getrennte Testsuiten. Die Tests von App A laufen grün. Die Tests von App B laufen grün. Zusammen, in der Produktion, scheitern sie.
- Ein unzuverlässiges Netz. Ein Funktionsaufruf innerhalb einer App läuft nicht auf halber Strecke in einen Timeout. Ein API Aufruf schon.
- Alte Versionen. App A ruft vielleicht noch einen Endpunkt auf, den App B vor Monaten als veraltet markiert und nie entfernt hat.
All das habe ich schon Ausfälle in der Produktion verursachen sehen. Der schlimmste war eine Zahlungsintegration, bei der der Anbieter in seiner Antwort ein Feld von transaction_id in txn_id umbenannt hatte. Ihre Tests liefen grün. Unsere Tests liefen grün, weil wir ihre Antworten nach der alten Dokumentation gemockt hatten. Es dauerte drei Tage mit gescheiterten Zahlungen, bis es jemand merkte.
Beispiel: wie eine Umbenennung durchrutscht
Das Beispiel ist erfunden. Ehrenfelder Kaffee ist eine Kaffeebar in Köln, deren Onlineshop (Rails) Bestellungen an Plagwitz Fulfilment schickt, ein erfundenes Lager in Leipzig, dessen System in Django geschrieben ist. Wird eine Sendung angelegt, liest der Shop tracking_number aus der Antwort des Lagers und schickt sie der Kundin per Mail.
- Die Tests des Shops ersetzen das Lager durch einen Stub mit einer festen Antwort, die
tracking_numberenthält. Sie laufen grün. - Das Lager benennt das Feld in
tracking_codeum und passt seine eigenen Tests an. Sie laufen grün. - Das Lager deployt am Donnerstag. Nichts in beiden Pipelines hat die zwei Seiten verglichen.
- Ab Donnerstag liest der Shop
nilund schickt jeder Kundin einen leeren Trackinglink.
In jedem Schritt war jeder Test grün. In keiner der beiden Apps war etwas falsch. Kaputt war die Vereinbarung zwischen ihnen, und die Vereinbarung hat niemand getestet.
Vier Wege, eine Integration zu testen
Es gibt vier grobe Ansätze. Jeder hat seinen Platz, und die meisten Systeme in der Produktion kombinieren sie.
Integrationstests von Ende zu Ende
Beide Anwendungen starten, sie aufeinander zeigen lassen und Tests durch den ganzen Ablauf schicken. Das testet die echte Integration, findet Probleme, die ein Mock übersehen würde, und gibt viel Sicherheit, wenn es grün ist.
Die Kosten sind genauso echt. Es ist langsam, weil beide Anwendungen mit befüllten Datenbanken laufen müssen. Es ist wackelig, weil Netzaussetzer, Timing und liegengebliebene Testdaten Fehler erzeugen, die kommen und gehen. Integrationsumgebungen am Leben zu halten ist teuer. Und die Rückmeldung kommt spät, oft erst nach einem Deploy auf Staging. Nimm es als letzte Prüfung vor einem Release, nicht als Hauptstrategie.
Alles mocken
Das andere Extrem: Jede Anwendung ersetzt die API der anderen durch einen Stub und testet für sich. Das ist schnell, verlässlich und läuft auf jedem Laptop.
Der Haken ist die Drift. Der Mock sagt, die API liefert user_id, die echte API liefert userId, deine Tests sind grün, und die Produktion scheitert. Alles sieht gut aus, während du gegen eine ausgedachte Fassung der anderen App testest. Mocks sind richtig für schnelle Unit Tests, nie als einzige Verteidigungslinie.
Contract Testing
Der Mittelweg: Einen Vertrag aufschreiben, dem beide Seiten zustimmen, und dann jede Seite für sich gegen diesen Vertrag testen. Das ist fast so schnell wie Mocken, findet brechende Änderungen vor einem Deploy und funktioniert über Sprachgrenzen hinweg, eine Rails App kann also einen Vertrag mit einer Django App haben.
Es braucht etwas Einarbeitung, es braucht einen Ort, an dem die Verträge geteilt werden, und es testet nur die Schnittstelle: Die Geschäftslogik jeder Seite braucht weiterhin ihre eigenen Tests. Für eine Integration zwischen Anwendungen, die du selbst in der Hand hast, ist es die richtige Hauptstrategie, mit ein paar Tests von Ende zu Ende obendrauf.
Consumer Driven Contracts
Eine bestimmte Spielart des Contract Testing, die Ian Robinson 2006 als Muster für die Weiterentwicklung von Services beschrieben hat.1 Der Consumer (die App, die die API aufruft) sagt, was er braucht. Der Provider (die App, die die API bereitstellt) weist nach, dass er es liefern kann.
Consumer prüfen nur, was sie wirklich nutzen. Ein Provider sieht deshalb genau, welche Änderung welchen Consumer brechen würde, und die Verträge selbst halten fest, wer wovon abhängt. Beide Teams müssen mitziehen, und mit vielen Consumern wächst der Abstimmungsaufwand. Die Pact Dokumentation sagt offen, wo der Ansatz nicht passt: bei öffentlichen APIs, deren Consumer sich nicht einzeln benennen lassen, und bei Integrationen, bei denen das andere Team Pact nicht nutzen will.2
Beispiel: drei von fünfzig Feldern
Angenommen, die Sendungsantwort von Plagwitz Fulfilment hat 50 Felder. Der Shop von Ehrenfelder Kaffee nutzt drei davon: shipment_id, status und tracking_number. Sein Consumer Vertrag nennt nur diese drei.
- Das Lager will
estimated_dispatchentfernen, ein Feld aus einer alten Funktion. Kein Consumer Vertrag erwähnt es, also läuft die Verifikation des Providers weiter grün. Das Feld kann gefahrlos weg. - Das Lager will
tracking_numberumbenennen. Der Vertrag von Ehrenfelder Kaffee erwähnt es, also scheitert die Verifikation, in der Pipeline des Lagers, vor dem Deploy. - Das Lagerteam weiß jetzt genau, welcher Consumer brechen würde, und kann mit diesem einen Team reden, statt allen eine Änderung anzukündigen.
Das ist der ganze Wert des Ansatzes in drei Schritten. Die Umbenennung aus dem ersten Beispiel wäre in Schritt zwei aufgefallen.
TDD für APIs in Rails
Ich zeige dir, wie ich API Tests in Rails angehe, Test zuerst, für eine API, die eine andere Anwendung nutzen wird. Request Specs sind das richtige Werkzeug: Sowohl das Rails als auch das RSpec Team raten davon ab, Controller direkt zu testen, und empfehlen Request Specs, die mit einem HTTP Request beginnen und mit der Antwort enden.3
# spec/requests/api/v1/orders_spec.rb
require "rails_helper"
RSpec.describe "Orders API", type: :request do
let(:user) { create(:user) }
let(:headers) { { "Authorization" => "Bearer #{user.api_token}", "Accept" => "application/json" } }
describe "GET /api/v1/orders/:id" do
let(:order) { create(:order, user: user, total_cents: 4999) }
it "returns the order details" do
get "/api/v1/orders/#{order.id}", headers: headers
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("application/json")
json = response.parsed_body
expect(json["id"]).to eq(order.id)
expect(json["total_cents"]).to eq(4999)
expect(json["status"]).to eq("pending")
end
it "includes the line items" do
create(:line_item, order: order, quantity: 2)
get "/api/v1/orders/#{order.id}", headers: headers
expect(response.parsed_body["line_items"].length).to eq(1)
expect(response.parsed_body["line_items"][0]["quantity"]).to eq(2)
end
it "rejects a request without a token" do
get "/api/v1/orders/#{order.id}"
expect(response).to have_http_status(:unauthorized)
end
it "answers 404 for someone else's order" do
stranger = create(:user)
get "/api/v1/orders/#{order.id}", headers: { "Authorization" => "Bearer #{stranger.api_token}" }
# 404, nicht 403, damit die Antwort nicht verrät, dass es die Bestellung gibt
expect(response).to have_http_status(:not_found)
end
end
describe "POST /api/v1/orders" do
let(:product) { create(:product, price_cents: 1999) }
let(:valid_params) do
{
order: {
line_items: [{ product_id: product.id, quantity: 2 }],
shipping_address: { line1: "Venloer Straße 1", city: "Köln", postcode: "50823" }
}
}
end
it "creates the order and works out the total" do
expect {
post "/api/v1/orders", params: valid_params, headers: headers, as: :json
}.to change(Order, :count).by(1)
expect(response).to have_http_status(:created)
expect(response.parsed_body["total_cents"]).to eq(3998)
end
it "refuses an order with no items" do
post "/api/v1/orders", params: { order: { line_items: [] } }, headers: headers, as: :json
expect(response).to have_http_status(422) # 422 Unprocessable Content
expect(response.parsed_body["errors"]).to include("line_items")
end
end
end
Der Test mit der fremden Bestellung ist wichtiger, als er aussieht. Eine API, die ein Objekt herausgibt, nur weil der Aufrufer seine id kennt, ist das erste Risiko der OWASP API Security Top 10, Broken Object Level Authorization.4 Der Status für eine gescheiterte Validierung ist 422, den HTTP heute "Unprocessable Content" nennt.5 Ich schreibe die Zahl in den Test, weil Rack 3.1 das Symbol dafür umbenannt und das alte als veraltet markiert hat.6
Beispiel: die Summe in Cent
Der Test sagt, zwei Stück eines Produkts zu 19,99 € ergeben 3998. Rechne es durch: 19,99 € sind 1.999 Cent, und 2 mal 1.999 sind 3.998 Cent, also 39,98 €. Die API schickt ganze Cent als Integer, nie eine Gleitkommazahl in Euro, aus demselben Grund, aus dem der TDD Artikel Geld als BigDecimal führt: Gleitkommazahlen können die meisten Dezimalbrüche nicht exakt speichern. Den Test zuerst zu schreiben hat diese Entscheidung erzwungen, bevor es überhaupt Code gab.
API Versionen testen
Hängen mehrere Anwendungen an deiner API, zählen Versionen. Ein Test pro Version legt fest, was jede verspricht:
# spec/requests/api/versioning_spec.rb
require "rails_helper"
RSpec.describe "API versions", type: :request do
let(:user) { create(:user) }
let(:order) { create(:order, user: user) }
let(:headers) { { "Authorization" => "Bearer #{user.api_token}" } }
it "keeps snake case keys in v1" do
get "/api/v1/orders/#{order.id}", headers: headers
expect(response.parsed_body).to include("total_cents", "created_at")
end
it "uses camel case keys and adds the delivery estimate in v2" do
get "/api/v2/orders/#{order.id}", headers: headers
expect(response.parsed_body).to include("totalCents", "createdAt", "estimatedDeliveryDate")
end
end
Der Test für v1 ist das Versprechen an jeden Consumer, der noch auf v1 ist. "Räumt" jemand v1 auf Camel Case "auf", scheitert er.
OpenAPI Dokumentation aus den Tests, mit rswag
OpenAPI ist das Standardformat, um eine HTTP API zu beschreiben. Es ist aus Swagger 2.0 hervorgegangen, das 2015 an die OpenAPI Initiative übergeben wurde, und die aktuelle Version ist 3.2.1.7 In Rails kannst du mit dem Gem rswag Request Specs in einer Form schreiben, die zugleich eine OpenAPI Datei erzeugt:
# spec/requests/api/v1/orders_openapi_spec.rb
require "swagger_helper"
RSpec.describe "Orders API", type: :request do
path "/api/v1/orders/{id}" do
get "Retrieves an order" do
tags "Orders"
produces "application/json"
parameter name: :id, in: :path, type: :integer
parameter name: "Authorization", in: :header, type: :string, required: true
response "200", "order found" do
schema type: :object,
properties: {
id: { type: :integer },
total_cents: { type: :integer },
status: { type: :string, enum: %w[pending confirmed shipped delivered] },
created_at: { type: :string, format: "date-time" }
},
required: %w[id total_cents status]
let(:user) { create(:user) }
let(:id) { create(:order, user: user).id }
let(:Authorization) { "Bearer #{user.api_token}" }
run_test!
end
response "404", "order not found" do
let(:user) { create(:user) }
let(:id) { 0 }
let(:Authorization) { "Bearer #{user.api_token}" }
run_test!
end
end
end
end
run_test! baut den Request, schickt ihn ab und prüft den tatsächlichen Statuscode und, wo es ein Schema gibt, den Antwortbody dagegen.8 rake rswag:specs:swaggerize schreibt danach aus denselben Specs die OpenAPI Datei.8 Weil die Specs grün sein müssen, beschreibt das Dokument, was die API wirklich tut. Eine Einstellung lohnt sich: Standardmäßig sind zusätzliche Felder in einer Antwort erlaubt, und openapi_no_additional_properties macht die Prüfung streng.8
Die Kosten: Die Specs werden mit dem ganzen Schema länger, die Schemasprache von OpenAPI braucht etwas Einarbeitung, und die Suite wird ein wenig langsamer, wenn du jeden Test so schreibst. Ich nehme rswag für die Endpunkte, die andere Teams nutzen, und einfache Request Specs für den Rest.
TDD für APIs in Django
Django hat ein eigenes, ausgereiftes Ökosystem rund um Django REST Framework (DRF). Dessen APITestCase gibt dir einen APIClient mit einer Methode force_authenticate für Tests.9 Dieselbe Bestell API, von der Seite des Lagers aus:
# tests/test_orders_api.py
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from orders.models import LineItem, Order
from products.models import Product
from users.models import User
class OrderApiTests(APITestCase):
def setUp(self):
self.user = User.objects.create_user(email="lea@example.com", password="a long test password")
self.client.force_authenticate(user=self.user)
self.product = Product.objects.create(name="Hausmischung 1kg", price_cents=1999)
def test_get_order_returns_details(self):
order = Order.objects.create(user=self.user, total_cents=4999, status="pending")
response = self.client.get(reverse("order-detail", kwargs={"pk": order.id}))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["total_cents"], 4999)
self.assertEqual(response.data["status"], "pending")
def test_get_order_includes_line_items(self):
order = Order.objects.create(user=self.user, total_cents=3998)
LineItem.objects.create(order=order, product=self.product, quantity=2, price_cents=1999)
response = self.client.get(reverse("order-detail", kwargs={"pk": order.id}))
self.assertEqual(response.data["line_items"][0]["quantity"], 2)
def test_get_order_without_auth_is_rejected(self):
self.client.force_authenticate(user=None)
order = Order.objects.create(user=self.user, total_cents=100)
response = self.client.get(reverse("order-detail", kwargs={"pk": order.id}))
# 401 bei Token Authentifizierung, 403 bei Session Authentifizierung
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_someone_elses_order_is_not_found(self):
stranger = User.objects.create_user(email="jonas@example.com", password="another long password")
order = Order.objects.create(user=stranger, total_cents=100)
response = self.client.get(reverse("order-detail", kwargs={"pk": order.id}))
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_create_order_works_out_the_total(self):
data = {
"line_items": [{"product_id": self.product.id, "quantity": 2}],
"shipping_address": {"line1": "Karl-Heine-Straße 1", "city": "Leipzig", "postcode": "04229"},
}
response = self.client.post(reverse("order-list"), data, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["total_cents"], 3998)
def test_create_order_without_items_fails(self):
response = self.client.post(reverse("order-list"), {"line_items": []}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("line_items", response.data)
Ein Detail ist anders als in Rails, und daran stolpern viele. Welchen Status ein Request ohne Anmeldung bekommt, hängt vom Authentifizierungsverfahren ab: DRF antwortet mit 401, wenn die erste Authentifizierungsklasse einen WWW-Authenticate Header schicken kann (Token Authentifizierung kann das), und mit 403, wenn nicht (Session Authentifizierung).10 Der Test oben geht von Token Authentifizierung aus. Entscheide, welches Verfahren du nutzt, und halte es in einem Test fest, denn Consumer gehen mit den beiden unterschiedlich um. Achte außerdem darauf, dass DRF eine gescheiterte Validierung mit 400 beantwortet, wo die Rails API 422 sagte. Keins von beiden ist falsch, aber ein Consumer, der mit beiden redet, muss es wissen, und genau so eine Tatsache hält ein Vertrag fest.
OpenAPI Dokumentation mit drf-spectacular
Das Django Gegenstück zu rswag ist drf-spectacular. Es erzeugt aus deinen DRF Views und Serializern ein OpenAPI Schema in Version 3.0.3, 3.1 oder 3.2.11
# orders/views.py
from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import status, viewsets
from rest_framework.response import Response
from .models import Order
from .serializers import OrderCreateSerializer, OrderSerializer
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
def get_queryset(self):
# Nur die eigenen Bestellungen des Aufrufers, damit besteht der 404 Test
return Order.objects.filter(user=self.request.user)
@extend_schema(
summary="Retrieve an order",
responses={200: OrderSerializer},
examples=[
OpenApiExample(
"An order",
value={"id": 123, "total_cents": 4999, "status": "pending"},
response_only=True,
)
],
)
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@extend_schema(summary="Create an order", request=OrderCreateSerializer, responses={201: OrderSerializer})
def create(self, request, *args, **kwargs):
serializer = OrderCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
order = serializer.save(user=request.user)
return Response(OrderSerializer(order).data, status=status.HTTP_201_CREATED)
Der Unterschied zu rswag liegt darin, woher die Wahrheit kommt. drf-spectacular beschreibt den Code (die Views und Serializer), das Schema folgt dem Code also von selbst. rswag beschreibt, was die Tests beobachtet haben. Das Schema von drf-spectacular kann deshalb eine Form behaupten, die die View gar nicht zurückgibt, wenn eine View ihren Serializer umgeht, und genau das fangen die Tests ab.
Contract Testing mit Pact
Jetzt der nützlichste Ansatz für APIs zwischen getrennten Anwendungen. Pact beschreibt sich selbst als Werkzeug, mit dem man HTTP und Nachrichtenintegrationen per Contract Tests im Code testet, und es ist consumer driven: Der Vertrag entsteht, während die eigenen Tests des Consumers laufen.12
Was Pact tut, Schritt für Schritt
- Der Consumer schreibt Tests gegen einen Pact Mock Server, die beschreiben, welche Requests er schicken wird und welche Antworten er erwartet.
- Diese Tests schreiben beim Laufen einen Vertrag, eine JSON Datei, die Pact heißt.
- Der Pact wird mit dem Provider geteilt, meist über einen Pact Broker.
- Der Provider spielt jeden Request im Pact gegen seine echte Anwendung ab und prüft, ob die Antworten passen. Vor jedem Request legt er die Daten an, von denen der Consumer ausging, den Provider State (zum Beispiel "ein Produkt mit der SKU HOUSE1KG existiert").13
- Besteht jede Interaktion, sind sich beide Seiten einig, ohne dass eine die andere starten musste.
Lies es von oben nach unten. Die Hälfte des Consumers endet, sobald der Pact veröffentlicht ist. Die Hälfte des Providers beginnt bei dieser Datei. Der letzte Kasten ist das Sicherheitsnetz: Der Broker führt eine Matrix, welche Versionen von Consumer und Provider gegeneinander verifiziert wurden, und sein Werkzeug can-i-deploy beantwortet, ob eine bestimmte Version sicher ausgerollt werden kann.14
Die Seite des Consumers, in Rails
Pact für Ruby ist inzwischen bei Version 2, gebaut auf dem gemeinsamen, in Rust geschriebenen Pact Kern, und unterstützt die neueste Pact Spezifikation. Die ältere DSL in reinem Ruby (Pact.service_consumer, Pact.like) gehört zu Version 1.15 Ein Consumer Test für den Lagerclient des Shops im Stil von Version 2:
# spec/pact/providers/plagwitz_fulfilment/shipments_spec.rb
require "pact/rspec"
RSpec.describe "Shipments client", :pact do
has_http_pact_between "ehrenfelder-shop", "plagwitz-fulfilment", opts: { mock_port: 3001 }
let(:client) { WarehouseClient.new(base_url: "http://localhost:3001", token: "test-token") }
let(:interaction) { new_interaction }
context "when creating a shipment" do
let(:interaction) do
super()
.given("a product exists", sku: "HOUSE1KG")
.upon_receiving("a request to create a shipment")
.with_request(
method: :post,
path: "/api/v1/shipments",
headers: { "Content-Type" => "application/json", "Authorization" => match_any_string("Bearer test-token") },
body: {
order_id: match_any_integer(4812),
items: match_each({ sku: match_any_string("HOUSE1KG"), quantity: match_any_integer(2) }),
shipping_address: { postcode: match_any_string("50823"), country: "DE" }
}
)
.will_respond_with(
status: 201,
body: {
shipment_id: match_any_string("SHP4812"),
status: "pending",
tracking_number: match_any_string("TRK000123")
}
)
end
it "returns the shipment with its tracking number" do
interaction.execute do
result = client.create_shipment(
order_id: 4812,
items: [{ sku: "HOUSE1KG", quantity: 2 }],
shipping_address: { postcode: "50823", country: "DE" }
)
expect(result.tracking_number).to eq("TRK000123")
end
end
end
context "when the shipment does not exist" do
let(:interaction) do
super()
.given("no shipment exists", shipment_id: "MISSING")
.upon_receiving("a request for a missing shipment")
.with_request(method: :get, path: "/api/v1/shipments/MISSING")
.will_respond_with(status: 404, body: { error: match_any_string("Shipment not found") })
end
it "raises a not found error" do
interaction.execute do
expect { client.shipment("MISSING") }.to raise_error(WarehouseClient::NotFound)
end
end
end
end
Auf die Matcher kommt es an. match_any_string("TRK000123") heißt "irgendein String, zum Beispiel dieser", der Provider darf also jede Trackingnummer liefern, muss unter genau diesem Schlüssel aber einen String liefern. status: "pending" ohne Matcher heißt genau diesen Wert. Der Vertrag hält nur die drei Antwortfelder fest, die der Shop nutzt, das Beispiel mit den drei von fünfzig Feldern als Code.
Die Seite des Providers, in Django
Das Lager verifiziert den Pact gegen seine echte Django Anwendung. In aktuellen Versionen von Pact für Python wird der Verifier Schritt für Schritt aufgebaut: wo der Provider läuft, woher die Pacts kommen, wie Provider States angelegt werden und wohin die Ergebnisse gehen.16
# tests/test_pact_provider.py
import os
import pytest
from pact import Verifier
from orders.models import Product, Shipment
def product_exists(action, parameters):
if action == "setup":
Product.objects.get_or_create(sku=parameters["sku"], defaults={"name": "Hausmischung 1kg", "stock": 100})
def no_shipment_exists(action, parameters):
if action == "setup":
Shipment.objects.filter(shipment_id=parameters["shipment_id"]).delete()
@pytest.mark.django_db(transaction=True)
def test_provider_honours_consumer_pacts(live_server):
verifier = (
Verifier("plagwitz-fulfilment")
.add_transport(url=live_server.url)
.broker_source("https://pact-broker.plagwitzfulfilment.example", token=os.environ["PACT_BROKER_TOKEN"])
.state_handler({"a product exists": product_exists, "no shipment exists": no_shipment_exists}, teardown=True)
)
if "CI" in os.environ:
verifier.set_publish_options(version=os.environ["GIT_COMMIT"], branch=os.environ["GIT_BRANCH"])
verifier.verify()
Die State Handler laufen im selben Prozess wie der Provider (hier lässt live_server Django in einem Thread des Testprozesses laufen), was die Pact Dokumentation verlangt, wenn Handler direkt auf die Datenbank zugreifen.16 Die Provider States sind parametrisiert (sku, shipment_id), statt die Werte in den Namen zu schreiben, wie es die Pact Dokumentation empfiehlt.16
Beispiel: die Umbenennung, abgefangen
Spiel die Geschichte von vorhin noch einmal durch, diesmal mit Pact. Das Lager benennt tracking_number in tracking_code um und pusht die Änderung.
- Die Pipeline des Lagers führt
test_provider_honours_consumer_pactsaus. Der Verifier holt den Pact von Ehrenfelder Kaffee aus dem Broker. - Er legt "a product exists" mit der SKU HOUSE1KG an und spielt den POST Request gegen die echte App ab.
- Die Antwort enthält
tracking_codeund keintracking_number. Der Pact sagt,tracking_numbermuss ein String sein. Die Verifikation scheitert. - Der Build des Lagers wird auf dem Branch rot, bevor irgendetwas deployt ist, mit einer Meldung, die den Consumer und den fehlenden Schlüssel nennt.
- Hätte jemand den Deploy trotzdem erzwungen, hätte
can-i-deploynein gesagt, weil diese Version des Providers keine erfolgreiche Verifikation gegen die Version des Consumers in der Produktion hat.
Aus drei Tagen kaputter Trackingmails wird ein einziger roter Build auf der Seite des Lagers.
Die Stärken von Pact: Es funktioniert über Sprachgrenzen hinweg, es findet brechende Änderungen vor dem Deploy, und Consumer legen nur fest, was sie nutzen. Die Kosten: echte Einarbeitung, ein Broker, den jemand betreiben muss (Open Source oder gehostet), Provider States, die Pflege brauchen, wenn sie mehr werden, und keine Abdeckung der Geschäftslogik, nur der Schnittstelle.
Was schiefgehen kann
Drift der Mocks
Du ersetzt die andere API durch einen Stub. Es funktioniert. Monate vergehen. Die API ändert sich, deine Stubs nicht, deine Tests bleiben grün, und die Produktion bricht. Die Lösung ist Contract Testing oder zumindest, ab und zu echte Antworten aus der Sandbox des Providers aufzuzeichnen und mit deinen Stubs zu vergleichen.
Falsche Sicherheit durch Staging
Die Integrationstests laufen auf Staging grün. Du deployst, und die Produktion scheitert. Staging hat andere Daten, andere Last und eine andere Konfiguration. Ein Integrationstest beweist, dass der Code funktionieren kann, nicht, dass er es tun wird. Integrationstests sind notwendig, aber nicht hinreichend: Kombinier sie mit Contract Tests, gründlichen Request Specs und Monitoring in der Produktion.
Nur den Gutfall testen
Was passiert, wenn die andere API mit 500 antwortet, in einen Timeout läuft oder etwas schickt, das kein JSON ist? Mit WebMock kann ein Stub jedes davon tun, einschließlich to_timeout:17
# spec/services/warehouse_client_spec.rb
require "rails_helper"
RSpec.describe WarehouseClient do
let(:url) { "https://api.plagwitzfulfilment.example/api/v1/shipments" }
let(:client) { WarehouseClient.new(base_url: "https://api.plagwitzfulfilment.example", token: "t") }
let(:order) { create(:order) }
it "reports the provider being down" do
stub_request(:post, url).to_return(status: 500, body: "Internal Server Error")
result = client.create_shipment_for(order)
expect(result).to be_failure
expect(result.error).to eq("Warehouse API unavailable")
end
it "reports a timeout" do
stub_request(:post, url).to_timeout
expect(client.create_shipment_for(order).error).to eq("Warehouse API timeout")
end
it "reports a response that is not JSON" do
stub_request(:post, url).to_return(status: 201, body: "not json")
expect(client.create_shipment_for(order).error).to eq("Invalid response from Warehouse API")
end
end
Wie sich der Shop verhalten sollte, wenn das Lager langsam oder down ist, mit Retries in einem Background Job und einem kurzen Timeout, steht in Background Jobs in Rails.
Gegen APIs in der Produktion testen
Ich habe Teams gesehen, die ihre Testsuiten gegen echte APIs in der Produktion laufen ließen. Dabei entstehen womöglich echte Bestellungen, Belastungen oder Sendungen. Deine Tests hängen an der Verfügbarkeit eines anderen. Du kannst in Rate Limits laufen. Und du legst echte Zugangsdaten in dein CI System. Nimm die Sandbox des Providers, Verträge oder aufgezeichnete Antworten. Nie die Produktion.
Die Authentifizierung in Tests abschalten
# Gefährlich: der Test berührt die Authentifizierung überhaupt nicht mehr
before do
allow_any_instance_of(ApiController).to receive(:authenticate!).and_return(true)
end
Dieser Test läuft grün und sagt dir nichts darüber, ob die Authentifizierung funktioniert. Dann deployst du, und echte Clients kommen nicht mehr hinein. Leg echte Testtokens an und schick sie mit, so wie es die Request Specs oben tun.
API Tests als Dokumentation
Hier ist der Lohn für all das: Deine API Tests werden zur verlässlichsten Dokumentation, die du hast. Von Hand geschriebene API Dokumentation beginnt an dem Tag zu veralten, an dem sie erscheint. Jemand ändert einen Endpunkt und vergisst die Doku. Feldnamen driften. Pflichtparameter werden optional. Bald führt die Dokumentation aktiv in die Irre.
Aus Tests erzeugte Dokumentation kann so nicht driften. Sagt der Test, der Endpunkt liefert user_id, und er liefert userId, scheitert der Build. Dieselbe Idee steckt hinter Cucumber Feature Dateien als lebende Dokumentation, angewandt auf eine API.
In Rails mit rswag:
# Die OpenAPI Datei aus den Specs schreiben
bundle exec rake rswag:specs:swaggerize
In Django mit drf-spectacular:
# Das OpenAPI Schema schreiben
python manage.py spectacular --file schema.yml
# urls.py: das Schema und eine interaktive Swagger UI ausliefern
from django.urls import path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns = [
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
]
Ein Pact ist Dokumentation anderer Art: nicht, was die API anbietet, sondern worauf sich jeder Consumer tatsächlich verlässt. Ein Blick in den Broker des Lagers zeigt, dass der Kölner Shop von shipment_id, status und tracking_number abhängt und von nichts sonst. Das hat keine von Hand geschriebene Seite je jemandem gesagt.
Alles zusammen: eine Teststrategie in fünf Schichten
Aus jahrelanger Arbeit daran ergibt sich mein empfohlener Ansatz für APIs zwischen Anwendungen:
- Unit Tests. Serializer, Validatoren und Geschäftslogik für sich. Schnell, verlässlich, bei jedem Commit.
- Request Specs. Echte HTTP Requests an deine eigene API, externe Abhängigkeiten als Stub. Authentifizierung, Autorisierung, Validierung und Antwortformate. Bei jedem Commit.
- Contract Tests. Pact zwischen den Anwendungen, die du in der Hand hast, bei jedem Commit, und
can-i-deployblockiert ein Release, das einen Vertrag bricht. - Ein paar Integrationstests. Von Ende zu Ende auf Staging, vor großen Releases, nur für die kritischen Pfade.
- Monitoring in der Produktion. Fehlerraten, Antwortzeiten und gescheiterte Aufrufe, mit Alarmen. Das fängt, was Tests nicht fangen können.
Beispiel: eine Änderung durch alle Schichten
Ehrenfelder Kaffee führt Geschenknachrichten für Bestellungen ein, also ein neues optionales Feld gift_message, das ans Lager geht.
- Ein Unit Test prüft, dass die Nachricht getrimmt und auf 200 Zeichen begrenzt wird.
- Eine Request Spec prüft, dass die eigene API des Shops das Feld annimmt und zurückgibt.
- Der Consumer Pact bekommt
gift_messageim Sendungsrequest. Die Verifikation des Lagers scheitert, weil seine API unbekannte Felder ablehnt. Das ist genau das Gespräch, das die beiden Teams führen mussten, und es findet statt, bevor eine Seite deployt. - Sobald das Lager das Feld unterstützt, gibt ein Test auf Staging eine echte Geschenkbestellung durch beide Systeme.
- In der Produktion beobachtet ein Alarm in den ersten Tagen nach dem Release die Rate gescheiterter Sendungsaufrufe.
Jede Schicht hat eine andere Art von Problem gefangen oder stand bereit, es zu fangen. Unit Tests fangen Logikfehler, Contract Tests fangen unpassende Schnittstellen, Integrationstests fangen Probleme der Umgebung, und Monitoring fängt, was in der echten Welt passiert.
Die Tests sind die Vereinbarung
APIs zwischen getrennten Anwendungen zu testen ist etwas anderes, als innerhalb einer zu testen. Getrennte Releases, getrennte Teams und ein unzuverlässiges Netz verlangen eigene Strategien. Contract Testing, und vor allem Consumer Driven Contracts mit Pact, bieten die beste Balance aus Tempo und Sicherheit, gestützt von gründlichen Request Specs und einer kleinen Zahl von Integrationstests. Und obendrein werden die Tests zu einer Dokumentation, die stimmt, weil sie bei jedem Build läuft.
Der Aufwand zahlt sich jedes Mal aus, wenn eine brechende Änderung einen Build scheitern lässt statt die Bestellung einer Kundin, jedes Mal, wenn eine neue Entwicklerin eine API kennenlernt, indem sie ihre Tests liest, und jedes Mal, wenn du refaktorierst, ohne die Luft anzuhalten.
Footnotes
-
Ian Robinson, Consumer driven contracts: a service evolution pattern, martinfowler.com, 12. Juni 2006, https://martinfowler.com/articles/consumerDrivenContracts.html
-
Pact, What is Pact good for, Pact Dokumentation, https://docs.pact.io/getting_started/what_is_pact_good_for
-
RSpec, rspec-rails README (Request Specs), GitHub, https://github.com/rspec/rspec-rails
-
OWASP, API1:2023 Broken Object Level Authorization, OWASP API Security Top 10, 2023, https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
-
R. Fielding, M. Nottingham und J. Reschke, RFC 9110 HTTP Semantics, Abschnitt 15.5.21, IETF, 2022, https://www.rfc-editor.org/rfc/rfc9110.html
-
Rack, Changelog, Version 3.1.0 (Statusbeschreibung für 422 aktualisiert, veraltete Statussymbole als deprecated markiert), GitHub, 11. Juni 2024, https://github.com/rack/rack/blob/main/CHANGELOG.md
-
OpenAPI Initiative, OpenAPI Specification, aktuelle Version und Versionsgeschichte, https://spec.openapis.org/oas/latest.html
-
rswag, README (run_test!, swaggerize, openapi_no_additional_properties), GitHub, https://github.com/rswag/rswag
-
Django REST framework, Testing, https://www.django-rest-framework.org/api-guide/testing/
-
Django REST framework, Authentication, Unauthorized and Forbidden responses, https://www.django-rest-framework.org/api-guide/authentication/
-
T. Franzel, README des Projekts drf spectacular, GitHub, https://github.com/tfranzel/drf-spectacular
-
Pact, Introduction, Pact Dokumentation, https://docs.pact.io/
-
Pact, Provider states, Pact Dokumentation, https://docs.pact.io/getting_started/provider_states
-
Pact, Can I deploy, Pact Broker Dokumentation, https://docs.pact.io/pact_broker/can_i_deploy
-
Pact Foundation, pact-ruby README, GitHub, https://github.com/pact-foundation/pact-ruby
-
Pact Foundation, Pact Python, Provider testing, GitHub, https://github.com/pact-foundation/pact-python/blob/main/docs/provider.md
-
WebMock, README (to_timeout), GitHub, https://github.com/bblimke/webmock