Back to All Articles
API Testing

Contract Testing with Pact — Consumer-Driven API Testing

Honnesh Muppala May 5, 2026 14 min read

The Problem with Integration Testing at Scale

In a microservices architecture, each service is developed and deployed independently. The user-facing frontend talks to a user-service, the user-service talks to an orders-service, the orders-service talks to a payment-service. Each service has its own repository, its own team, and its own release cadence.

Traditional integration testing in this environment means spinning up all dependent services simultaneously and running end-to-end scenarios. This approach has compounding problems at scale:

Contract testing is the solution to this problem. It allows each service to verify its integration with its dependencies independently — without running the other service at all.

What is Contract Testing?

Contract testing is an approach to integration testing where:

  1. The consumer (the service that calls the API) defines what it needs — the specific requests it makes and the minimum response it expects
  2. This definition is saved as a pact (a JSON contract file)
  3. The provider (the service that serves the API) verifies that it actually delivers everything the consumer defined in the pact

This separation means the consumer's test suite generates a contract and the provider's test suite verifies it. Each service's tests run in isolation. The Pact Broker acts as the exchange point for pact files between teams.

Contract testing is not end-to-end testing. It does not verify business flows or user journeys. It verifies the API contract — that the provider responds to the exact requests the consumer makes, with the exact response shape the consumer expects. Think of it as automated API contract enforcement, not functional integration verification.

Architecture Overview

Consumer Tests
(frontend / downstream service)
Pact File (JSON)
frontend-user-service.json
Pact Broker
(central contract store)
Provider Verification Tests
(run against real provider)
Real Provider Code
(user-service)

The consumer tests run against a Pact-managed mock server (not the real provider). They generate the pact file. The provider tests pull the pact file from the Pact Broker and verify that the real provider code satisfies every interaction defined in it. Both sides publish their results to the Pact Broker, enabling the can-i-deploy command to gate deployments.

Consumer Side in Python

pip install pact-python
# tests/contract/test_user_service_consumer.py
import pytest
import requests
from pact import Consumer, Provider

# Define the consumer/provider relationship
pact = Consumer("frontend-app").has_pact_with(
    Provider("user-service"),
    host_name="localhost",
    port=1234,
    pact_dir="./pacts"  # Where the generated pact JSON will be saved
)


@pytest.fixture(scope="module", autouse=True)
def start_mock_server():
    """Start the Pact mock server before tests and stop after."""
    pact.start_service()
    yield
    pact.stop_service()


def test_get_user_by_id():
    """Define what the frontend expects when it calls GET /users/42."""

    # Define the expected interaction
    (
        pact
        .given("User with ID 42 exists")               # Provider state
        .upon_receiving("a request for user 42")        # Interaction description
        .with_request(
            method="GET",
            path="/api/v1/users/42",
            headers={"Authorization": "Bearer test-token"}
        )
        .will_respond_with(
            status=200,
            headers={"Content-Type": "application/json"},
            body={
                "id": 42,
                "email": "user@example.com",
                "firstName": "Jane",
                "lastName": "Smith",
                "status": "active"
            }
        )
    )

    with pact:
        # Make the actual request to the Pact mock server (not the real provider)
        response = requests.get(
            "http://localhost:1234/api/v1/users/42",
            headers={"Authorization": "Bearer test-token"}
        )

        # Assert the consumer-side code handles the response correctly
        assert response.status_code == 200
        user = response.json()
        assert user["id"] == 42
        assert user["email"] == "user@example.com"
        assert "status" in user


def test_get_nonexistent_user():
    """Define what the frontend expects for a 404 response."""

    (
        pact
        .given("User with ID 99999 does not exist")
        .upon_receiving("a request for a nonexistent user")
        .with_request(method="GET", path="/api/v1/users/99999")
        .will_respond_with(
            status=404,
            body={"error": "User not found", "code": "USER_NOT_FOUND"}
        )
    )

    with pact:
        response = requests.get("http://localhost:1234/api/v1/users/99999")
        assert response.status_code == 404
        assert response.json()["code"] == "USER_NOT_FOUND"


