Contract Testing: The Missing Link Between Unit and E2E Tests
How to catch breaking API changes before they reach production—without the overhead of full integration environments

You've got unit tests covering your business logic. You've got end-to-end tests validating user workflows. But somehow, your microservices still break in production when Service A changes its API and Service B didn't get the memo.
The testing pyramid has a blind spot: service boundaries. Unit tests can't verify that your HTTP client matches the actual API. Integration tests require spinning up multiple services, databases, and message queues—making them slow, flaky, and expensive to maintain.
Contract testing fills this gap. It validates that the producer's API matches what consumers expect, without requiring a full integration environment. Teams using contract testing report 60-80% faster test suites and catch breaking changes before they hit staging.
What Is Contract Testing?
A contract is a formal agreement defining how two services communicate. It specifies request/response formats, status codes, headers, and data structures. Unlike traditional integration tests that verify actual runtime behavior, contract tests verify that both sides honor their agreement—independently.
Here's the key insight: the consumer defines the contract based on what it needs. The provider verifies it can fulfill those needs. This consumer-driven approach prevents the common antipattern where providers add features consumers don't use, or break features consumers rely on.
Contract Testing vs. Integration Testing
- Integration tests: Require running all services, databases, and dependencies. Verify actual end-to-end behavior.
- Contract tests: Run in isolation using mock servers. Verify that service interfaces match expectations.
- When to use each: Use contract tests for API boundary validation. Use integration tests for complex multi-service workflows and data consistency checks.
The Consumer-Driven Contract Workflow
Let's walk through a realistic example: an order service (consumer) calling a payment service (provider). Here's the full workflow using Pact, the most popular contract testing framework:
Step 1: Consumer Defines the Contract
The order service writes a test describing what it expects from the payment API:
// order-service/tests/payment-contract.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const provider = new PactV3({
consumer: 'order-service',
provider: 'payment-service',
});
describe('Payment API Contract', () => {
it('processes a valid payment', async () => {
await provider
.given('account has sufficient funds')
.uponReceiving('a payment request')
.withRequest({
method: 'POST',
path: '/api/v1/payments',
headers: {
'Content-Type': 'application/json',
'Authorization': MatchersV3.like('Bearer token123'),
},
body: {
orderId: MatchersV3.uuid(),
amount: MatchersV3.decimal(99.99),
currency: 'EUR',
},
})
.willRespondWith({
status: 201,
headers: { 'Content-Type': 'application/json' },
body: {
transactionId: MatchersV3.uuid(),
status: 'completed',
timestamp: MatchersV3.iso8601DateTime(),
},
})
.executeTest(async (mockServer) => {
// Test your actual HTTP client against the mock
const client = new PaymentClient(mockServer.url);
const response = await client.processPayment({
orderId: '550e8400-e29b-41d4-a716-446655440000',
amount: 99.99,
currency: 'EUR',
});
expect(response.status).toBe('completed');
expect(response.transactionId).toMatch(/^[0-9a-f]{8}-/);
});
});
});When this test runs, Pact generates a contract file (order-service-payment-service.json) capturing the interaction. This file is your source of truth.
Step 2: Share the Contract
The consumer publishes the contract to a Pact Broker (a central registry) or shares it via a Git repository:
# Publish to Pact Broker
pact-broker publish \
./pacts \
--consumer-app-version=${GIT_COMMIT} \
--tag=main \
--broker-base-url=https://pact-broker.company.comStep 3: Provider Verifies the Contract
The payment service runs verification tests against the real API implementation:
// payment-service/tests/contract-verification.test.ts
import { Verifier } from '@pact-foundation/pact';
describe('Payment Service Contract Verification', () => {
it('honors all consumer contracts', async () => {
const verifier = new Verifier({
provider: 'payment-service',
providerBaseUrl: 'http://localhost:3001',
// Fetch contracts from broker
pactBrokerUrl: 'https://pact-broker.company.com',
consumerVersionSelectors: [
{ tag: 'main', latest: true },
{ deployedOrReleased: true },
],
// State management for test data
stateHandlers: {
'account has sufficient funds': async () => {
await db.accounts.create({
id: 'test-account',
balance: 1000.00,
});
},
},
// Verify requests match expectations
requestFilter: (req, res, next) => {
// Add auth headers if needed
req.headers['authorization'] = 'Bearer test-token';
next();
},
});
await verifier.verifyProvider();
});
});If the payment service's actual API doesn't match what the order service expects—wrong status code, missing field, incompatible type—the verification fails immediately. No deployment needed to catch the issue.
Spring Cloud Contract for Spring Boot Services
If your microservices use Spring Boot, Spring Cloud Contract offers tighter framework integration. It generates both consumer stubs and provider tests from a single DSL:
// payment-service/src/test/resources/contracts/processPayment.groovy
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "Process a valid payment"
request {
method POST()
url "/api/v1/payments"
headers {
contentType applicationJson()
header 'Authorization': 'Bearer token123'
}
body([
orderId: $(consumer(regex('[0-9a-f-]{36}')),
provider('550e8400-e29b-41d4-a716-446655440000')),
amount: $(consumer(regex('[0-9]+\\.[0-9]{2}')),
provider(99.99)),
currency: 'EUR'
])
}
response {
status 201
headers {
contentType applicationJson()
}
body([
transactionId: $(consumer(regex('[0-9a-f-]{36}')),
provider('660e9511-f39c-52e5-b827-557766551111')),
status: 'completed',
timestamp: $(consumer(regex(isoDateTime())),
provider('2026-01-19T14:30:00Z'))
])
}
}Spring Cloud Contract automatically generates WireMock stubs for consumers and MockMvc tests for providers. Add this to your Maven build:
<!-- payment-service/pom.xml -->
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<configuration>
<baseClassForTests>
com.company.payment.BaseContractTest
</baseClassForTests>
</configuration>
</plugin>The base test class handles application context setup:
// payment-service/src/test/java/BaseContractTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
public abstract class BaseContractTest {
@Autowired
protected MockMvc mockMvc;
@MockBean
protected PaymentProcessor paymentProcessor;
@Before
public void setup() {
// Mock external dependencies
given(paymentProcessor.charge(any()))
.willReturn(new Transaction("660e9511-f39c-52e5-b827-557766551111",
TransactionStatus.COMPLETED));
}
}Handling API Versioning and Breaking Changes
Contract testing shines when you need to evolve APIs without breaking existing consumers. Here are battle-tested strategies:
Strategy 1: Non-Breaking Changes
Adding optional fields is safe—existing contracts won't break:
// Provider adds a new optional field
{
"transactionId": "660e9511-f39c-52e5-b827-557766551111",
"status": "completed",
"timestamp": "2026-01-19T14:30:00Z",
"processingFeeEur": 0.30 // New field - old consumers ignore it
}Contract tests pass because consumers only validate fields they care about.
Strategy 2: Provider-Side Compatibility Layer
When you need to change field names or types, maintain both versions temporarily:
// Payment API v1 (deprecated but supported)
{
"amount": 99.99, // Legacy field
"currency": "EUR"
}
// Payment API v2 (recommended)
{
"price": {
"value": 99.99,
"currencyCode": "EUR"
}
}Your controller supports both formats:
@PostMapping("/api/v1/payments")
public ResponseEntity<PaymentResponse> processPayment(
@RequestBody PaymentRequest request) {
// Support both formats
Money amount = request.getPrice() != null
? request.getPrice() // v2 format
: Money.of(request.getAmount(), request.getCurrency()); // v1 format
Transaction tx = paymentService.charge(amount);
return ResponseEntity.status(201).body(toResponse(tx));
}Strategy 3: Can-I-Deploy Checks
Before deploying a provider change, verify all deployed consumers can handle it:
# CI/CD pipeline step
pact-broker can-i-deploy \
--pacticipant=payment-service \
--version=${GIT_COMMIT} \
--to-environment=production
# Output:
# ✓ order-service (v2.3.1) verified payment-service contract
# ✓ invoice-service (v1.8.0) verified payment-service contract
# ✗ legacy-accounting (v1.2.0) failed verification
#
# Deployment BLOCKED: 1 consumer cannot handle changesThis prevents the classic mistake: deploying a breaking change because you tested against the latest consumer but forgot about older production versions.
Where Contract Testing Fits in Your Test Suite
Contract testing doesn't replace other testing layers—it complements them. Here's when to use each:
Test Layer Decision Matrix
Unit Tests
Business logic, validation rules, data transformations
Speed: Milliseconds | Coverage: 70-80%
Contract Tests
API boundaries, request/response formats, service interfaces
Speed: Seconds | Coverage: All service boundaries
Integration Tests
Database transactions, message queues, multi-step workflows
Speed: 10-60 seconds | Coverage: Critical paths only
E2E Tests
User journeys, UI interactions, full system behavior
Speed: Minutes | Coverage: Key user flows
Common Pitfalls and How to Avoid Them
Pitfall 1: Over-Specifying Contracts
Don't validate fields your consumer doesn't use. This contract is too strict:
// ❌ Bad: Consumer validates entire response
body: {
transactionId: MatchersV3.uuid(),
status: 'completed',
timestamp: MatchersV3.iso8601DateTime(),
processingFeeEur: 0.30,
paymentMethod: 'card',
merchantId: 'merch_123',
// ... 15 more fields the consumer ignores
}Better approach—only validate what you actually use:
// ✅ Good: Consumer validates only needed fields
body: {
transactionId: MatchersV3.uuid(),
status: MatchersV3.like('completed'),
}Pitfall 2: Ignoring Provider States
Provider states set up preconditions for tests. Without them, verification fails randomly:
// ❌ Bad: No state setup
.given('account has sufficient funds') // Provider doesn't set this up
// ✅ Good: State handler creates test data
stateHandlers: {
'account has sufficient funds': async () => {
await db.accounts.upsert({
id: 'test-account',
balance: 1000.00,
status: 'active',
});
},
'payment method is expired': async () => {
await db.paymentMethods.upsert({
id: 'test-card',
expiryDate: '2020-01-01',
});
},
}Pitfall 3: Not Running Verification in CI
Contract tests are worthless if providers don't verify them. Add verification to your CI pipeline:
# .github/workflows/contract-tests.yml
name: Verify Consumer Contracts
on:
push:
branches: [main]
pull_request:
jobs:
verify-contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: Start service
run: npm run start:test &
- name: Verify contracts
run: npm run test:pact:verify
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=payment-service \
--version=${{ github.sha }} \
--to-environment=productionGetting Started: Your First Contract Test
Ready to implement contract testing? Start with one critical service boundary:
- Identify your most brittle integration. Which service calls break most often?
- Choose your tool. Use Pact for polyglot services, Spring Cloud Contract for Spring Boot.
- Write consumer tests first. Define what your service actually needs from the provider.
- Set up contract publishing. Use Pact Broker or commit contracts to Git.
- Add provider verification. Run verification tests in CI for every provider change.
- Enable can-i-deploy checks. Block deployments that break consumer contracts.
Start small. One service boundary tested with contracts is better than zero. Once you prove the value—faster feedback, fewer production failures—expand to other integrations.
Key Takeaways
- Contract testing validates service boundaries without full environment setup, reducing test execution time by 60-80%
- Consumer-driven contracts shift left API breaking change detection, catching issues before deployment
- Implement contract tests using Pact for polyglot services or Spring Cloud Contract for Spring Boot ecosystems
- Contract testing complements but doesn't replace integration tests—use contracts for API boundaries, integration tests for data consistency and complex workflows
Need Help Implementing Contract Testing?
Desplega.ai helps teams build robust QA pipelines for microservices architectures. We specialize in contract testing implementation, CI/CD integration, and test automation strategies for distributed systems across Spain and Europe.
Get Expert QA GuidanceRelated Posts
Why Your QA Team Is Secretly Running Your Company (And Your Developers Don't Want You to Know) | desplega.ai
A satirical exposé on how QA engineers have become the unsung kingmakers in software organizations. While CTOs obsess over microservices, QA holds the keys to releases, customer satisfaction, and your weekend.
Rabbit Hole: Why Your QA Team Is Building Technical Debt (And Your Developers Know It) | desplega.ai
Hiring more QA engineers without automation strategy compounds technical debt. Learn why executive decisions about test infrastructure matter 10x more than headcount.
Rabbit Hole: TDD in AI Coding Era: Tests as Requirements
TDD transforms into strategic requirement specification for AI code generation. Tests become executable contracts that reduce defects 53% while accelerating delivery.