In a microservices architecture, services communicate via APIs. Traditional integration testing, where you test the actual communication between live services, becomes complex, slow, and brittle. How can you ensure that service integrations will work in production without deploying all services simultaneously? The answer is Contract Testing, and Pact is one of its most popular implementations.
This article explores how to implement Contract Testing with Pact in the Java ecosystem, enabling teams to develop and deploy independently while maintaining confidence in their service interactions.
What is Contract Testing?
Contract Testing is a technique for ensuring that two applications (typically a consumer and a provider) can communicate correctly with each other. Instead of testing the actual services, it focuses on verifying the contract between them—the structure of the HTTP requests and responses.
The Core Idea: The consumer and provider each test against a shared, versioned contract, not against each other directly.
How Pact Implements Contract Testing
Pact follows a specific workflow:
- Consumer-Driven Contracts: The consumer defines the expected interactions in a test.
- Pact File Generation: These expectations are written to a JSON file (the Pact file).
- Provider Verification: The provider verifies that its actual API implementation meets the expectations in the Pact file.
This creates a "trust but verify" relationship. The consumer states its needs, and the provider proves it meets them.
Key Concepts
- Consumer: The service that initiates the HTTP request (e.g., a web front-end or API client).
- Provider: The service that responds to the HTTP request (e.g., a backend API).
- Pact File: A JSON file that records all the expected interactions between a specific consumer and provider.
- Pact Broker: A service for sharing Pact files and tracking verification results across teams (highly recommended for production use).
Implementing Pact in Java: A Step-by-Step Example
Let's model a simple scenario: an Order Service (consumer) needs to get user details from a User Service (provider).
Project Setup (Maven)
Both consumer and provider projects need the Pact dependencies.
<!-- For Consumer Tests (JUnit 5) --> <dependency> <groupId>au.com.dius.pact.consumer</groupId> <artifactId>junit5</artifactId> <version>4.6.8</version> <!-- Check for latest version --> <scope>test</scope> </dependency> <!-- For Provider Tests (JUnit 5) --> <dependency> <groupId>au.com.dius.pact.provider</groupId> <artifactId>junit5</artifactId> <version>4.6.8</version> <scope>test</scope> </dependency> <!-- Also include your preferred HTTP client (e.g., Spring WebClient, OkHttp) -->
Step 1: Consumer-Side Testing
The Order Service defines what it expects from the User Service.
Model Class:
public class User {
private String id;
private String name;
private String email;
private boolean active;
// Default constructor, getters, and setters are REQUIRED
public User() {}
public User(String id, String name, String email, boolean active) {
this.id = id;
this.name = name;
this.email = email;
this.active = active;
}
// Getters and Setters...
}
Client Class:
import org.springframework.web.client.RestTemplate;
public class UserServiceClient {
private final String baseUrl;
private final RestTemplate restTemplate;
public UserServiceClient(String baseUrl, RestTemplate restTemplate) {
this.baseUrl = baseUrl;
this.restTemplate = restTemplate;
}
public User getUserById(String userId) {
String url = baseUrl + "/users/" + userId;
return restTemplate.getForObject(url, User.class);
}
}
Pact Consumer Test:
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 au.com.dius.pact.core.model.RequestResponsePact;
import au.com.dius.pact.core.model.annotations.Pact;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.web.client.RestTemplate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@ExtendWith(PactConsumerTestExt.class)
public class UserServiceConsumerPactTest {
// 1. Define the expected interaction and generate the Pact file
@Pact(provider = "user-service", consumer = "order-service")
public RequestResponsePact getUserByIdPact(PactDslWithProvider builder) {
return builder
.given("user with ID 123 exists")
.uponReceiving("a request for user with ID 123")
.path("/users/123")
.method("GET")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body("""
{
"id": "123",
"name": "John Doe",
"email": "[email protected]",
"active": true
}
""")
.toPact();
}
// 2. Test our client against the mock provider defined in the pact
@Test
@PactTestFor(pactMethod = "getUserByIdPact")
void testGetUserById(String baseUrl) {
// Arrange
RestTemplate restTemplate = new RestTemplate();
UserServiceClient client = new UserServiceClient(baseUrl, restTemplate);
// Act
User user = client.getUserById("123");
// Assert
assertNotNull(user);
assertEquals("123", user.getId());
assertEquals("John Doe", user.getName());
assertEquals("[email protected]", user.getEmail());
assertEquals(true, user.isActive());
}
}
Running the Consumer Test:
- This test starts a mock provider server that behaves according to the pact.
- If the test passes, a Pact file (
order-service-user-service.json) is generated in thetarget/pactsdirectory. - This JSON file is the contract that will be shared with the provider team.
Step 2: Provider-Side Verification
The User Service now needs to verify it can fulfill the contract.
Provider Controller:
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{userId}")
public ResponseEntity<User> getUserById(@PathVariable String userId) {
// Real business logic - could be from a database
if ("123".equals(userId)) {
User user = new User("123", "John Doe", "[email protected]", true);
return ResponseEntity.ok(user);
} else {
return ResponseEntity.notFound().build();
}
}
}
Provider Verification Test:
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.State;
import au.com.dius.pact.provider.junitsupport.loader.PactFolder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
@Provider("user-service") // Must match the provider name in the pact file
@PactFolder("pacts") // Location of pact files (could be from a Pact Broker)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserServiceProviderPactTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
// State method - sets up the provider state before verification
@State("user with ID 123 exists")
public void setupUser123() {
// Setup code to ensure a user with ID 123 exists
// This might involve inserting test data into a test database
System.out.println("Setting up state: user with ID 123 exists");
}
}
Running the Provider Test:
- The test starts the actual User Service application.
- For each interaction in the Pact file, it makes actual HTTP requests to the running service.
- If all interactions pass, the contract is considered verified.
The Pact Broker: Scaling Contract Testing
For real-world applications with multiple teams, manually sharing Pact files is impractical. The Pact Broker solves this.
Benefits:
- Central storage for Pact files
- Web UI for visualizing contracts
- Deployment integration (can you deploy without breaking consumers?)
- Version tracking and tagging
Using the Pact Broker:
Consumer Side (publish pacts):
// In build.gradle or via Maven plugin
// Gradle example:
pact {
publish {
pactDirectory = 'target/pacts'
pactBrokerUrl = 'https://your-pact-broker.com'
// Use authentication in real scenarios
pactBrokerUsername = project.property('pactBrokerUser')
pactBrokerPassword = project.property('pactBrokerPassword')
}
}
Provider Side (fetch from broker):
@PactBroker(
host = "your-pact-broker.com",
scheme = "https",
authentication = @PactBrokerAuth(username = "${pact.broker.user}", password = "${pact.broker.password}")
)
public class UserServiceProviderPactTest {
// Test class remains the same
}
Best Practices and Pitfalls
- Consumer-Driven: Contracts should be driven by consumer needs, not provider capabilities.
- Meaningful Provider States: Use provider states to set up specific data conditions for reproducible tests.
- Test Data Management: Providers need strategies for managing test data without polluting production databases.
- CI/CD Integration:
- Consumer: Run Pact tests and publish pacts on successful builds.
- Provider: Fetch latest pacts from consumers and verify on each build.
- Versioning: Use semantic versioning for both consumers and providers.
- Don't Over-specify: Only specify what you need in the contract. Avoid matching on dynamic data like timestamps unless necessary.
When to Use Pact
- Microservices Architecture with multiple independent teams
- Public APIs where you want to avoid breaking changes
- Any HTTP-based service communication that needs reliability
- When end-to-end tests are too slow or flaky
Conclusion
Contract Testing with Pact provides a robust solution to one of the biggest challenges in microservices: ensuring independent deployability without breaking integrations. By shifting testing left and creating executable specifications in the form of Pact files, teams can:
- Develop and deploy independently
- Detect breaking changes before they reach production
- Reduce reliance on slow, flaky end-to-end tests
- Improve communication between consumer and provider teams
While there's an initial learning curve and setup cost, the long-term benefits of increased deployment confidence and reduced integration issues make Pact an invaluable tool in the modern Java microservices toolkit.