def test_create_user():
    """Define what the frontend expects when creating a new user."""

    (
        pact
        .given("The user service is available")
        .upon_receiving("a request to create a new user")
        .with_request(
            method="POST",
            path="/api/v1/users",
            headers={"Content-Type": "application/json"},
            body={
                "email": "newuser@example.com",
                "firstName": "New",
                "lastName": "User",
                "password": "SecurePass123!"
            }
        )
        .will_respond_with(
            status=201,
            body={
                "id": 43,
                "email": "newuser@example.com",
                "firstName": "New",
                "lastName": "User",
                "status": "active",
                "createdAt": "2026-05-05T10:00:00Z"
            }
        )
    )

    with pact:
        response = requests.post(
            "http://localhost:1234/api/v1/users",
            json={
                "email": "newuser@example.com",
                "firstName": "New",
                "lastName": "User",
                "password": "SecurePass123!"
            },
            headers={"Content-Type": "application/json"}
        )
        assert response.status_code == 201
        assert "id" in response.json()

Running Consumer Tests and Generating the Pact File

# Run consumer tests
pytest tests/contract/ -v

# After a successful run, the pact file is created:
# ./pacts/frontend-app-user-service.json

The generated pact file is a JSON document that encodes all the interactions your consumer tests defined. It looks like:

{
  "consumer": { "name": "frontend-app" },
  "provider": { "name": "user-service" },
  "interactions": [
    {
      "description": "a request for user 42",
      "providerState": "User with ID 42 exists",
      "request": {
        "method": "GET",
        "path": "/api/v1/users/42",
        "headers": { "Authorization": "Bearer test-token" }
      },
      "response": {
        "status": 200,
        "body": { "id": 42, "email": "user@example.com", ... }
      }
    }
  ],
  "metadata": { "pactSpecification": { "version": "2.0.0" } }
}

Pact Broker

The Pact Broker is the central store for pact files. It enables the consumer team to publish pacts and the provider team to retrieve and verify them — without sharing files manually or needing direct repository access across teams.

Self-hosting the Pact Broker

# docker-compose.yml for Pact Broker
version: '3'
services:
  postgres:
    image: postgres:14
    environment:
      POSTGRES_USER: pact
      POSTGRES_PASSWORD: pactpassword
      POSTGRES_DB: pact

  pact-broker:
    image: pactfoundation/pact-broker:latest
    ports:
      - "9292:9292"
    environment:
      PACT_BROKER_DATABASE_URL: postgresql://pact:pactpassword@postgres/pact
      PACT_BROKER_BASIC_AUTH_USERNAME: admin
      PACT_BROKER_BASIC_AUTH_PASSWORD: brokerpassword
    depends_on:
      - postgres

Publishing pacts to the broker

pip install pact-python

# Publish from CI after consumer tests pass
pact-broker publish ./pacts \
    --broker-base-url=http://pact-broker:9292 \
    --consumer-app-version=$(git rev-parse --short HEAD) \
    --branch=$(git branch --show-current) \
    --tag=$(git branch --show-current)

Provider Side in Python

The provider verification test pulls pacts from the broker and verifies them against the running provider service.

# tests/contract/test_user_service_provider.py
import pytest
from pact import Verifier

PROVIDER_BASE_URL = "http://localhost:8000"  # Running provider service
PACT_BROKER_URL = "http://pact-broker:9292"
PACT_BROKER_USER = "admin"
PACT_BROKER_PASS = "brokerpassword"


def test_provider_satisfies_consumer_pacts():
    """Verify the user-service satisfies all consumer pacts."""
    verifier = Verifier(
        provider="user-service",
        provider_base_url=PROVIDER_BASE_URL
    )

    output, _ = verifier.verify_with_broker(
        broker_url=PACT_BROKER_URL,
        broker_username=PACT_BROKER_USER,
        broker_password=PACT_BROKER_PASS,
        provider_states_setup_url=f"{PROVIDER_BASE_URL}/_pact/provider-states",
        publish_verification_results=True,
        provider_app_version="1.2.3",
        verbose=True
    )

    assert output == 0, "Provider verification failed — consumer pacts not satisfied"

Provider state setup endpoint

