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.