Contract Testing for Microservices#
Integration tests verify that services work together, but they require all services to be running. In a system with 20 services, running full integration tests is slow, brittle, and blocks deployments. Contract testing solves this by verifying service interactions independently – each service tests against a contract instead of against a live instance of every dependency.
The Problem with Integration and E2E Tests#
A traditional integration test for “order service calls payment service” requires both services running, along with their databases, message brokers, and any other dependencies. Problems:
- Slow feedback. Spinning up 5+ services takes minutes. Running this on every PR is impractical.
- Flaky. Any service or infrastructure instability fails the test, even if the change under test is correct.
- Ownership confusion. When the test breaks, who fixes it? The team that changed their service or the team that owns the test?
- Deployment coupling. You cannot deploy the order service without also deploying whatever version of the payment service the integration tests expect.
Contract tests decouple this by testing each side of an interaction independently.
Consumer-Driven Contracts with Pact#
Pact is the most widely adopted contract testing framework. The core idea: the consumer (client) defines what it expects from the provider (server), and both sides verify independently.
Consumer Side#
The consumer writes a test that describes what it needs from the provider:
// order-service/tests/payment-contract.test.js
const { PactV4 } = require('@pact-foundation/pact');
const pact = new PactV4({
consumer: 'OrderService',
provider: 'PaymentService',
});
describe('Payment Service Contract', () => {
it('charges a payment', async () => {
await pact
.addInteraction()
.given('a valid payment method exists')
.uponReceiving('a charge request')
.withRequest('POST', '/api/charges', (builder) => {
builder
.headers({ 'Content-Type': 'application/json' })
.jsonBody({
amount: 9950,
currency: 'USD',
payment_method_id: 'pm_test_123',
});
})
.willRespondWith(201, (builder) => {
builder.jsonBody({
charge_id: like('ch_abc123'),
status: 'succeeded',
amount: 9950,
});
})
.executeTest(async (mockServer) => {
const client = new PaymentClient(mockServer.url);
const result = await client.charge(9950, 'USD', 'pm_test_123');
expect(result.status).toBe('succeeded');
});
});
});This test generates a pact file (JSON contract) describing the expected interaction. The consumer tests run against a mock server that Pact spins up – no real Payment Service needed.
Provider Side#
The provider verifies it can fulfill the contracts written by all its consumers:
// payment-service/tests/pact-verification.test.js
const { Verifier } = require('@pact-foundation/pact');
describe('Payment Service Provider Verification', () => {
it('fulfills OrderService contract', async () => {
const verifier = new Verifier({
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://pact-broker.internal.company.com',
provider: 'PaymentService',
providerVersion: process.env.GIT_SHA,
publishVerificationResult: true,
stateHandlers: {
'a valid payment method exists': async () => {
// Set up test data so the provider can handle the expected request
await db.insert('payment_methods', { id: 'pm_test_123', valid: true });
},
},
});
await verifier.verifyProvider();
});
});The provider runs its real service and replays the interactions from the pact file against it. If the real responses match the contract, verification passes.
Pact Broker#
Pact files need to be shared between consumer and provider teams. The Pact Broker stores contracts, tracks verification status, and enables deployment safety checks.
# Consumer publishes pact after tests pass
pact-broker publish ./pacts \
--consumer-app-version=$(git rev-parse HEAD) \
--broker-base-url=https://pact-broker.internal.company.com \
--tag=$(git branch --show-current)
# Before deploying, check if it's safe (can-i-deploy)
pact-broker can-i-deploy \
--pacticipant=OrderService \
--version=$(git rev-parse HEAD) \
--to-environment=productioncan-i-deploy checks whether the current version of your service has a verified contract with every service it interacts with in the target environment. This is the deployment gate – if contracts are not verified, deployment is blocked.
CI Integration#
# .github/workflows/contract-tests.yml
jobs:
consumer-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:contract
- run: |
pact-broker publish ./pacts \
--consumer-app-version=${{ github.sha }} \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--tag=${{ github.ref_name }}
can-i-deploy:
needs: consumer-tests
runs-on: ubuntu-latest
steps:
- run: |
pact-broker can-i-deploy \
--pacticipant=OrderService \
--version=${{ github.sha }} \
--to-environment=productionOn the provider side, set up a webhook in Pact Broker to trigger provider verification whenever a new consumer pact is published. This creates a feedback loop: consumer publishes contract, provider automatically verifies, results flow back to the broker, can-i-deploy checks pass or fail.
Schema Compatibility Testing#
For event-driven services communicating via Avro or Protobuf, contract testing means schema compatibility testing.
Avro Schema Compatibility#
Confluent Schema Registry enforces compatibility rules:
# Set compatibility mode for a subject
curl -X PUT http://schema-registry:8081/config/order-events-value \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
-d '{"compatibility": "BACKWARD"}'
# Test compatibility before registering
curl -X POST http://schema-registry:8081/compatibility/subjects/order-events-value/versions/latest \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
-d '{"schema": "{\"type\":\"record\",\"name\":\"OrderEvent\",\"fields\":[...]}"}'
# Returns: {"is_compatible": true}Protobuf Compatibility#
Use buf to check for breaking changes:
# buf.yaml
version: v2
breaking:
use:
- FILE # Detect breaking changes at the file level# Check for breaking changes against the main branch
buf breaking --against '.git#branch=main'
# Output:
# payment/v1/payment.proto:12:3: Field "3" on message "ChargeRequest"
# changed type from "int32" to "string".Integrate this in CI to block PRs that introduce breaking schema changes.
Contract Tests vs Integration Tests vs E2E Tests#
Contract tests verify the interface between two services. They are fast (no real services needed), reliable (no infrastructure dependencies), and run on every PR. They catch: API shape changes, missing fields, type mismatches, incompatible schema evolution.
Integration tests verify that services actually work together. They are slower but catch: serialization bugs, network behavior, configuration mismatches, database interaction issues. Run these less frequently – nightly or on release branches.
E2E tests verify complete user workflows across the entire system. They are the slowest and most brittle. Reserve them for critical business paths and run them in staging, not on every PR.
The testing pyramid for microservices: many contract tests (fast, per-PR), fewer integration tests (slower, nightly), very few E2E tests (slowest, pre-release).
When to Use Contract Tests#
Use contract tests when: multiple teams own different services that interact, you deploy services independently, API breakage has caused production incidents, or your integration test suite is too slow and flaky.
Skip contract tests when: you have a small team that owns all services, services rarely change their APIs, or you have fewer than 3 services. The overhead of maintaining Pact Broker and contract test infrastructure is not worth it for a handful of services owned by one team.