Imagine you have two microservices: the "Customer" service and the "Product" service. The first one wants to get product info from the second. Sounds simple, but what happens if the team owning the "Product" service decides to change the structure of the returned JSON objects? Yeah — the "Customer" service will start happily breaking. To avoid that mess, we use contracts that help make sure the interaction between services stays "in sync".
What is Pact?
Pact is a contract testing framework that helps ensure two microservices (or a client/server) interact the way they promised. Pact works on a "consumer-provider" model:
- Consumer (Consumer) creates a contract that describes how it expects the provider to behave.
- Provider (Provider) verifies that its functionality matches the contract and updates the contract if things change.
Example simple Pact flow:
[Consumer service -> Generates contract -> Contract stored -> Provider verifies contract]
Task: Let's implement contract testing
For our example we'll have:
- Consumer — the microservice
CustomerService, which calls the API ofProductService. - Provider — the microservice
ProductService, which serves product data.
Goal: write tests for CustomerService and make sure ProductService conforms to the contract.
Step 1: Prepare the environment
To work with Pact in Gradle add the following dependencies:
dependencies {
testImplementation 'au.com.dius.pact.provider:junit5:4.6.2'
testImplementation 'au.com.dius.pact.consumer:junit5:4.6.2'
}
Now we have everything to work with Pact on both the consumer and provider sides.
Step 2: Write a contract for the consumer
Create a test class CustomerServicePactTest where we'll describe the interaction with ProductService.
import au.com.dius.pact.consumer.Pact;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ExtendWith(PactConsumerTestExt.class)
public class CustomerServicePactTest {
@Pact(consumer = "CustomerService", provider = "ProductService")
public Map<String, Object> createPact(PactDslWithProvider builder) {
return builder
.given("Product with ID 1 exists")
.uponReceiving("request to get product info")
.path("/products/1")
.method("GET")
.willRespondWith()
.status(200)
.body("{\"id\": 1, \"name\": \"Laptop\", \"price\": 999.99}")
.toPact();
}
@Test
@PactTestFor(providerName = "ProductService", port = "8080")
void testGetProduct() {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.getForEntity("http://localhost:8080/products/1", String.class);
assertEquals(200, response.getStatusCodeValue());
assertEquals("{\"id\": 1, \"name\": \"Laptop\", \"price\": 999.99}", response.getBody());
}
}
What's going on here:
- In the
createPactmethod we describe the contract:- Given that a Product with ID 1 exists
- If the client requests
/products/1, return a JSON object with product data
- In the
testGetProducttest we check thatCustomerServicesends the request correctly and gets the expected response.
This test generates a contract that is written out as a JSON file.
Example of the contract:
{
"provider": { "name": "ProductService" },
"consumer": { "name": "CustomerService" },
"interactions": [
{
"description": "request to get product info",
"request": {
"method": "GET",
"path": "/products/1"
},
"response": {
"status": 200,
"body": { "id": 1, "name": "Laptop", "price": 999.99 }
}
}
]
}
Step 3: Verify the contract on the provider side
Now move to ProductService. Here we'll make sure the API matches the contract.
Create a test class ProductServicePactTest:
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(PactVerificationInvocationContextProvider.class)
public class ProductServicePactTest {
@BeforeEach
void before(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", 8080));
}
@Test
void pactVerificationTest(PactVerificationContext context) {
context.verifyInteraction();
}
}
The key points here:
HttpTestTargettells where our provider API is hosted (localhost:8080).- The
verifyInteractionmethod loads the contract we generated for the consumer and checks thatProductServicesatisfies the described interactions.
Step 4: Run everything together
- Bring up
ProductServiceonlocalhost:8080. Make sure it returns the correct data for/products/1. - Run the
CustomerServicePactTestfirst to generate the contract. - Then run
ProductServicePactTestto verify the provider.
Error handling and common issues
- Provider API changes: if the
ProductServiceteam changes the API, provider tests will start failing. That's a signal — update the contract and coordinate the change with the consumer. - Data mismatch in the contract: if the provider returns extra fields, that's not necessarily a failure, but you should discuss it with the consumer team.
- Multiple contracts: if
ProductServiceserves multiple consumers, each consumer's contract should be verified separately.
Why this matters in practice
Contract testing helps catch integration issues between microservices before they hit production. This is especially important for teams working on different services, where changes on one side can silently "break" the other side.
Contracts are also great for CI/CD: you can run these tests as part of your build pipeline to automate verification of changes.
Congrats! Now you know how to write Pact contract tests. This is an important step toward reliable and robust microservice systems. Here's to stable integrations!
GO TO FULL VERSION