Definition
- Contract testing – Verifies the agreement between two services about the shape and semantics of their interaction, independently of running both together.
- Consumer-driven – The consumer declares the requests it sends and responses it expects, forming a contract. The provider is verified against it.
- Independent testing – Consumer tests against a mock; provider is verified against the contract. No full E2E environment needed.
- Pact – The standard tool for consumer-driven contract testing. Consumer generates a pact file, provider verifies against it.
- Pact Broker – Stores, versions, and tracks compatibility of contracts. The can-I-deploy check gates deployments.
- Scope – Tests structure, required fields, and types, not exhaustive business logic.
At the National Bank of India, the mobile-app team depends on the accounts service to return a balance in a certain shape. Contract testing is a written agreement: the app team writes down exactly what it sends and expects, and the accounts team is checked against that agreement. If the accounts team ever changes the response in a breaking way, the check fails before anything ships.
Understanding the Concepts
What is contract testing?
Contract testing sits between unit tests and E2E tests and targets a specific risk: that two independently deployed services drift apart at their interface.
Without contract testing:
Consumer unit tests: PASS
Provider unit tests: PASS
Interface between them: BROKEN (discovered in production)
With contract testing:
Consumer contract test: generates pact
Provider verifies pact: FAIL (caught before deployment)
How does consumer-driven contract testing work?
The consumer writes tests against a mock, declaring what it expects. Running those tests produces a pact file.
// Consumer test (Node.js with Pact)
const { PactV3 } = require('@pact-foundation/pact');
const provider = new PactV3({
consumer: 'MobileBankingApp',
provider: 'AccountsService',
});
describe('Accounts API contract', () => {
it('returns account balance', async () => {
provider
.given('Account ACC-001 exists with balance 50000')
.uponReceiving('a request for account balance')
.withRequest({
method: 'GET',
path: '/api/accounts/ACC-001/balance',
headers: { Authorization: 'Bearer valid-token' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
accountId: 'ACC-001',
balance: 50000.00,
currency: 'INR',
},
});
await provider.executeTest(async (mockServer) => {
const response = await fetch(
`${mockServer.url}/api/accounts/ACC-001/balance`,
{ headers: { Authorization: 'Bearer valid-token' } }
);
const data = await response.json();
expect(data.balance).toBe(50000.00);
expect(data.currency).toBe('INR');
});
});
});
// This generates a pact file: MobileBankingApp-AccountsService.json
How does the provider verify the contract?
The provider replays each interaction from the pact and checks that the actual response matches.
// Provider verification (Java with Pact)
package com.bank.accounts;
import au.com.dius.pact.provider.junit5.*;
import au.com.dius.pact.provider.junitsupport.*;
import au.com.dius.pact.provider.junitsupport.loader.*;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
@Provider("AccountsService")
@PactBroker(url = "https://pact-broker.nationalbank.in")
public class AccountsContractVerification {
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
@State("Account ACC-001 exists with balance 50000")
void setupAccount() {
// Seed the test data for this interaction
accountRepo.save(new Account("ACC-001", 50000.00, "INR"));
}
}
What is the Pact Broker?
The Broker stores, versions, and tracks which consumer and provider versions are compatible.
# Publish the consumer's pact to the Broker
pact-broker publish pacts/ \
--consumer-app-version=$(git rev-parse HEAD) \
--broker-base-url=https://pact-broker.nationalbank.in
# Check if it's safe to deploy
pact-broker can-i-deploy \
--pacticipant=AccountsService \
--version=$(git rev-parse HEAD) \
--to-environment=production
# Exits non-zero if incompatible -> blocks deployment
How does it fit in CI/CD?
# Consumer pipeline
jobs:
contract-test:
steps:
- run: npm test -- --grep contract
- run: pact-broker publish pacts/ --consumer-app-version=$GITHUB_SHA
# Provider pipeline
jobs:
verify-contracts:
steps:
- run: mvn test -Dgroups=contract
can-i-deploy:
needs: verify-contracts
steps:
- run: pact-broker can-i-deploy --pacticipant=AccountsService --version=$GITHUB_SHA --to-environment=production
What should contracts test and not test?
| Test in Contract | Do NOT Test in Contract |
|---|---|
| Required fields present | Full business logic |
| Correct field types | All edge cases |
| Response structure | Performance |
| Status codes for key scenarios | Exhaustive validation |
| Error response shape | UI behavior |
// Use Pact matchers for flexible contracts
.willRespondWith({
status: 200,
body: {
accountId: like('ACC-001'), // Any string
balance: decimal(50000.00), // Any decimal
currency: regex(/^[A-Z]{3}$/, 'INR'), // 3-letter code
timestamp: iso8601DateTime(), // Any ISO datetime
},
})
// Matchers verify structure and types, not exact values
What is provider-driven contract testing?
For public APIs with many unknown consumers, schema-based validation using OpenAPI is an alternative.
# OpenAPI schema as a contract
openapi: 3.0.0
paths:
/api/accounts/{id}/balance:
get:
responses:
'200':
content:
application/json:
schema:
type: object
required: [accountId, balance, currency]
properties:
accountId: { type: string }
balance: { type: number }
currency: { type: string }
What are common interview follow-up questions?
- "Does contract testing replace E2E?" No. It catches interface drift but not end-to-end business flows. Keep a thin E2E layer for critical journeys.
- "What happens when a contract change is needed?" Coordinate as a team event. Use expand-and-contract: the provider supports both old and new shapes during the transition.
- "How many contracts should a provider have?" One per consumer. The Broker tracks them all.
Visual Overview
Consumer-Driven Contract Testing Flow
─────────────────────────────────────
CONSUMER SIDE PROVIDER SIDE
──────────── ─────────────
┌────────────────┐
│ Consumer test │
│ declares: │
│ - Request shape │
│ - Expected resp │
└───────┬────────┘
│ generates
▼
┌────────────────┐
│ Pact File │──────> Pact Broker
│ (contract) │ (stores, versions)
└────────────────┘ │
│ provider pulls
▼
┌────────────────┐
│ Provider verify │
│ - Replay each │
│ interaction │
│ - Check actual │
│ vs expected │
└───────┬────────┘
│
▼
┌────────────────┐
│ can-i-deploy? │
│ Compatible? │──> Deploy gate
└────────────────┘
Key Takeaway
Contract testing verifies the agreement between a consumer and a provider about the shape of their interaction, independently of running both together. The consumer declares what it sends and expects, producing a contract (pact file). The provider verifies itself against the contract. If the provider's response no longer matches, verification fails before deployment.
The key property is independence: no full E2E environment needed. The consumer tests against a fast local mock, the provider verifies alone. Pact is the standard tool, with a Broker that stores and versions contracts and provides can-I-deploy checks as automated deployment gates.
Contracts focus on structure, required fields, and types, not exhaustive business logic. They complement unit tests and a thin E2E layer. And they are consumer-driven, which captures exactly what consumers rely on, protecting them while freeing the provider to change anything no contract covers.
At the National Bank of India, our mobile app consumed the accounts service, and before contract testing a backend change renamed a field and broke the app in production. With Pact, the consumer's contract declared it needed a numeric balance field, and when a provider change would have broken it, verification failed in CI and can-I-deploy blocked the release.
Remember one sentence: contract testing lets the consumer declare "I expect this shape" and the provider is verified to always return it, catching interface drift before deployment without a full E2E environment.
---