Provider states (the .given() clauses in consumer tests) require a special endpoint on the provider that sets up the test data needed for each state. In a Flask/FastAPI provider:

# In your provider's test configuration
from fastapi import FastAPI
from your_app.database import create_test_user, delete_all_test_users

provider_app = FastAPI()

@provider_app.post("/_pact/provider-states")
async def setup_provider_state(body: dict):
    state = body.get("state")

    if state == "User with ID 42 exists":
        create_test_user(id=42, email="user@example.com",
                         first_name="Jane", last_name="Smith")

    elif state == "User with ID 99999 does not exist":
        # Ensure this user does not exist
        delete_all_test_users(id=99999)

    elif state == "The user service is available":
        pass  # No setup needed — service is always available in tests

    return {"state": state, "status": "setup complete"}

Consumer Side in Java with pact-jvm

// build.gradle
dependencies {
    testImplementation 'au.com.dius.pact.consumer:junit5:4.6.0'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
// UserServiceConsumerTest.java
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "user-service")
class UserServiceConsumerTest {

    @Pact(consumer = "frontend-app")
    RequestResponsePact getUserById(PactDslWithProvider builder) {
        return builder
            .given("User with ID 42 exists")
            .uponReceiving("a request for user 42")
                .path("/api/v1/users/42")
                .method("GET")
                .headers(Map.of("Authorization", "Bearer test-token"))
            .willRespondWith()
                .status(200)
                .headers(Map.of("Content-Type", "application/json"))
                .body(new PactDslJsonBody()
                    .integerType("id", 42)
                    .stringType("email", "user@example.com")
                    .stringType("firstName", "Jane")
                    .stringType("lastName", "Smith")
                    .stringType("status", "active")
                )
            .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "getUserById")
    void testGetUserById(MockServer mockServer) {
        UserServiceClient client = new UserServiceClient(mockServer.getUrl());
        User user = client.getUserById(42L);

        assertThat(user.getId()).isEqualTo(42L);
        assertThat(user.getEmail()).isEqualTo("user@example.com");
        assertThat(user.getStatus()).isEqualTo("active");
    }
}

Provider Verification in Java

// UserServiceProviderTest.java
@Provider("user-service")
@PactBroker(
    url = "http://pact-broker:9292",
    authentication = @PactBrokerAuth(username = "admin", password = "brokerpassword")
)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserServiceProviderTest {

    @LocalServerPort
    private int port;

    @BeforeEach
    void before(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", port));
    }

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifyPact(PactVerificationContext context) {
        context.verifyInteraction();
    }

    @State("User with ID 42 exists")
    void setupUserWith42() {
        // Insert test data via repository
        userRepository.save(new User(42L, "user@example.com", "Jane", "Smith", "active"));
    }

    @State("User with ID 99999 does not exist")
    void ensureUser99999Missing() {
        userRepository.deleteById(99999L);
    }
}

The can-i-deploy Command

The can-i-deploy command is the critical workflow integration tool that makes contract testing a deployment gate rather than just a test. It queries the Pact Broker to determine whether a specific version of a service can be safely deployed to a given environment, based on whether all its contract verifications have passed.

# Can the frontend-app at this git SHA be deployed to production?
pact-broker can-i-deploy \
    --pacticipant frontend-app \
    --version $(git rev-parse --short HEAD) \
    --to-environment production \
    --broker-base-url http://pact-broker:9292 \
    --broker-username admin \
    --broker-password brokerpassword

# Can the user-service be deployed to staging?
pact-broker can-i-deploy \
    --pacticipant user-service \
    --version 1.2.3 \
    --to-environment staging \
    --broker-base-url http://pact-broker:9292

Exit code 0 means "yes, safe to deploy." Non-zero means "no — a consumer pact is not verified by this version of the provider." Your CI pipeline treats this as a hard gate: the deployment step only runs if can-i-deploy passes.

GitHub Actions CI — Full Contract Testing Pipeline

# .github/workflows/contract-tests.yml
name: Contract Tests

on: [push, pull_request]

