Contract Testing with Pact for Microservices
Learn how Pact contract testing catches integration bugs between microservices without needing a full end-to-end environment.
What you'll learn
- ✓What contract testing is and why integration tests fall short
- ✓How Pact consumer-driven contracts work
- ✓Writing consumer and provider tests step by step
- ✓Using a Pact Broker for contract management
Prerequisites
- •Basic understanding of REST APIs
- •Familiarity with microservices architecture
Why Integration Tests Are Not Enough
In a microservices architecture, Service A calls Service B over HTTP. You can test each service in isolation with unit tests, and you can spin up both services together for integration tests. But integration tests have serious problems at scale:
- They require all dependent services to be running.
- They are slow, flaky, and expensive to maintain.
- A broken test might be caused by any of the services in the chain.
- They do not scale when you have dozens of services.
Contract testing solves this by testing the agreement between services without requiring them to run together. Each side tests against a shared contract, and if both sides pass, you know they will work together in production.
How Pact Works
Pact is the most widely used contract testing framework. It uses a consumer-driven model:
- Consumer test: The consumer (the service making the request) defines what it expects from the provider. Pact records this as a pact file (a JSON contract).
- Pact file: A JSON document that describes the expected interactions — request method, path, headers, body, and the expected response.
- Provider verification: The provider (the service receiving the request) replays the pact file against its real implementation and verifies it returns what the consumer expects.
If both sides pass, the contract holds. If the provider changes its API in a way that breaks the contract, the provider verification fails — catching the bug before deployment.
Setting Up a Consumer Test (JavaScript)
Let us build a concrete example. Suppose an OrderService (consumer) calls a UserService (provider) to get user details.
Install the Pact library:
npm install --save-dev @pact-foundation/pact
Write the consumer test:
// order-service/tests/user.consumer.pact.test.js
const { Pact } = require("@pact-foundation/pact");
const { fetchUser } = require("../src/userClient");
const path = require("path");
const provider = new Pact({
consumer: "OrderService",
provider: "UserService",
port: 1234,
log: path.resolve(process.cwd(), "logs", "pact.log"),
dir: path.resolve(process.cwd(), "pacts"),
});
describe("User API Contract", () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
it("returns user details for a valid user ID", async () => {
// Arrange: define the expected interaction
await provider.addInteraction({
state: "user with ID 42 exists",
uponReceiving: "a request for user 42",
withRequest: {
method: "GET",
path: "/users/42",
headers: { Accept: "application/json" },
},
willRespondWith: {
status: 200,
headers: { "Content-Type": "application/json" },
body: {
id: 42,
name: "Alice",
email: "alice@example.com",
},
},
});
// Act: call the real client code against the mock
const user = await fetchUser(42);
// Assert: verify the client handles the response
expect(user.id).toBe(42);
expect(user.name).toBe("Alice");
expect(user.email).toBe("alice@example.com");
});
it("returns 404 for a non-existent user", async () => {
await provider.addInteraction({
state: "user with ID 999 does not exist",
uponReceiving: "a request for user 999",
withRequest: {
method: "GET",
path: "/users/999",
headers: { Accept: "application/json" },
},
willRespondWith: {
status: 404,
body: { error: "User not found" },
},
});
await expect(fetchUser(999)).rejects.toThrow("User not found");
});
});
When this test runs, Pact starts a mock server on port 1234. Your fetchUser function makes real HTTP requests to that mock. Pact records the interactions and writes them to a pact file at pacts/OrderService-UserService.json.
The Pact File
The generated pact file looks like this:
{
"consumer": { "name": "OrderService" },
"provider": { "name": "UserService" },
"interactions": [
{
"description": "a request for user 42",
"providerState": "user with ID 42 exists",
"request": {
"method": "GET",
"path": "/users/42",
"headers": { "Accept": "application/json" }
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": {
"id": 42,
"name": "Alice",
"email": "alice@example.com"
}
}
}
]
}
This file is the contract. It gets shared with the provider team.
Provider Verification
On the provider side, you verify that the real UserService satisfies the contract:
// user-service/tests/pact.provider.test.js
const { Verifier } = require("@pact-foundation/pact");
const { app } = require("../src/app"); // your Express/Fastify app
describe("UserService Provider Verification", () => {
let server;
beforeAll((done) => {
server = app.listen(3001, done);
});
afterAll((done) => {
server.close(done);
});
it("validates the contract with OrderService", async () => {
await new Verifier({
providerBaseUrl: "http://localhost:3001",
pactUrls: [
"./pacts/OrderService-UserService.json",
],
stateHandlers: {
"user with ID 42 exists": async () => {
// Set up the database state so user 42 exists
await db.users.create({ id: 42, name: "Alice", email: "alice@example.com" });
},
"user with ID 999 does not exist": async () => {
// Ensure user 999 does not exist
await db.users.deleteWhere({ id: 999 });
},
},
}).verifyProvider();
});
});
The stateHandlers are critical. They set up the provider’s database or mock state to match the providerState defined in the consumer test. This is how Pact handles the fact that the provider needs to be in a specific state for each interaction.
Using Matchers for Flexible Contracts
Hardcoding exact values like "Alice" makes contracts brittle. Pact provides matchers for flexible contracts:
const { Matchers } = require("@pact-foundation/pact");
const { like, eachLike, term } = Matchers;
await provider.addInteraction({
state: "user with ID 42 exists",
uponReceiving: "a request for user 42",
withRequest: {
method: "GET",
path: "/users/42",
},
willRespondWith: {
status: 200,
body: {
id: like(42), // must be an integer, value doesn't matter
name: like("Alice"), // must be a string
email: term({
generate: "alice@example.com",
matcher: "^[\\w.]+@[\\w.]+$", // must match this regex
}),
roles: eachLike("admin"), // must be an array with at least one string
},
},
});
Matchers verify the shape of the response rather than exact values. The provider can return "Bob" instead of "Alice" and the contract still passes, as long as it is a string.
The Pact Broker
Sharing pact files via Git or file systems works for small teams, but does not scale. The Pact Broker is a service that stores pact files and verification results centrally:
# Publish the pact from the consumer pipeline
pact-broker publish ./pacts \
--consumer-app-version=$(git rev-parse HEAD) \
--broker-base-url=https://your-broker.example.com
# Verify on the provider side, pulling pacts from the broker
npx pact-provider-verifier \
--provider-base-url=http://localhost:3001 \
--pact-broker-base-url=https://your-broker.example.com \
--provider=UserService
The Broker also supports can-i-deploy, a CLI command that checks whether a particular version of a service can be safely deployed based on whether all its contracts have been verified:
pact-broker can-i-deploy \
--pacticipant=OrderService \
--version=$(git rev-parse HEAD) \
--to-environment=production
This becomes a gate in your CI/CD pipeline.
Contract Testing in Python
Pact also has a Python library. The consumer side looks like this:
import atexit
import unittest
from pact import Consumer, Provider
pact = Consumer("OrderService").has_pact_with(
Provider("UserService"),
port=1234,
pact_dir="./pacts",
)
pact.start_service()
atexit.register(pact.stop_service)
class TestUserContract(unittest.TestCase):
def test_get_user(self):
expected = {"id": 42, "name": "Alice", "email": "alice@example.com"}
(pact
.given("user with ID 42 exists")
.upon_receiving("a request for user 42")
.with_request("GET", "/users/42")
.will_respond_with(200, body=expected))
with pact:
result = fetch_user(42) # your client function
self.assertEqual(result["id"], 42)
When Contract Testing Helps Most
Contract testing is most valuable when:
- Multiple teams own different services. Contracts catch breaking changes before deployment, without requiring coordination.
- End-to-end tests are too slow or flaky. Contracts run in seconds and do not need external infrastructure.
- APIs change frequently. The consumer defines what it needs, so the provider knows exactly which fields and endpoints are actually used.
- You are migrating from a monolith. As you extract services, contracts verify the extracted service matches what the monolith provided.
It is less useful for single-team monoliths or services with a single consumer where regular integration tests suffice.
Common Pitfalls
Testing too much in the contract. Contracts should verify structure and types, not business logic. Keep interactions minimal.
Skipping state handlers. Without proper state setup, provider verification becomes flaky. Treat state handlers as first-class test setup.
Not running provider verification in CI. The contract is only useful if both sides run their tests automatically. Add provider verification to the provider’s CI pipeline.
Using exact value matching everywhere. Use matchers to keep contracts flexible. Exact values are for things that truly must not change, like status codes and error message formats.
Key Takeaways
Contract testing with Pact gives you confidence that services will integrate correctly without running them together. The consumer defines expectations, Pact generates a contract file, and the provider verifies against it. Combined with a Pact Broker and can-i-deploy, you get a deployment safety net that scales across dozens of services. Start by identifying your most critical service-to-service calls and adding contracts there first.
Related articles
- Testing 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.
- Testing Integration Testing: Test Real Dependencies, Not Mocks
Learn how to write effective integration tests that verify real database queries, API calls, and service interactions using testcontainers and proper test infrastructure.
- Testing Performance Testing: Load, Stress, and Spike Testing
Learn load testing, stress testing, and spike testing with k6 and Locust. Includes test scripts, result interpretation, and strategies for finding bottlenecks.
- Testing Test-Driven Development (TDD): A Practical Guide
Learn the TDD workflow with real examples in Python and JavaScript. Covers red-green-refactor, when TDD works best, and how to handle common challenges.