Contract Testing Across Services with Pact
Contract Testing Across Services with Pact
The Failure
The catalog team renamed the price field to unitPrice in their API response. They updated their OpenAPI spec, their own tests passed, and they deployed to production on a Tuesday afternoon.
The checkout service still expected price. Every checkout request failed with a null pointer exception because response.price was undefined. The team rolled back catalog-service, coordinated the field rename across both services, and deployed again two days later.
An OpenAPI spec catches structural changes if someone reads it. A contract test catches structural changes automatically, in CI, before the image is built.
The Mechanism
Consumer-Driven Contracts
In consumer-driven contract testing, the consumer defines what it expects from the provider. The consumer writes tests that say: “When I send a GET request to /api/products/123, I expect a response with fields id, name, price, and currency.”
These expectations are recorded as a “pact” (a JSON file) and published to a Pact broker. The provider runs verification tests against these pacts to prove that its API satisfies the consumer’s expectations.
The flow:
- Consumer CI runs consumer contract tests → generates pact file → publishes to Pact broker
- Pact broker sends a webhook to the provider repository
- Provider CI runs verification tests against the consumer’s pact → reports results to the broker
- Consumer CI runs
can-i-deploycheck before promotion → broker confirms the provider has verified the pact
The E-Commerce Contract Map
| Consumer | Provider | Contract Scope |
|---|---|---|
| checkout-service | catalog-service | Product lookup: GET /api/products/:id → {id, name, price, currency} |
| checkout-service | inventory-service | Stock reservation: POST /api/reservations → {reservationId, status, expiresAt} |
| checkout-service | payments-service | Payment charge: POST /api/charges → {chargeId, status, amount} |
| frontend-shell | catalog-service | Product listing: GET /api/products?category=:cat → {items[], total, page} |
| frontend-shell | checkout-service | Cart operations: POST /api/cart/checkout → {orderId, status} |
Five contracts. Each consumer defines its expectations. Each provider verifies against all consumers.
The Implementation
Consumer Test: Checkout → Catalog
// checkout-service/tests/contract/catalog.pact.test.js
const { PactV4, MatchersV3 } = require("@pact-foundation/pact");
const { like, integer, string, decimal } = MatchersV3;
const provider = new PactV4({
consumer: "checkout-service",
provider: "catalog-service",
});
describe("Catalog API Contract", () => {
it("returns product details for a valid product ID", async () => {
await provider
.addInteraction()
.given("product 42 exists")
.uponReceiving("a request for product 42")
.withRequest("GET", "/api/products/42")
.willRespondWith(200, (builder) => {
builder.jsonBody({
id: integer(42),
name: string("Wireless Mouse"),
price: decimal(29.99),
currency: string("USD"),
inStock: like(true),
});
})
.executeTest(async (mockServer) => {
const response = await fetch(`${mockServer.url}/api/products/42`);
const product = await response.json();
expect(response.status).toBe(200);
expect(product.id).toBe(42);
expect(product.price).toBeDefined();
expect(product.currency).toBeDefined();
});
});
it("returns 404 for a non-existent product", async () => {
await provider
.addInteraction()
.given("product 99999 does not exist")
.uponReceiving("a request for non-existent product")
.withRequest("GET", "/api/products/99999")
.willRespondWith(404, (builder) => {
builder.jsonBody({
error: string("Product not found"),
code: string("PRODUCT_NOT_FOUND"),
});
})
.executeTest(async (mockServer) => {
const response = await fetch(`${mockServer.url}/api/products/99999`);
expect(response.status).toBe(404);
});
});
});
Consumer Test: Checkout → Inventory
// checkout-service/tests/contract/inventory.pact.test.js
const { PactV4, MatchersV3 } = require("@pact-foundation/pact");
const { like, string, iso8601DateTimeWithMillis } = MatchersV3;
const provider = new PactV4({
consumer: "checkout-service",
provider: "inventory-service",
});
describe("Inventory API Contract", () => {
it("reserves stock for a product", async () => {
await provider
.addInteraction()
.given("product 42 has 10 units in stock")
.uponReceiving("a stock reservation request")
.withRequest("POST", "/api/reservations", (builder) => {
builder.jsonBody({
productId: 42,
quantity: 1,
orderId: "order-abc-123",
});
})
.willRespondWith(201, (builder) => {
builder.jsonBody({
reservationId: string("res-xyz-789"),
status: string("RESERVED"),
expiresAt: iso8601DateTimeWithMillis("2024-03-15T10:30:00.000Z"),
});
})
.executeTest(async (mockServer) => {
const response = await fetch(`${mockServer.url}/api/reservations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
productId: 42,
quantity: 1,
orderId: "order-abc-123",
}),
});
const reservation = await response.json();
expect(response.status).toBe(201);
expect(reservation.status).toBe("RESERVED");
expect(reservation.expiresAt).toBeDefined();
});
});
});
CI Pipeline: Consumer Side
# HARDENED: Consumer contract tests with Pact broker publishing
contract-test:
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v4
- name: Run consumer contract tests
run: |
docker run --rm \
-v $PWD/pacts:/app/pacts \
${{ env.IMAGE }}@${{ needs.build.outputs.image-digest }} \
npm run test:contract
- name: Publish pacts to broker
if: github.ref == 'refs/heads/main'
env:
PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
npx @pact-foundation/pact-cli \
publish pacts/ \
--consumer-app-version=${{ github.sha }} \
--branch=${{ github.ref_name }} \
--tag=${{ github.ref_name }}
- name: Can I deploy?
if: github.ref == 'refs/heads/main'
env:
PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
npx @pact-foundation/pact-cli \
can-i-deploy \
--pacticipant=checkout-service \
--version=${{ github.sha }} \
--to-environment=production
CI Pipeline: Provider Side (Webhook-Triggered)
# HARDENED: Provider verification triggered by Pact broker webhook
# catalog-service/.github/workflows/verify-pacts.yml
name: verify-pacts
on:
repository_dispatch:
types: [pact-changed]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start catalog-service
run: |
docker compose -f docker-compose.test.yml up -d --wait
echo "Waiting for service to be ready..."
docker compose -f docker-compose.test.yml exec -T catalog \
curl --retry 10 --retry-delay 2 --retry-connrefused \
http://localhost:8080/health
- name: Verify pacts
env:
PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
npx @pact-foundation/pact-cli \
verify \
--provider=catalog-service \
--provider-base-url=http://localhost:8080 \
--provider-version=${{ github.sha }} \
--provider-branch=main \
--publish-verification-results \
--pact-broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--pact-broker-token=${{ secrets.PACT_BROKER_TOKEN }}
- name: Cleanup
if: always()
run: docker compose -f docker-compose.test.yml down -v
Pact Broker Webhook Configuration
Configure the Pact broker to trigger provider verification when a consumer publishes a new pact:
{
"events": [{ "name": "contract_content_changed" }],
"request": {
"method": "POST",
"url": "https://api.github.com/repos/acme/catalog-service/dispatches",
"headers": {
"Authorization": "Bearer ${GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json"
},
"body": {
"event_type": "pact-changed",
"client_payload": {
"consumer": "${pactbroker.consumerName}",
"provider": "${pactbroker.providerName}",
"pactUrl": "${pactbroker.pactUrl}"
}
}
}
}
The Gate
The can-i-deploy check is the contract gate. It queries the Pact broker to verify that all contracts between the consumer (at the current version) and all its providers (at their production versions) have been successfully verified.
If the catalog service has not verified the latest checkout pact, can-i-deploy fails and the checkout service cannot be promoted. This prevents deploying a consumer that depends on API changes the provider has not confirmed.
$ pact-cli can-i-deploy \
--pacticipant checkout-service \
--version abc123 \
--to-environment production
Computer says no ¯\_(ツ)_/¯
CONSUMER | C.VERSION | PROVIDER | P.VERSION | SUCCESS?
checkout-service | abc123 | catalog-service | def456 | true
checkout-service | abc123 | inventory-service | ghi789 | true
checkout-service | abc123 | payments-service | jkl012 | FAILED
The verification between checkout-service (abc123) and
payments-service (jkl012) has failed.
The pipeline stops. The checkout team sees exactly which provider contract is broken and can coordinate with the payments team before deploying.
The Recovery
Provider verification fails after a consumer publishes a new pact: The consumer expects something the provider does not offer. Two paths:
- The consumer’s expectation is wrong — fix the consumer test
- The consumer needs a new feature — the provider team implements it, verifies the pact, and both services deploy
Provider wants to remove a field: Run can-i-deploy for the provider to see which consumers depend on that field. Coordinate with those teams. Use the expand-contract pattern (CH9): add the new field, migrate consumers, then remove the old field.
Pact broker is down: Contract tests still run locally (they generate pacts against a mock server). Publishing and can-i-deploy fail. The pipeline stops at the gate. Do not skip the gate. Fix the broker or wait for it to recover. A 30-minute broker outage is cheaper than a production contract break.
Too many contracts to maintain: This is a sign that service boundaries are wrong. If service A has contracts with 8 other services, service A is doing too much. Refactor the API or introduce an API gateway that consolidates the contract surface.