Contract Testing APIs
Contract Testing für APIs
Contract Testing stellt sicher, dass API-Provider und -Consumer kompatibel bleiben. Lernen Sie Consumer-Driven Contracts mit Pact zu implementieren.
Das Problem
┌─────────────────────────────────────────────────────────────┐
│ INTEGRATION TESTING PROBLEM │
├─────────────────────────────────────────────────────────────┤
│ │
│ Consumer (Frontend) Provider (API) │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Tests ✓ │ │ Tests ✓ │ │
│ │ Build ✓ │ │ Build ✓ │ │
│ │ Deploy ✓ │ │ Deploy ✓ │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ │ Production │ │
│ │ 💥 │ │
│ └──────────┴───────────────┘ │
│ │
│ PROBLEM: Beide Tests sind grün, aber zusammen │
│ funktioniert es nicht! │
│ │
│ URSACHE: │
│ • Consumer erwartet: { "user_name": "John" } │
│ • Provider liefert: { "userName": "John" } │
│ │
└─────────────────────────────────────────────────────────────┘
Contract Testing Lösung
┌─────────────────────────────────────────────────────────────┐
│ CONTRACT TESTING │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. CONSUMER generiert Contract │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Consumer Test: │ │
│ │ "Wenn ich GET /users/1 aufrufe, │ │
│ │ erwarte ich { user_name: string, email: string }" │ │
│ │ │ │
│ │ → Generiert: contract.json (Pact File) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 2. CONTRACT wird geteilt (Pact Broker) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ┌────────────────┐ │ │
│ │ │ Pact Broker │ │ │
│ │ │ (Contract DB) │ │ │
│ │ └────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 3. PROVIDER verifiziert Contract │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Provider Test: │ │
│ │ "Erfülle ich alle Contracts meiner Consumer?" │ │
│ │ │ │
│ │ → Testet echte API gegen Contract-Erwartungen │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Consumer-Driven Contracts mit Pact (JavaScript)
// CONSUMER SEITE
// userService.test.js - Consumer Test
const { Pact } = require('@pact-foundation/pact');
const { fetchUser } = require('./userService');
describe('User Service', () => {
const provider = new Pact({
consumer: 'Frontend',
provider: 'UserAPI',
port: 1234,
log: './logs/pact.log',
dir: './pacts',
});
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
describe('get user by id', () => {
it('returns user when user exists', async () => {
// ARRANGE: Erwartung definieren
await provider.addInteraction({
state: 'user with id 1 exists',
uponReceiving: 'a request for user 1',
withRequest: {
method: 'GET',
path: '/api/users/1',
headers: {
Accept: 'application/json',
},
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json',
},
body: {
id: 1,
name: 'John Doe',
email: 'john@example.com',
},
},
});
// ACT: Consumer Code ausführen
const user = await fetchUser(1);
// ASSERT
expect(user).toEqual({
id: 1,
name: 'John Doe',
email: 'john@example.com',
});
});
it('returns 404 when user not found', async () => {
await provider.addInteraction({
state: 'user with id 999 does not exist',
uponReceiving: 'a request for non-existent user',
withRequest: {
method: 'GET',
path: '/api/users/999',
headers: {
Accept: 'application/json',
},
},
willRespondWith: {
status: 404,
body: {
error: 'User not found',
},
},
});
await expect(fetchUser(999)).rejects.toThrow('User not found');
});
});
});
// userService.js - Der Consumer Code
const API_BASE = process.env.API_URL || 'http://localhost:1234';
async function fetchUser(id) {
const response = await fetch(`${API_BASE}/api/users/${id}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
return response.json();
}
module.exports = { fetchUser };
Provider Verification
// PROVIDER SEITE
// providerVerification.test.js
const { Verifier } = require('@pact-foundation/pact');
const app = require('./app'); // Express App
describe('Pact Verification', () => {
let server;
beforeAll((done) => {
server = app.listen(3000, done);
});
afterAll((done) => {
server.close(done);
});
it('validates the expectations of the consumer', async () => {
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:3000',
provider: 'UserAPI',
// Pact Files laden
pactUrls: ['./pacts/frontend-userapi.json'],
// Oder von Pact Broker
// pactBrokerUrl: 'https://your-broker.pactflow.io',
// pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// Provider States Handler
stateHandlers: {
'user with id 1 exists': async () => {
// Test-Daten in DB einfügen
await db.users.create({
id: 1,
name: 'John Doe',
email: 'john@example.com',
});
},
'user with id 999 does not exist': async () => {
// Sicherstellen dass User nicht existiert
await db.users.deleteWhere({ id: 999 });
},
},
// Provider States aufräumen
beforeEach: async () => {
await db.users.truncate();
},
});
await verifier.verifyProvider();
});
});
Pact mit Matching
const { Matchers } = require('@pact-foundation/pact');
const { like, eachLike, term, integer, uuid } = Matchers;
await provider.addInteraction({
state: 'users exist',
uponReceiving: 'a request for all users',
withRequest: {
method: 'GET',
path: '/api/users',
},
willRespondWith: {
status: 200,
body: {
// Array mit mindestens einem Element
users: eachLike({
// Typ-Matching statt exakter Werte
id: integer(1),
name: like('John Doe'),
email: term({
matcher: '.*@.*\\..*',
generate: 'john@example.com',
}),
uuid: uuid('ce118b6e-d8e1-11e7-9296-cec278b6b50a'),
createdAt: like('2024-01-15T10:30:00Z'),
}),
pagination: {
page: integer(1),
totalPages: integer(5),
totalItems: integer(100),
},
},
},
});
// Matchers:
// like() - Typ muss übereinstimmen, nicht Wert
// eachLike() - Array mit Elementen dieses Typs
// term() - Regex-Matching
// integer() - Integer-Typ
// uuid() - UUID-Format
// boolean() - Boolean-Typ
Pact Broker
# Docker Compose für Pact Broker
version: '3'
services:
pact-broker:
image: pactfoundation/pact-broker
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: postgres://postgres:password@postgres/pact_broker
PACT_BROKER_BASIC_AUTH_USERNAME: admin
PACT_BROKER_BASIC_AUTH_PASSWORD: admin
depends_on:
- postgres
postgres:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: pact_broker
// Contracts zum Broker publizieren
// package.json scripts
{
"scripts": {
"pact:publish": "pact-broker publish ./pacts --consumer-app-version=$npm_package_version --broker-base-url=$PACT_BROKER_URL --broker-token=$PACT_BROKER_TOKEN"
}
}
// In CI/CD
npm run pact:publish
// Provider Verification vom Broker
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:3000',
provider: 'UserAPI',
pactBrokerUrl: process.env.PACT_BROKER_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
publishVerificationResult: true,
providerVersion: process.env.GIT_SHA,
});
Can-I-Deploy
# Vor Deployment prüfen: Sind alle Contracts erfüllt?
# Consumer fragen: Kann ich deployen?
pact-broker can-i-deploy \
--pacticipant Frontend \
--version 1.2.3 \
--to production \
--broker-base-url $PACT_BROKER_URL
# Provider fragen: Kann ich deployen?
pact-broker can-i-deploy \
--pacticipant UserAPI \
--version 2.0.0 \
--to production \
--broker-base-url $PACT_BROKER_URL
# Exit Code 0 = Ja, alle Contracts verifiziert
# Exit Code 1 = Nein, Breaking Changes
# In CI/CD Pipeline
- name: Can I Deploy?
run: |
pact-broker can-i-deploy \
--pacticipant $SERVICE_NAME \
--version ${{ github.sha }} \
--to production
CI/CD Integration
# .github/workflows/consumer.yml
name: Consumer CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run Pact tests
run: npm test
- name: Publish Pacts
run: |
npm run pact:publish
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
- name: Can I Deploy?
run: |
pact-broker can-i-deploy \
--pacticipant Frontend \
--version ${{ github.sha }} \
--to production
# .github/workflows/provider.yml
name: Provider CI
on:
push:
workflow_dispatch:
inputs:
pact_url:
description: 'Pact URL to verify (webhook trigger)'
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Start Provider
run: npm start &
- name: Verify Pacts
run: npm run pact:verify
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
PACT_URL: ${{ github.event.inputs.pact_url }}
- name: Can I Deploy?
run: |
pact-broker can-i-deploy \
--pacticipant UserAPI \
--version ${{ github.sha }} \
--to production
Contract Testing vs Integration Testing
| Aspekt | Contract Testing | Integration Testing |
|---|---|---|
| Geschwindigkeit | Schnell (isoliert) | Langsam (echte Services) |
| Unabhängigkeit | Teams können unabhängig testen | Alle Services müssen laufen |
| Feedback | Sofort bei Contract-Bruch | Erst bei Integration |
| Scope | API-Schnittstelle | End-to-End Verhalten |
| Ersetzt | Nein, ergänzt | - |
💡 Best Practices:
1. Consumer-First: Consumer definiert was er braucht
2. Matchers statt exakte Werte für Flexibilität
3. Pact Broker für zentrales Contract-Management
4. can-i-deploy vor jedem Deployment
5. Provider States für testbare Szenarien
2. Matchers statt exakte Werte für Flexibilität
3. Pact Broker für zentrales Contract-Management
4. can-i-deploy vor jedem Deployment
5. Provider States für testbare Szenarien
Weitere Informationen
- 🧪 TDD
- 🏋️ Load Testing
- 🌐 API Design