Skip to content
Codeloom
Testing

Contract Testing with Pact for Microservices

Learn how Pact contract testing catches integration bugs between microservices without needing a full end-to-end environment.

·8 min read · By Codeloom
Intermediate 11 min read

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:

  1. 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).
  2. Pact file: A JSON document that describes the expected interactions — request method, path, headers, body, and the expected response.
  3. 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.