Contract testing is the only way to verify API integrations without the overhead of brittle end-to-end tests.
Let’s see it in action. Imagine we have two services: OrderService (consumer) and ProductService (provider). The OrderService needs to fetch product details from ProductService to fulfill an order.
Here’s a simplified OrderService code snippet that calls ProductService:
import requests
def get_product_details(product_id):
response = requests.get(f"http://product-service:8080/products/{product_id}")
response.raise_for_status() # Raise an exception for bad status codes
return response.json()
# Example usage
try:
details = get_product_details("PROD-123")
print(f"Product Name: {details['name']}, Price: {details['price']}")
except requests.exceptions.RequestException as e:
print(f"Error fetching product details: {e}")
And here’s a conceptual ProductService endpoint:
// GET /products/{product_id}
{
"id": "PROD-123",
"name": "Wireless Mouse",
"price": 25.99,
"description": "Ergonomic wireless mouse with long battery life."
}
Now, how do we ensure OrderService can correctly consume ProductService’s API without actually deploying both and running an end-to-end test every time we make a change? This is where contract testing shines.
The core idea is to define a "contract" between the consumer (OrderService) and the provider (ProductService). This contract specifies exactly what the consumer expects from the provider.
The Consumer’s Perspective: Defining the Contract
The OrderService (consumer) defines its expectations. Using a tool like Pact, this looks like defining a "pact file."
# consumer/pacts/product_service.json
{
"consumer": { "name": "OrderService" },
"provider": { "name": "ProductService" },
"interactions": [
{
"description": "a request for an existing product",
"request": {
"method": "GET",
"path": "/products/PROD-123",
"headers": { "Accept": "application/json" }
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": {
"id": "PROD-123",
"name": "Wireless Mouse",
"price": 25.99,
"description": "Ergonomic wireless mouse with long battery life."
}
}
}
],
"metadata": {
"pactRustVersion": "0.4.1",
"pactSpecification": { "version": "2.0.0" }
}
}
This JSON file is the contract. It states: "When OrderService makes a GET request to /products/PROD-123 with Accept: application/json, it expects a 200 OK response with Content-Type: application/json and a JSON body containing at least id, name, and price (with specific example values)."
The consumer’s test suite then runs against a "mock provider" that enforces these expectations. If OrderService tries to access a field that isn’t in the mock response, or if the mock doesn’t return what the consumer expects, the consumer’s test fails. This ensures the consumer can handle the data it’s asking for.
The Provider’s Perspective: Verifying the Contract
The ProductService (provider) then takes this pact file and uses it to verify that it actually fulfills the contract. This is done by running a "provider verification" process.
The provider verification tool (also part of Pact) will:
- Read the pact file.
- Make the requests defined in the pact file to the actual
ProductServicerunning locally or in a test environment. - Compare the actual responses from
ProductServiceagainst the expected responses defined in the pact.
If ProductService returns a different status code, different headers, or a JSON body that doesn’t match the structure and types defined in the pact, the provider verification fails. This tells the ProductService team that they’ve broken the contract with OrderService.
The Mental Model: A Shared Understanding
Contract testing creates a shared understanding of how services interact.
- Consumer-Driven: The consumer dictates what it needs. This prevents providers from making breaking changes that consumers aren’t prepared for.
- Independent Verification: Consumer tests verify against a mock provider, and provider tests verify against the real provider using the pact. This means you don’t need to deploy both services simultaneously to test their integration.
- Fast Feedback: Tests run quickly, integrating into CI/CD pipelines. A broken contract is caught early.
The "contract" is the pact file. It’s the single source of truth for the interaction.
What most people miss is that the contract isn’t just about the shape of the data, but also about the types and values within that data. When defining the pact, you specify example values. The provider verification checks that the actual data returned by the provider is compatible with these examples (e.g., if you specify price: 25.99, the provider must return a number for price, not a string like "25.99"). This type checking is crucial for preventing subtle bugs.
The next challenge you’ll encounter is managing pacts across many services and ensuring they are published and retrieved correctly in your CI/CD pipeline.