Contract Testing for Microservices

Reverend Philip Dec 29, 2025 11 min read

Verify API compatibility between services with contract tests. Implement consumer-driven contracts using Pact.

Microservices communicate through APIs, and breaking changes cause outages. Contract testing verifies that services honor their API agreements without requiring integration tests that spin up all dependencies.

The Integration Testing Problem

Traditional Approach

Traditional integration testing requires starting every service in your system to verify that communication works correctly. This approach quickly becomes unmanageable as your system grows.

The following illustrates the setup required for traditional integration tests. Each service dependency must be running, along with all infrastructure components. This creates a fragile and time-consuming test environment.

To test Service A:
1. Start Service A
2. Start Service B (dependency)
3. Start Service C (dependency)
4. Start Database
5. Start Message Queue
6. Run tests
7. Debug which service broke

Problems:

  • Slow setup
  • Flaky tests
  • Hard to isolate failures
  • Version coordination nightmare

Contract Testing

Consumer-Driven Contracts

Contract testing flips the traditional approach. Instead of testing the full integration, consumers define what they need from providers. The provider then verifies it can fulfill those contracts. This decouples testing while ensuring compatibility.

This diagram shows the basic flow of consumer-driven contract testing. The consumer specifies its expectations, and the provider verifies it meets them. Neither service needs to know about the other during testing.

Consumer (Service A) defines:
"I expect the Users API to return { id, name, email }"

Provider (Users Service) verifies:
"My API returns at least { id, name, email }"

Contract is the shared agreement

Pact Framework

Pact is the most popular contract testing tool. It generates contract files from consumer tests, which providers then verify against their actual implementation.

Consumer generates → Contract (Pact file) → Provider verifies

Consumer Side

Writing Consumer Tests

The consumer side writes tests that describe what it expects from the provider. These tests run against a mock server that records the interactions. The recorded interactions become the contract.

This test class demonstrates the complete consumer testing pattern. You define the expected request and response, then make actual HTTP calls through your client code. Pact records these interactions and generates the contract file.

// tests/Contract/UserServiceConsumerTest.php
use PhpPact\Consumer\InteractionBuilder;
use PhpPact\Consumer\Model\ConsumerRequest;
use PhpPact\Consumer\Model\ProviderResponse;
use PhpPact\Standalone\MockService\MockServerConfig;

class UserServiceConsumerTest extends TestCase
{
    private InteractionBuilder $builder;
    private MockServerConfig $config;

    protected function setUp(): void
    {
        parent::setUp();

        $this->config = new MockServerConfig();
        $this->config
            ->setConsumer('OrderService')
            ->setProvider('UserService')
            ->setPactDir(__DIR__ . '/../../pacts');

        $this->builder = new InteractionBuilder($this->config);
    }

    public function test_get_user_by_id(): void
    {
        // Define expected interaction
        $request = new ConsumerRequest();
        $request
            ->setMethod('GET')
            ->setPath('/api/users/123')
            ->addHeader('Accept', 'application/json');

        $response = new ProviderResponse();
        $response
            ->setStatus(200)
            ->addHeader('Content-Type', 'application/json')
            ->setBody([
                'id' => 123,
                'name' => 'John Doe',
                'email' => 'john@example.com',
            ]);

        $this->builder
            ->uponReceiving('a request for user 123')
            ->with($request)
            ->willRespondWith($response);

        // Make request against mock server
        $client = new UserServiceClient($this->config->getBaseUri());
        $user = $client->getUser(123);

        // Verify response
        $this->assertEquals(123, $user->id);
        $this->assertEquals('John Doe', $user->name);
        $this->assertEquals('john@example.com', $user->email);

        // Verify all interactions occurred
        $this->builder->verify();
    }

    public function test_user_not_found(): void
    {
        $request = new ConsumerRequest();
        $request
            ->setMethod('GET')
            ->setPath('/api/users/999');

        $response = new ProviderResponse();
        $response
            ->setStatus(404)
            ->setBody(['error' => 'User not found']);

        $this->builder
            ->uponReceiving('a request for non-existent user')
            ->with($request)
            ->willRespondWith($response);

        $client = new UserServiceClient($this->config->getBaseUri());

        $this->expectException(UserNotFoundException::class);
        $client->getUser(999);

        $this->builder->verify();
    }

    protected function tearDown(): void
    {
        // Write pact file
        $this->builder->finalize();
        parent::tearDown();
    }
}

The key is that your actual client code makes real HTTP requests to the mock server. This ensures your client code correctly handles the expected responses.

Generated Pact File

When consumer tests pass, Pact generates a JSON file describing all interactions. This file becomes the contract that providers must fulfill. It includes the exact request structure and expected response.

The generated Pact file contains machine-readable descriptions of every interaction your consumer expects. This file is shared with the provider team, typically through a Pact Broker.

