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:
- Environment complexity: Getting 8 services to run together consistently requires orchestration infrastructure that itself becomes a maintenance burden
- Slow feedback: Integration test suites that take 40 minutes to run discourage frequent execution and slow down development cycles
- Brittle tests: A bug or deployment failure in any one service breaks the entire integration suite — including tests for services that are unrelated to the failure
- Unclear ownership: When an integration test fails, which team owns the fix? This ambiguity leads to tests being ignored
- No isolation: You cannot test the frontend's behavior in isolation from the backend — they rise and fall together
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:
- The consumer (the service that calls the API) defines what it needs — the specific requests it makes and the minimum response it expects
- This definition is saved as a pact (a JSON contract file)
- 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.
Architecture Overview
(frontend / downstream service)
frontend-user-service.json
(central contract store)
(run against real provider)
(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.
Back to Blog