Loading learning content...
In a microservices architecture, services evolve independently—different teams, different codebases, different release schedules. This independence is the core value proposition, but it creates a fundamental challenge: How do you know that a change in Service A won't break Service B?
Traditional integration tests require running all services together, which becomes impractical at scale. End-to-end tests are slow, flaky, and create bottlenecks. Contract testing offers a powerful alternative: verify that services honor their agreements without deploying them together.
By the end of this page, you will understand how to implement contract testing for microservices using consumer-driven contracts, provider verification, and tooling like Pact. You'll learn how contracts enable independent deployment while maintaining system-wide compatibility, and how to integrate contract testing into your CI/CD pipeline.
Contract testing verifies that two services can communicate correctly by testing each side against a contract—a formal specification of the expected interactions. Unlike integration tests that require both services to be running, contract tests run in isolation against the contract itself.
The Contract Concept:
A contract defines:
Key Terminology:
| Approach | Speed | Isolation | Coverage | Deployment Dependency |
|---|---|---|---|---|
| Unit Tests (mocked) | Very Fast | Complete | Limited to mocks | None |
| Contract Tests | Fast | Complete | API surface | None (contracts shared) |
| Integration Tests | Medium | Partial | Real paths tested | Infrastructure needed |
| E2E Tests | Slow | None | Full system | All services deployed |
Two Sides of Contract Testing:
1. Consumer Side: The consumer writes tests describing the interactions it expects. These tests generate a contract file that captures the consumer's requirements.
2. Provider Side: The provider runs verification tests against all consumer contracts to ensure it satisfies every consumer's expectations.
This two-sided approach ensures forward compatibility—providers know exactly what consumers depend on and can evolve safely without breaking anyone.
Contract testing enables a crucial organizational benefit: teams can develop and deploy independently. If your provider verification passes against all consumer contracts, you can deploy with confidence—without coordinating releases with consumer teams or running expensive integration environments.
The most effective contract testing approach is Consumer-Driven Contracts (CDC). In CDC, the consumer defines the contract based on what it actually needs from the provider—not what the provider happens to expose.
Why Consumer-Driven?
The CDC Workflow:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
import { Pact, Matchers } from "@pact-foundation/pact";import { OrderServiceClient } from "./order-service-client"; const { like, eachLike, iso8601DateTimeWithMillis } = Matchers; describe("Order Service Client - Consumer Contract", () => { const provider = new Pact({ consumer: "CheckoutService", provider: "OrderService", port: 1234, log: "./pact/logs/", dir: "./pact/contracts/", }); beforeAll(() => provider.setup()); afterAll(() => provider.finalize()); afterEach(() => provider.verify()); describe("GET /orders/:id", () => { it("returns order details for existing order", async () => { // Define the expected interaction await provider.addInteraction({ state: "an order with ID ord-123 exists", uponReceiving: "a request for order ord-123", withRequest: { method: "GET", path: "/orders/ord-123", headers: { "Accept": "application/json", "Authorization": like("Bearer valid-token"), }, }, willRespondWith: { status: 200, headers: { "Content-Type": "application/json", }, body: { // Use matchers for flexible verification id: "ord-123", status: like("CONFIRMED"), // Any string customerId: like("cust-456"), items: eachLike({ productId: like("prod-001"), quantity: like(1), unitPrice: like(29.99), }), totalAmount: like(29.99), createdAt: iso8601DateTimeWithMillis(), }, }, }); // Execute the consumer code against the mock provider const client = new OrderServiceClient(`http://localhost:1234`); const order = await client.getOrder("ord-123", "valid-token"); // Assert on the response (consumer's perspective) expect(order.id).toBe("ord-123"); expect(order.items).toHaveLength(1); expect(order.totalAmount).toBeGreaterThan(0); }); it("returns 404 for non-existent order", async () => { await provider.addInteraction({ state: "no order with ID ord-999 exists", uponReceiving: "a request for non-existent order", withRequest: { method: "GET", path: "/orders/ord-999", headers: { "Accept": "application/json", "Authorization": like("Bearer valid-token"), }, }, willRespondWith: { status: 404, headers: { "Content-Type": "application/json", }, body: { error: "Order not found", code: "ORDER_NOT_FOUND", }, }, }); const client = new OrderServiceClient(`http://localhost:1234`); await expect(client.getOrder("ord-999", "valid-token")) .rejects.toThrow("Order not found"); }); }); describe("POST /orders", () => { it("creates a new order", async () => { await provider.addInteraction({ state: "customer cust-789 exists", uponReceiving: "a request to create an order", withRequest: { method: "POST", path: "/orders", headers: { "Content-Type": "application/json", "Authorization": like("Bearer valid-token"), }, body: { customerId: "cust-789", items: eachLike({ productId: like("prod-001"), quantity: like(2), }), }, }, willRespondWith: { status: 201, headers: { "Content-Type": "application/json", "Location": like("/orders/ord-new"), }, body: { id: like("ord-new"), status: "DRAFT", customerId: "cust-789", items: eachLike({ productId: like("prod-001"), quantity: like(2), unitPrice: like(0), }), totalAmount: like(0), createdAt: iso8601DateTimeWithMillis(), }, }, }); const client = new OrderServiceClient(`http://localhost:1234`); const order = await client.createOrder("valid-token", { customerId: "cust-789", items: [{ productId: "prod-001", quantity: 2 }], }); expect(order.id).toBeDefined(); expect(order.status).toBe("DRAFT"); expect(order.customerId).toBe("cust-789"); }); });}); // Generated contract (pact file) structure:// {// "consumer": { "name": "CheckoutService" },// "provider": { "name": "OrderService" },// "interactions": [// {// "description": "a request for order ord-123",// "providerState": "an order with ID ord-123 exists",// "request": { ... },// "response": { ... }// },// ...// ]// }Pact matchers (like(), eachLike(), regex()) are crucial for maintainable contracts. Exact value matching creates brittle contracts that break when test data changes. Matchers verify structure and type while allowing value flexibility. Use exact matches only when the specific value matters to the consumer.
The provider side of contract testing is verification—running the provider's real implementation against consumer contracts to ensure compatibility.
Provider Verification Flow:
The State Setup Challenge:
Provider states require the provider to set up specific data conditions. This is often the trickiest part of contract testing—you need hooks that can create the test data described in consumer states.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
import { Verifier, VerifierOptions } from "@pact-foundation/pact"; // Provider verification testdescribe("Order Service - Provider Contract Verification", () => { let server: Express.Application; beforeAll(async () => { // Start the real provider service server = await startOrderService(); }); afterAll(async () => { await stopOrderService(server); }); it("validates all consumer contracts", async () => { const options: VerifierOptions = { provider: "OrderService", providerBaseUrl: "http://localhost:3000", // Fetch contracts from Pact Broker pactBrokerUrl: "https://pact.company.com", pactBrokerToken: process.env.PACT_BROKER_TOKEN, // Or use local pact files // pactUrls: ["./pacts/checkoutservice-orderservice.json"], // Provider version for broker reporting providerVersion: process.env.GIT_COMMIT || "local", // Publish verification results to broker publishVerificationResult: process.env.CI === "true", // State setup handler stateHandlers: { "an order with ID ord-123 exists": async () => { await createTestOrder({ id: "ord-123", status: "CONFIRMED", customerId: "cust-456", items: [ { productId: "prod-001", quantity: 1, unitPrice: 29.99 }, ], totalAmount: 29.99, }); }, "no order with ID ord-999 exists": async () => { // Ensure order doesn't exist (cleanup if needed) await deleteOrderIfExists("ord-999"); }, "customer cust-789 exists": async () => { await createTestCustomer({ id: "cust-789", email: "test@example.com", }); }, }, // Request filter for auth token replacement requestFilter: (req, res, next) => { // Replace placeholder auth with valid test token if (req.headers.authorization) { req.headers.authorization = "Bearer test-valid-token"; } next(); }, }; // Run verification await new Verifier(options).verifyProvider(); });}); // Reusable state setup helpersasync function createTestOrder(data: OrderData): Promise<void> { await prisma.order.upsert({ where: { id: data.id }, update: data, create: { ...data, createdAt: new Date(), }, }); // Create order items for (const item of data.items) { await prisma.orderItem.create({ data: { orderId: data.id, ...item, }, }); }} async function deleteOrderIfExists(orderId: string): Promise<void> { await prisma.orderItem.deleteMany({ where: { orderId } }); await prisma.order.deleteMany({ where: { id: orderId } });} async function createTestCustomer(data: CustomerData): Promise<void> { await prisma.customer.upsert({ where: { id: data.id }, update: data, create: { ...data, createdAt: new Date(), }, });}Provider state handlers are the most common source of contract test failures. If your state setup is incomplete or inconsistent, verification will fail even when the provider correctly implements the API. Invest in reliable, idempotent state handlers that can be run repeatedly without side effects.
The Pact Broker is the central coordination point for contract testing. It stores contracts, tracks verification results, and enables the 'can I deploy?' workflow that makes contract testing practical at scale.
Pact Broker Capabilities:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
# CI Pipeline - Consumer Sidename: Checkout Service CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Unit Tests run: npm test - name: Run Consumer Contract Tests run: npm run test:pact - name: Publish Contracts to Pact Broker if: github.event_name == 'push' env: PACT_BROKER_BASE_URL: https://pact.company.com PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }} run: | npx pact-broker publish ./pact/contracts \ --consumer-app-version=${{ github.sha }} \ --branch=${{ github.ref_name }} \ --tag=${{ github.ref_name }} - name: Can I Deploy? if: github.ref == 'refs/heads/main' env: PACT_BROKER_BASE_URL: https://pact.company.com PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }} run: | npx pact-broker can-i-deploy \ --pacticipant=CheckoutService \ --version=${{ github.sha }} \ --to-environment=production ---# CI Pipeline - Provider Sidename: Order Service CI on: push: # Webhook from Pact Broker when new consumer contracts published repository_dispatch: types: [pact-contract-published] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Unit Tests run: npm test - name: Run Integration Tests run: npm run test:integration - name: Verify Consumer Contracts env: PACT_BROKER_BASE_URL: https://pact.company.com PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }} run: | npm run test:pact:verify - name: Can I Deploy? if: github.ref == 'refs/heads/main' env: PACT_BROKER_BASE_URL: https://pact.company.com PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }} run: | npx pact-broker can-i-deploy \ --pacticipant=OrderService \ --version=${{ github.sha }} \ --to-environment=productionThe can-i-deploy check is what makes contract testing practical. Before deploying any service, you ask the broker: 'Given my consumer contracts and the verification results from my providers, is it safe to deploy this version to production?' This simple check prevents incompatible deployments without requiring coordinated releases.
Event-driven architectures need contract testing too. When services communicate via events, the contract defines the event schema and semantics—what events are published and what structure consumers can rely on.
Event Contract Considerations:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
import { MessageConsumerPact, Matchers } from "@pact-foundation/pact"; const { like, eachLike, iso8601DateTimeWithMillis, uuid } = Matchers; // Consumer contract for eventsdescribe("Inventory Service - Order Event Consumer", () => { const messagePact = new MessageConsumerPact({ consumer: "InventoryService", provider: "OrderService", dir: "./pact/contracts", }); describe("OrderCreated event", () => { it("processes order created event correctly", async () => { await messagePact .given("an order was created") .expectsToReceive("an OrderCreated event") .withContent({ // Event structure contract eventType: "OrderCreated", eventId: uuid(), timestamp: iso8601DateTimeWithMillis(), correlationId: uuid(), data: { orderId: uuid(), customerId: uuid(), items: eachLike({ productId: like("prod-001"), sku: like("SKU-001"), quantity: like(1), }), metadata: { source: like("web"), version: "1.0", }, }, }) .verify(async (message) => { // Test consumer's handler with the message const event = JSON.parse(message.contents.toString()); const handler = new OrderCreatedEventHandler( mockInventoryRepository ); await handler.handle(event); // Verify the handler processed correctly expect(mockInventoryRepository.reserveStock).toHaveBeenCalledWith( expect.objectContaining({ productId: expect.any(String), quantity: expect.any(Number), }) ); }); }); }); describe("OrderCancelled event", () => { it("releases inventory on order cancellation", async () => { await messagePact .given("an order was cancelled") .expectsToReceive("an OrderCancelled event") .withContent({ eventType: "OrderCancelled", eventId: uuid(), timestamp: iso8601DateTimeWithMillis(), data: { orderId: like("ord-123"), reason: like("customer_request"), items: eachLike({ productId: like("prod-001"), quantity: like(1), }), }, }) .verify(async (message) => { const event = JSON.parse(message.contents.toString()); const handler = new OrderCancelledEventHandler( mockInventoryRepository ); await handler.handle(event); expect(mockInventoryRepository.releaseReservation).toHaveBeenCalled(); }); }); });}); // Provider verification for eventsdescribe("Order Service - Event Provider Verification", () => { it("verifies all message contracts", async () => { const verifier = new MessageProviderPact({ provider: "OrderService", handlers: { "OrderCreated event": async () => { // Return the actual event your service produces return { eventType: "OrderCreated", eventId: "evt-123", timestamp: new Date().toISOString(), correlationId: "corr-456", data: { orderId: "ord-789", customerId: "cust-abc", items: [ { productId: "prod-001", sku: "SKU-001", quantity: 2 }, ], metadata: { source: "web", version: "1.0", }, }, }; }, "OrderCancelled event": async () => { return { eventType: "OrderCancelled", eventId: "evt-456", timestamp: new Date().toISOString(), data: { orderId: "ord-789", reason: "customer_request", items: [ { productId: "prod-001", quantity: 2 }, ], }, }; }, }, }); const options = { provider: "OrderService", pactBrokerUrl: "https://pact.company.com", pactBrokerToken: process.env.PACT_BROKER_TOKEN, providerVersion: process.env.GIT_COMMIT, publishVerificationResult: process.env.CI === "true", }; await verifier.verify(options); });});Events are harder to evolve than APIs because you can't negotiate with consumers in real-time. Include a version field in events and follow schema evolution rules: add optional fields, never remove or rename fields, never change field types. Contract tests verify that producers don't break these rules.
Contract testing is powerful but requires discipline to realize its benefits. These best practices, distilled from teams that have successfully adopted CDC at scale, will help you avoid common pitfalls.
The greatest value of consumer-driven contracts isn't the tests themselves—it's the conversations they enable. When a consumer publishes a contract that fails provider verification, it surfaces a design misalignment that would otherwise only appear in production. Use failed contracts as the starting point for provider-consumer discussions, not as blame tools.
Contract testing isn't the only approach to service compatibility verification. Understanding when to use contracts versus alternatives helps you choose the right tool for each situation.
| Approach | Best For | Limitations | When to Use |
|---|---|---|---|
| Contract Testing (Pact) | Consumer-centric API verification | Requires both sides to adopt | Internal microservices with shared ownership |
| Schema Validation (JSON Schema, OpenAPI) | Provider-defined structure validation | Doesn't verify consumer needs | Public APIs, provider-controlled contracts |
| Integration Tests | Full interaction verification | Slow, requires running services | Critical paths, database interactions |
| Mocks/Stubs | Fast, isolated testing | Mocks can drift from reality | Unit testing, when speed is critical |
| Service Virtualization | Simulating complex services | Maintenance burden, can drift | Third-party dependencies, legacy systems |
When Contract Testing Shines:
When Alternatives May Be Better:
Contract testing doesn't replace other testing strategies—it complements them. A comprehensive microservices testing approach uses unit tests for logic, contract tests for API compatibility, integration tests for infrastructure, and E2E tests for critical user journeys. Each layer catches different categories of bugs.
Contract testing enables the key promise of microservices: independent deployment. By verifying service compatibility at build time rather than at integration time, teams can evolve and deploy services without coordinating releases or maintaining expensive shared environments.
What's Next:
Contract testing verifies pairwise compatibility between services, but some bugs only appear when the entire system operates together. The next page explores End-to-End Testing—how to verify complete user journeys across the full microservices architecture, when E2E tests are worth their cost, and how to keep them maintainable.
You now understand how to implement contract testing for microservices. You've learned about consumer-driven contracts, provider verification, the Pact Broker workflow, and event contract testing. Next, we'll explore end-to-end testing strategies for distributed systems.