Contract Tests Explained: Catching Integration Bugs Early
Understand consumer-driven contract testing, how it differs from integration tests, and how tools like Pact prevent breaking API changes between services.
What you'll learn
- ✓What a contract test verifies
- ✓How consumer-driven contracts work
- ✓The role of a broker
- ✓Writing a Pact-style contract
- ✓When contract tests beat full integration
Prerequisites
- •Familiar with testing concepts
- •Basic understanding of HTTP APIs
What and Why
In a microservices world, Service A calls Service B. If team B changes a JSON field from userId to user_id, Service A breaks — but no unit test catches it, because the unit tests on each side mock the other. Integration tests catch it, but they need both services running, are slow, and often live in a separate repo.
Contract tests fix this. A contract is a small, versioned document describing what the consumer expects from the provider: “When I send GET /users/42, I expect a 200 with a JSON body containing id and email.” Both sides verify against the contract independently. No shared environment required.
Mental Model
Think of a contract as a written agreement between two services. The consumer writes it (“here is what I assume”), and the provider verifies it (“yes, I deliver that”). If the provider changes shape without updating the contract, verification fails — and the breakage is caught before deploy.
This is consumer-driven: consumers define what they actually use, so providers do not waste effort guaranteeing fields nobody reads. A central broker stores contracts and tracks which versions are compatible.
Hands-on Example
Using Pact in Node.js, the consumer side looks like:
const { PactV3 } = require('@pact-foundation/pact');
const provider = new PactV3({ consumer: 'WebApp', provider: 'UserService' });
provider
.uponReceiving('a request for user 42')
.withRequest({ method: 'GET', path: '/users/42' })
.willRespondWith({
status: 200,
body: { id: 42, email: 'jane@example.com' },
});
await provider.executeTest(async (mock) => {
const user = await fetchUser(mock.url, 42);
expect(user.email).toBe('jane@example.com');
});
Running this generates a JSON contract file. You publish it to the broker. The provider then runs its verification suite, which replays the contract against a real instance of UserService.
Consumer test Broker Provider test
| | |
| -- publish ------> | |
| | <-- fetch ------- |
| | |
| | -- verify ------> |
| | <-- result ------ |
| | |
pass/fail records pass/fail If UserService renames email to emailAddress, provider verification fails immediately. The broker also offers can-i-deploy checks — CI asks “can WebApp v3.4 deploy to prod given UserService v2.1?” and gets a yes or no.
Common Pitfalls
- Treating contracts as full schemas: contracts should describe only what the consumer actually reads, not every field the provider returns.
- Skipping verification on the provider: a contract is worthless if nobody verifies it on the producing side.
- Pinning to exact values: use matchers (
like,term) so contracts do not break when timestamps or IDs change. - No broker, just shared files: a broker tracks versions and compatibility; without it you lose can-i-deploy.
- Confusing with integration tests: contracts do not test real network calls between live services — they test that each side honors a shared shape.
Practical Tips
Start with one pair of services that talk frequently and break often. Get the consumer test green, publish to the broker, wire up provider verification, then add can-i-deploy to CI. Keep contracts small; one interaction per scenario. Tag pacts with the consumer version so old contracts can be retired. And document the workflow: contract testing is a team sport, and silent breakage often comes from one side ignoring the broker.
Wrap-up
Contract tests give you the safety of integration testing without the cost of shared environments. They shine in distributed systems where teams ship independently. Adopt them when “we broke an upstream caller” becomes a recurring incident — the payoff is fewer surprise outages and faster, more confident deploys.
Related articles
- Testing Property-Based Testing: An Introduction
Stop writing one example per test. Property-based testing generates inputs for you and finds the edge cases you would never think to write.
- Testing Test Coverage Metrics and Their Pitfalls
Line, branch, and mutation coverage explained. Learn what each metric tells you, what it hides, and how to use coverage without gaming it.
- Testing End-to-End Testing with Playwright: A Practical Tutorial
Learn how to write reliable end-to-end tests with Playwright, including selectors, fixtures, auto-waiting, and patterns that avoid flakiness.
- Testing Flaky Tests and How to Fix Them
Diagnose and eliminate flaky tests caused by timing, ordering, shared state, and the network. Build a culture that stops flakes at the source.