Introduction
In a microservices architecture, services communicate through APIs. When one service changes its contract, it can break dependent services. Contract testing provides a way to verify that APIs remain compatible without requiring full integration environments. This guide covers contract testing fundamentals, implementation patterns, and best practices for modern distributed systems.
Understanding Contract Testing
What is a Contract?
A contract defines the agreement between a service provider (API) and its consumers (clients). This includes:
- Request Structure: Headers, query parameters, body schema
- Response Structure: Status codes, headers, response body
- Behavior: Expected interactions and edge cases
Why Contract Testing?
Traditional integration testing requires running all services together, which becomes impractical as systems grow. Contract testing offers:
- Fast Feedback: Test independently without full environment
- Early Detection: Find breaking changes before deployment
- Isolation: Each team can work independently
- Documentation: Contracts serve as living API documentation
Contract Testing vs Integration Testing
| Aspect | Integration Testing | Contract Testing |
|---|---|---|
| Environment | Full system required | Isolated |
| Speed | Slow | Fast |
| Scope | End-to-end | Per-service |
| Dependencies | All services running | Mock/stub |
| Failure Impact | System-wide | Single service |
Consumer-Driven Contracts
The CDC Pattern
Consumer-driven contracts (CDC) flip the traditional approach. Instead of providers defining their API, consumers define what they need:
- Consumer writes tests defining expected behavior
- Tests generate contracts describing requirements
- Provider verifies it meets all consumer contracts
- Shared contract becomes the API specification
Benefits of CDC
- Consumers drive API evolution
- Providers know exactly what’s being used
- Early feedback on breaking changes
- Reduced coupling between teams
Pact: Consumer-Driven Contract Testing
Setting Up Pact
// consumer/package.json
{
"dependencies": {
"@pact-foundation/pact": "^12.0.0",
"@pact-foundation/pact-node": "^10.17.0"
}
}
Writing Consumer Tests
// consumer/test/booking-service.spec.js
const { Pact } = require('@pact-foundation/pact');
const { like, eachLike, regex } = require('@pact-foundation/pact').Matchers;
const axios = require('axios');
describe('Booking Service Consumer', () => {
const provider = new Pact({
consumer: 'booking-frontend',
provider: 'booking-api',
port: 8080,
log: path.resolve(process.cwd(), 'logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'pacts'),
logLevel: 'INFO',
});
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
describe('when getting bookings', () => {
beforeAll(() =>
provider.addInteraction({
state: 'there are bookings',
uponReceiving: 'a request for all bookings',
withRequest: {
method: 'GET',
path: '/api/bookings',
headers: {
'Accept': 'application/json',
'Authorization': regex({
generate: 'Bearer token123',
matcher: '^Bearer .+'
})
}
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': regex({
generate: 'application/json',
matcher: '^application/json'
})
},
body: eachLike({
id: like('booking-123'),
customerName: like('John Doe'),
checkIn: like('2026-03-15'),
checkOut: like('2026-03-20'),
roomType: like('suite'),
totalPrice: like(500.00),
status: like('confirmed')
})
}
})
);
it('should return a list of bookings', async () => {
const response = await axios.get('http://localhost:8080/api/bookings', {
headers: { 'Authorization': 'Bearer token123' }
});
expect(response.status).toBe(200);
expect(Array.isArray(response.data)).toBe(true);
expect(response.data[0]).toHaveProperty('id');
expect(response.data[0]).toHaveProperty('customerName');
});
});
});
Publishing Contracts
// scripts/publish-contracts.js
const { Publisher } = require('@pact-foundation/pact-node');
const publisher = new Publisher({
pactBroker: 'https://pact-broker.example.com',
pactFilesOrDirs: ['./pacts'],
consumerVersion: '1.0.0',
tags: ['main', 'production']
});
publisher.publish()
.then(() => console.log('Contracts published successfully'))
.catch(err => console.error('Failed to publish:', err));
Provider Verification
Setting Up Provider Tests
// provider/test/booking-api.spec.js
const { Verifier } = require('@pact-foundation/pact-node');
const { bootstrap } = require('./test-helpers');
describe('Booking API Provider', () => {
const verifier = new Verifier({
provider: 'booking-api',
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://pact-broker.example.com',
consumerVersionSelectors: [
{ mainBranch: true },
{ deployedOrReleased: true }
],
enablePending: true,
publishVerificationResult: true,
providerVersion: '1.2.3'
});
beforeAll(async () => {
await bootstrap(); // Start the API
});
it('should validate the expectations of booking-frontend', async () => {
const output = await verifier.verify();
console.log('Verification result:', output);
});
});
Handling Provider State
// provider/test/provider-states.js
const { stateHandler } = require('@pact-foundation/pact-node');
const providerStates = {
'there are bookings': async () => {
await db.bookings.insert([
{ id: 'booking-123', customerName: 'John Doe', ... },
{ id: 'booking-456', customerName: 'Jane Smith', ... }
]);
},
'there are no bookings': async () => {
await db.bookings.delete({});
},
'booking exists': async () => {
await db.bookings.insert({
id: 'booking-123',
customerName: 'John Doe',
checkIn: '2026-03-15',
checkOut: '2026-03-20',
status: 'confirmed'
});
},
'booking is cancelled': async () => {
await db.bookings.update(
{ id: 'booking-123' },
{ $set: { status: 'cancelled' } }
);
}
};
// Register states with the verifier
module.exports = providerStates;
Spring Cloud Contract
Provider Side (Java)
// pom.xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<scope>test</scope>
</dependency>
// src/test/java/com/example/booking/ContractTest.java
package com.example.booking;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner;
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.RestTemplate;
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;
@AutoConfigureStubRunner(
ids = "com.example:booking-api:+:stubs:8080",
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
public class ContractTest {
@Test
public void validate_booking_contract() {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<Booking> response = restTemplate.getForEntity(
"http://localhost:8080/api/bookings/123",
Booking.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getId()).isEqualTo("123");
}
}
Consumer Side (Java)
// src/test/java/com/example/booking/client/BookingClientContractTest.java
package com.example.booking.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.contract.wiremock.WireMockSpring;
import com.github.tomakehurst.wiremock.client.WireMock;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
public class BookingClientContractTest {
@Test
public void test_get_booking() {
// Configure mock
stubFor(get(urlEqualTo("/api/bookings/123"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"id": "123",
"customerName": "John Doe",
"checkIn": "2026-03-15",
"checkOut": "2026-03-20",
"totalPrice": 500.00,
"status": "confirmed"
}
""")
));
// Test client
BookingClient client = new BookingClient("http://localhost:8080");
Booking booking = client.getBooking("123");
assertThat(booking.getId()).isEqualTo("123");
assertThat(booking.getCustomerName()).isEqualTo("John Doe");
}
}
Contract Testing Best Practices
1. Write Meaningful Contracts
// GOOD: Specific, meaningful interactions
provider.addInteraction({
state: 'customer has active booking',
uponReceiving: 'request to cancel booking',
withRequest: {
method: 'POST',
path: '/api/bookings/123/cancel',
headers: { 'Authorization': 'Bearer valid-token' }
},
willRespondWith: {
status: 200,
body: {
bookingId: '123',
status: 'cancelled',
refundAmount: like(450.00),
refundDate: like('2026-03-09')
}
}
});
// BAD: Generic, vague contracts
provider.addInteraction({
uponReceiving: 'some request',
willRespondWith: { status: 200 }
});
2. Handle Pagination
// Consumer contract for paginated response
provider.addInteraction({
state: 'there are multiple bookings',
uponReceiving: 'request for paginated bookings',
withRequest: {
method: 'GET',
path: '/api/bookings',
queryParameters: {
page: '0',
size: '20'
}
},
willRespondWith: {
status: 200,
body: {
content: eachLike({
id: like('booking-1'),
customerName: like('John')
}),
page: like(0),
size: like(20),
totalElements: like(100),
totalPages: like(5)
}
}
});
3. Version Contracts
const contractVersion = '2.0.0';
describe(`Booking API v${contractVersion}`, () => {
const provider = new Pact({
consumer: `booking-frontend-v${contractVersion}`,
provider: `booking-api-v${contractVersion}`,
// ... config
});
// Tests use versioned contracts
});
CI/CD Integration
GitHub Actions Workflow
# .github/workflows/contracts.yml
name: Contract Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
consumer-contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run consumer tests
run: npm run test:pact
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
- name: Publish contracts
run: npm run publish:pacts
provider-contracts:
runs-on: ubuntu-latest
needs: consumer-contracts
steps:
- uses: actions/checkout@v4
- name: Build application
run: ./mvnw clean package -DskipTests
- name: Run provider verification
run: ./mvnw verify
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
Troubleshooting
Common Issues
| Issue | Solution |
|---|---|
| Flaky tests | Use provider states consistently |
| Missing interactions | Check interaction order |
| Version conflicts | Ensure compatible Pact versions |
| Timeout errors | Increase timeout settings |
| Port conflicts | Use dynamic port allocation |
Debugging Tips
// Enable verbose logging
const provider = new Pact({
log: path.resolve(process.cwd(), 'logs', 'pact.log'),
logLevel: 'DEBUG',
// ... other config
});
// Or run with debug
// DEBUG=* npm test
Conclusion
Contract testing is essential for maintaining reliable microservices architectures. By implementing consumer-driven contracts with tools like Pact, teams can verify API compatibility independently and catch breaking changes early. The key is to start small, write meaningful contracts, and integrate them into your CI/CD pipeline.
Remember that contract testing complements but doesn’t replace integration testing. Use contract tests for rapid feedback and integration tests for end-to-end validation.
Comments