The Integration Test Lie
You have 95% code coverage. Your unit tests are green. You deploy your service, and within minutes the downstream consumer is throwing 500s. The response field was renamed from userId to user_id. Nobody noticed because nobody tested the contract between services.
This happens constantly in microservice architectures. Each service has its own test suite, its own CI pipeline, its own deploy cadence. And the space between services — the actual API contract — is where things break.
Unit tests verify your logic. Integration tests verify your wiring. Contract tests verify your promises.
What Contract Testing Actually Is
A contract test validates that two services agree on the shape and behavior of their communication. The consumer says "I expect a GET /users/123 to return { id, name, email }." The provider says "I can deliver that." The contract test verifies both sides independently.
This isn't a new idea — it's been around since Ian Robinson's work on consumer-driven contracts in the mid-2000s. But the tooling has matured dramatically.
The key insight: you don't need both services running at the same time to verify compatibility. Consumer tests generate a contract file. Provider tests verify against it. Each runs in its own CI pipeline. If either side breaks the contract, the build fails before deployment.
Consumer-Driven vs. Provider-Driven
Two approaches, different tradeoffs:
Consumer-driven contracts (CDC) let the consumer define what it needs. The provider must satisfy all consumer contracts. This works well when you have multiple consumers with different needs — the provider knows exactly what each consumer relies on and can evolve safely.
Provider-driven contracts flip this: the provider publishes its full API schema, and consumers verify they only use what's available. This is simpler when you have one provider serving many consumers and want to move fast on the provider side.
In practice, most teams start with consumer-driven because it catches the failures that actually hurt: a provider changing something a consumer depends on.
Pact in Practice
Pact is the most widely adopted contract testing framework. It supports consumer-driven contracts across languages — JavaScript, Go, Java, Python, Rust, and more.
Here's the flow:
Consumer Side
The consumer writes a test that defines its expectations:
// user-service.consumer.spec.ts
const provider = new PactV4({
consumer: 'OrderService',
provider: 'UserService',
});
describe('User API', () => {
it('returns user details', async () => {
await provider
.addInteraction()
.given('user 123 exists')
.uponReceiving('a request for user 123')
.withRequest('GET', '/users/123')
.willRespondWith(200, (builder) => {
builder.jsonBody({
id: 123,
name: 'Jane Doe',
email: 'jane@example.com',
});
})
.executeTest(async (mockServer) => {
const user = await fetchUser(mockServer.url, 123);
expect(user.name).toBe('Jane Doe');
});
});
});
This test generates a pact file — a JSON contract describing the expected interaction. The consumer CI publishes this to a Pact Broker (or PactFlow for the hosted version).
Provider Side
The provider runs verification against all consumer pacts:
// user-service.provider.spec.ts
describe('User API provider verification', () => {
it('satisfies all consumer contracts', async () => {
await new Verifier({
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://your-broker.pactflow.io',
provider: 'UserService',
providerStatesSetupUrl: 'http://localhost:3000/test/setup',
}).verifyProvider();
});
});
The provider spins up its real server (or a close approximation), and Pact replays each consumer's expected interactions against it. If any interaction fails, the provider build breaks.
Beyond Pact: Schema-Based Contracts
Not every team needs Pact's full ceremony. If your services communicate via OpenAPI, gRPC, or GraphQL, you already have a schema contract.
OpenAPI + Spectral: Lint your OpenAPI specs for breaking changes on every PR. Tools like openapi-diff can flag removed fields, changed types, or narrowed enums automatically.
gRPC + Protocol Buffers: Protobuf's wire format is inherently backward-compatible if you follow the proto3 style guide. Use buf to enforce breaking change detection in CI.
GraphQL: The schema is the contract. Tools like GraphQL Inspector detect breaking changes between schema versions.
The tradeoff: schema-based approaches catch structural breaks but miss behavioral contracts. They won't tell you that the status field now returns "ACTIVE" instead of "active". Pact catches both.
Fitting Contract Tests Into CI
Contract tests belong in your pull request pipeline, not in a nightly job you check once a week. Here's a practical setup:
# .github/workflows/contract-tests.yml
name: Contract Tests
on: [pull_request]
jobs:
consumer-contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:contract
- run: |
npx pact-broker publish ./pacts \
--consumer-app-version=${{ github.sha }} \
--branch=${{ github.head_ref }}
provider-verification:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run start:test &
- run: npm run test:contract:verify
The critical addition: can-i-deploy. Before deploying any service, ask the Pact Broker whether all contracts are satisfied:
npx pact-broker can-i-deploy \
--pacticipant UserService \
--version $(git rev-parse HEAD) \
--to-environment production
This single command prevents deploying a provider that would break its consumers, or a consumer that depends on a provider change that hasn't shipped yet.
When Contract Tests Save You
Real scenarios where contract tests catch what other tests miss:
-
Field renames. Provider renames
created_attocreatedAt. Unit tests pass on both sides. Contract test fails. - Type changes. An ID field changes from number to string. The consumer's JSON parser silently coerces it — until it doesn't.
- Removed optional fields. Provider stops sending a field the consumer treats as optional but actually depends on for a specific code path.
- Enum expansion. Provider adds a new status value. Consumer's switch statement falls through to an error case.
-
Pagination changes. Response wrapping changes from
{ items: [...] }to{ data: [...], meta: {...} }. Every consumer breaks.
Each of these passes unit tests. Each breaks production. Each is caught by a contract test.
Common Mistakes
Testing too much. Contract tests verify the interface, not the business logic. Don't assert that the response contains exactly 3 items — assert that it contains an array of objects with the expected shape.
Not setting up provider states. If your consumer test expects "user 123 exists," the provider verification needs a setup endpoint that creates that state. Skip this, and you get flaky tests that depend on database content.
Treating contracts as integration tests. Contract tests replace the need for end-to-end integration tests between specific service pairs. They don't replace your service's own functional tests.
Running only on main. Consumer contracts should be published from feature branches. Otherwise you only discover breaks after merging — exactly when it's most painful.
Start Small
You don't need to contract-test every service interaction on day one. Start with:
- Identify your most painful integration point. Which service boundary causes the most production incidents?
- Add a consumer contract test for the 2-3 most critical endpoints.
- Add provider verification to the provider's CI.
- Set up a Pact Broker (the Docker image works fine for starters).
- Add can-i-deploy to your deployment pipeline.
One contract between two services. That's it. Expand from there based on where breaks actually happen.
The goal isn't 100% contract coverage. It's preventing the category of failures that slip through every other testing layer — the ones that only show up when two services talk to each other in production.
Those are the expensive ones.
Top comments (0)