jobs:
  consumer-tests:
    name: Consumer — Generate Pact
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install pytest pact-python requests
      - name: Run consumer contract tests
        run: pytest tests/contract/consumer/ -v
      - name: Publish pact to broker
        run: |
          pact-broker publish ./pacts \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
            --broker-username=${{ secrets.PACT_BROKER_USER }} \
            --broker-password=${{ secrets.PACT_BROKER_PASS }} \
            --consumer-app-version=${{ github.sha }} \
            --branch=${{ github.head_ref || github.ref_name }}

  provider-verification:
    name: Provider — Verify Pact
    runs-on: ubuntu-latest
    needs: consumer-tests
    services:
      user-service:
        image: ghcr.io/myorg/user-service:latest
        ports: ['8000:8000']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install pytest pact-python
      - name: Run provider verification
        run: pytest tests/contract/provider/ -v
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}

  can-i-deploy:
    name: can-i-deploy Gate
    runs-on: ubuntu-latest
    needs: provider-verification
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @pact-foundation/pact-node
      - name: Check if safe to deploy
        run: |
          pact-broker can-i-deploy \
            --pacticipant user-service \
            --version ${{ github.sha }} \
            --to-environment staging \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }}

Pact vs Integration Tests vs E2E Tests

Concern Contract Tests (Pact) Integration Tests E2E Tests
What is verified API contract — request/response shape Two services working together end-to-end Full user journey through the system
Environment needed None — each side tests in isolation Both services running simultaneously All services, UI, database, dependencies
Speed Fast — seconds per interaction Medium — minutes Slow — tens of minutes
Flakiness risk Low — deterministic mock responses Medium — service availability, data High — full system, network, timing
Best for Preventing breaking API changes Verifying data flow between services Critical user journeys, smoke tests
Pyramid layer Service layer — just above unit tests Integration layer — middle Top — fewest, highest value tests

Tool Comparison

Tool Language Support Broker Mock Type CI Fit
Pact Python, Java, JS, Go, Ruby, .NET Pact Broker (open source + PactFlow SaaS) Consumer-generated mock server Excellent — can-i-deploy gate
Spring Cloud Contract Java / Kotlin (Spring ecosystem) Maven/Gradle artifact repository Provider-generated stubs (JAR) Good — Spring Boot native
Schemathesis Python None — spec-driven OpenAPI / GraphQL schema Good — CLI-friendly
Manual Integration Tests Any None Real services running Poor — environment complexity

Best Practices

1. Publish pacts on every consumer PR

Consumer pacts should be published to the Pact Broker on every pull request, tagged with the branch name. This allows provider teams to proactively verify their changes against in-progress consumer work before either side merges.

2. Verify pacts on every provider PR

Provider verification should be a mandatory CI step that runs against pacts from all consumers. A provider PR that breaks a consumer contract should fail before merge — not after deployment.

3. Never merge if can-i-deploy fails

The can-i-deploy command should be a hard gate in your deployment pipeline. If it returns non-zero, the deployment is blocked. This is the mechanism that gives contract testing its power — it prevents breaking changes from reaching shared environments.

4. Use meaningful provider states

Provider states like "User with ID 42 exists" are the human-readable link between consumer expectations and provider setup. Make them descriptive and specific. Avoid generic states like "database has data" — they make provider state setup ambiguous and hard to maintain.

From Experience at Amazon: In a project involving multiple internal microservices — device registry, telemetry ingest, and a user-facing status API — end-to-end integration tests were notoriously unreliable. The test environment for all three services together was available less than 70% of the time due to deployment conflicts and data drift. Introducing Pact contract testing between the status API (consumer) and the device registry (provider) allowed both teams to verify their integration in their own CI pipelines with 100% environment availability. Pipeline time for the integration coverage dropped from 35 minutes (shared environment spin-up) to under 4 minutes per service. The stability improvement alone justified the migration.

Back to Blog
From Experience — Virtusa: At Virtusa, Postman was our primary API testing tool across every client engagement. We built environment-variable-driven collections covering dev, staging, and production, with pre-request scripts handling OAuth token refresh automatically. The discipline that made the biggest difference: validating response schemas, not just status codes. This caught three breaking backend changes before they reached QA — each time, a field was renamed or a type changed. Status 200 with wrong data is a silent failure that status-code-only tests never catch.