{
  "consumer": { "name": "OrderService" },
  "provider": { "name": "UserService" },
  "interactions": [
    {
      "description": "a request for user 123",
      "request": {
        "method": "GET",
        "path": "/api/users/123",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "body": {
          "id": 123,
          "name": "John Doe",
          "email": "john@example.com"
        }
      }
    },
    {
      "description": "a request for non-existent user",
      "request": {
        "method": "GET",
        "path": "/api/users/999"
      },
      "response": {
        "status": 404,
        "body": { "error": "User not found" }
      }
    }
  ]
}

The contract is readable and can be reviewed by both teams. This transparency helps catch misunderstandings early in the development process.

Provider Side

Verifying Contracts

The provider side runs its actual application against the contract file. Pact replays each interaction and verifies the real responses match what consumers expect. This ensures the provider has not broken any consumer expectations.

This test demonstrates provider verification. You start your actual application, set up the required test data, and let Pact verify each interaction from the contract file.

// tests/Contract/UserServiceProviderTest.php
use PhpPact\Standalone\ProviderVerifier\Model\VerifierConfig;
use PhpPact\Standalone\ProviderVerifier\Verifier;

class UserServiceProviderTest extends TestCase
{
    public function test_provider_honors_consumer_contracts(): void
    {
        // Start your actual application
        $this->startApplication();

        // Set up test data that matches contract expectations
        User::factory()->create([
            'id' => 123,
            'name' => 'John Doe',
            'email' => 'john@example.com',
        ]);

        $config = new VerifierConfig();
        $config
            ->setProviderName('UserService')
            ->setProviderBaseUrl('http://localhost:8000')
            ->setPactUrl(__DIR__ . '/../../pacts/orderservice-userservice.json');

        $verifier = new Verifier($config);
        $verifier->verify();

        // If we get here, provider satisfies the contract
        $this->assertTrue(true);
    }
}

Note that the provider test runs against the real application, not mocks. This catches actual implementation bugs that would otherwise only appear in production.

Provider States

Handle test data setup for different scenarios. Provider states let consumers specify preconditions for interactions. The provider implements handlers that set up the required state before verification.

This state handler creates the specific data needed for each test scenario. When the consumer specifies a state like "user 123 exists", the handler ensures that user exists in the test database.

// Provider state handler
class PactProviderStateHandler
{
    public function handle(string $state): void
    {
        match ($state) {
            'user 123 exists' => $this->createUser123(),
            'user 999 does not exist' => $this->ensureNoUser999(),
            'user has orders' => $this->createUserWithOrders(),
            default => throw new UnknownStateException($state),
        };
    }

    private function createUser123(): void
    {
        User::factory()->create([
            'id' => 123,
            'name' => 'John Doe',
            'email' => 'john@example.com',
        ]);
    }

    private function ensureNoUser999(): void
    {
        User::where('id', 999)->delete();
    }
}

Provider states make tests deterministic by ensuring the database contains exactly the data needed for each interaction.

Updated consumer test with provider state:

$this->builder
    ->given('user 123 exists')  // Provider state
    ->uponReceiving('a request for user 123')
    ->with($request)
    ->willRespondWith($response);

Pact Broker

Centralized Contract Storage

As your system grows, managing contract files becomes challenging. The Pact Broker provides centralized storage and tracks which versions of services are compatible with each other.

Consumer CI → Publish Pact → Pact Broker ← Verify Pact ← Provider CI

Publishing Contracts

After consumer tests pass, publish the generated contract to the broker. Include the git commit hash as the version to enable precise compatibility tracking.

The following command publishes your pact files to the broker with version information. This creates a permanent record of what your consumer expected at each commit.

# In consumer CI pipeline
pact-broker publish ./pacts \
  --consumer-app-version=$(git rev-parse HEAD) \
  --broker-base-url=https://pact-broker.example.com \
  --broker-token=$PACT_TOKEN

Verifying Latest Contracts

Providers can configure verification to automatically fetch the latest contracts from the broker. Publishing verification results back to the broker creates a complete compatibility matrix.

$config = new VerifierConfig();
$config
    ->setProviderName('UserService')
    ->setProviderBaseUrl('http://localhost:8000')
    ->setBrokerUri('https://pact-broker.example.com')
    ->setBrokerToken(getenv('PACT_TOKEN'))
    ->setPublishResults(true)
    ->setProviderVersion(getenv('GIT_COMMIT'));

Can I Deploy?

The killer feature of the Pact Broker is the "can I deploy" check. Before deploying any service, you can verify that the new version is compatible with all services currently in production.

This single command queries the Pact Broker to determine whether your version is safe to deploy. It checks all consumer contracts and provider verifications to ensure compatibility.

# Check if it's safe to deploy
pact-broker can-i-deploy \
  --pacticipant=UserService \
  --version=$(git rev-parse HEAD) \
  --to-environment=production

# Returns exit code 0 if safe, 1 if not

This single command prevents deploying breaking changes. It should be a required step in every deployment pipeline.

CI/CD Integration

Consumer Pipeline

The consumer pipeline runs contract tests, publishes the pact, and checks deployment safety. This ensures consumers do not deploy with expectations that providers cannot fulfill.

The following workflow demonstrates a complete consumer CI setup. After tests pass, it publishes the pact and verifies deployment safety before allowing the pipeline to proceed.

# .github/workflows/consumer.yml
name: Consumer CI

on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run contract tests
        run: php artisan test --filter=Contract

      - name: Publish pacts
        run: |
          pact-broker publish ./pacts \
            --consumer-app-version=${{ github.sha }} \
            --broker-base-url=${{ secrets.PACT_BROKER_URL }} \
            --broker-token=${{ secrets.PACT_TOKEN }}

      - name: Can I deploy?
        run: |
          pact-broker can-i-deploy \
            --pacticipant=OrderService \
            --version=${{ github.sha }} \
            --to-environment=production

Provider Pipeline

The provider pipeline verifies contracts and publishes results. Webhooks can trigger provider verification whenever a consumer publishes a new contract, ensuring rapid feedback on breaking changes.

This provider workflow starts the application, runs contract verification, and publishes results back to the broker. The workflow can be triggered manually or by webhooks from the Pact Broker.

# .github/workflows/provider.yml
name: Provider CI

on:
  push:
  workflow_dispatch:  # Allow manual trigger

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start application
        run: php artisan serve &

      - name: Verify contracts
        run: |
          php artisan test --filter=ProviderContract
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_TOKEN: ${{ secrets.PACT_TOKEN }}
          PROVIDER_VERSION: ${{ github.sha }}

Webhooks for Contract Changes

Configure the Pact Broker to trigger provider builds when consumers publish new contracts. This creates a fast feedback loop where providers learn about new consumer expectations within minutes.

This webhook configuration tells the Pact Broker to trigger a GitHub Actions workflow whenever a consumer publishes a changed contract. The provider team learns immediately when expectations change.

# Pact Broker webhook configuration
{
  "events": ["contract_content_changed"],
  "request": {
    "method": "POST",
    "url": "https://api.github.com/repos/org/user-service/dispatches",
    "headers": {
      "Authorization": "Bearer ${GITHUB_TOKEN}",
      "Accept": "application/vnd.github.v3+json"
    },
    "body": {
      "event_type": "contract_changed",
      "client_payload": {
        "pact_url": "${pactbroker.pactUrl}"
      }
    }
  }
}

Best Practices

Keep Contracts Minimal

Over-specifying contracts makes them brittle. Only include fields that your consumer actually uses. Adding fields you do not need couples you unnecessarily to provider implementation details.

Compare these two approaches to specifying the response body. The first includes fields the consumer does not use, creating unnecessary coupling. The second specifies only what matters.

// Bad: Over-specified contract
$response->setBody([
    'id' => 123,
    'name' => 'John Doe',
    'email' => 'john@example.com',
    'created_at' => '2024-01-15T10:30:00Z',  // Exact timestamp
    'updated_at' => '2024-01-15T10:30:00Z',
    'metadata' => [...],  // Fields consumer doesn't use
]);

// Good: Only fields consumer actually uses
$response->setBody([
    'id' => Matcher::integer(123),
    'name' => Matcher::string('John Doe'),
    'email' => Matcher::email('john@example.com'),
]);

Use Matchers

Matchers make contracts flexible by specifying the type and format rather than exact values. This prevents false failures when providers return different but valid data.

The following demonstrates various matchers available in Pact. Instead of requiring exact values, matchers verify the structure and format of responses, making tests more resilient.

use PhpPact\Consumer\Matcher\Matcher;

$matcher = new Matcher();

$response->setBody([
    'id' => $matcher->integer(),
    'name' => $matcher->string(),
    'email' => $matcher->regex('.*@.*', 'test@example.com'),
    'created_at' => $matcher->datetime("Y-m-d'T'H:i:s'Z'"),
    'items' => $matcher->eachLike([
        'product_id' => $matcher->integer(),
        'quantity' => $matcher->integer(),
    ]),
]);

The eachLike matcher is particularly useful for arrays. It specifies the structure that each element must have, regardless of how many elements the provider returns.

Version Your APIs

When you have multiple API versions, create separate contracts for each. This allows consumers to migrate at their own pace while maintaining backward compatibility.

// Different contracts for different API versions
$this->builder
    ->given('user 123 exists')
    ->uponReceiving('a v2 request for user 123')
    ->with((new ConsumerRequest())
        ->setMethod('GET')
        ->setPath('/api/v2/users/123'))
    ->willRespondWith($v2Response);

Conclusion

Contract testing catches API incompatibilities before deployment without the overhead of full integration tests. Consumers define their expectations, providers verify they meet them, and the Pact Broker coordinates the whole workflow. Integrate contract testing into CI/CD to catch breaking changes early. The "can I deploy" check becomes your safety net, ensuring services remain compatible across the entire system.

Share this article

Related Articles

Distributed Locking Patterns

Coordinate access to shared resources across services. Implement distributed locks with Redis, ZooKeeper, and databases.

Jan 16, 2026

Need help with your project?

Let's discuss how we can help you build reliable software.