Contract Testing – what is it and why do you need it
Testing is a multilayered engineering activity required at many levels in the software architecture and development process. However, as companies look to design systems in a more modular fashion to allow for more rapid development and independent deployment cycles between delivery teams and services, the importance of contract testing has risen.
Contract testing is software testing that focuses on verifying and ensuring that two separate systems (usually services in a microservices architecture) can communicate with each other according to a predefined agreement, known as a "contract." This contract typically defines the expectations of the communication, including the structure of the requests and responses and the data types and values exchanged between the services.
This approach enables teams to create comprehensive automated tests for their services, incorporating effective mocking based on agreed-upon contracts. By doing so, teams can deploy with a high level of confidence, knowing that their dependencies haven't changed unexpectedly—otherwise, the contract tests would catch and flag these issues.
This method allows teams to develop software independently and with greater trust, while also alerting them to any unforeseen changes in dependencies, enabling them to address these proactively. Additionally, it significantly reduces the need for extensive and complex end-to-end testing. Although contract testing doesn't eliminate the need for end-to-end tests, it ensures that only minimal smoke-level integration tests are necessary, as the full functionality would already have been thoroughly validated within the pipelines using mocks.
Contract testing offers a structured, efficient, and scalable approach to ensuring that services in a distributed system can interact correctly and reliably. It provides significant advantages in terms of early issue detection, team independence, and overall system stability, making it a key practice for organizations adopting microservices or other service-oriented architectures.
Key Aspects of Contract Testing
Below are the key aspects of Contract Testing that it is important to know:
What problems does it solve:
Contract testing addresses several critical challenges in software development, particularly in environments where multiple services or components need to interact with one another. Here’s a breakdown of the key problems contract testing solves:
Integration Breakages Due to API Changes
Lack of Clear Communication and Expectations Between Services
Slow and Fragile End-to-End Testing
Difficulty in Isolating and Diagnosing Integration Issues
Dependency Hell in Service Development
Regression Risks with Continuous Deployment
Challenges in Scaling Testing Across Large, Distributed Systems
Lack of Confidence in Service Integrations
Contract testing solves the critical problem of ensuring reliable and consistent communication between services in a distributed system. It addresses the challenges of integration breakages, unclear expectations, slow testing processes, and scaling in large systems, making it an essential practice for maintaining the health and stability of service-oriented architectures.
Benefits of Contract Testing
We’ve looked at the reason why it is needed and the problems Contract Testin can solve. Alongside this, though Contract testing offers several significant benefits, especially in complex, distributed systems like microservices architectures. Let’s delve deeper into these benefits:
Early Detection of Integration Issues
Decoupling of Development Teams
Faster Feedback Loops
Reduced Need for Extensive End-to-End Tests
Enhanced Communication and Collaboration
Improved Reliability and Stability
Documentation and Traceability
Cost and Time Efficiency
Supports Microservices and Distributed Architectures
Improved Consumer Confidence
How to Implement Contract Testing
Below I will provide an example of how you can implement contract testing in your area with a specific example between two services. In this example, we will explain the difference between the two services what the contract will look like and provide some scripting examples of what the tests themselves will look like.
Example Scenario
For this example, we will consider the following two services:
The Contract
The contract between Service A and Service B might specify:
Here’s a sample contract in JSON code:
{
"request": {
"method": "GET",
"path": "/user/123",
"headers": {
"Accept": "application/json"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"id": "123",
"name": "John Doe",
"email": "[email protected]"
}
}
}
Writing Contract Test Cases
There are many different tools available for contract testing, but for this example, we'll use Pact, which is a popular tool for consumer-driven contract testing. Look out for a future article about Pact.
Step 1: Writing Consumer Tests (Service A)
Service A will define its expectations in a contract. These expectations are written as unit tests using a Pact library.
Here’s an example using Pact with Java:
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.PactProviderRule;
import au.com.dius.pact.consumer.PactVerification;
import au.com.dius.pact.consumer.PactVerificationResult;
import au.com.dius.pact.consumer.dsl.PactDslJsonBody;
import org.junit.Rule;
import org.junit.Test;
import static io.restassured.RestAssured.given;
import static org.junit.Assert.assertTrue;
public class UserServicePactTest {
@Rule
public PactProviderRule mockProvider = new PactProviderRule("UserService", this);
@Pact(consumer = "UserFrontend")
public RequestResponsePact createPact(PactDslWithProvider builder) {
return builder
.given("User 123 exists")
.uponReceiving("A request for user 123")
.path("/user/123")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("id", "123")
.stringType("name", "John Doe")
.stringType("email", "[email protected]"))
.toPact();
}
@Test
@PactVerification
public void runTest() {
// Act: Making a request to the mock provider
String response = given()
.when()
.get(mockProvider.getUrl() + "/user/123")
.then()
.extract()
.asString();
// Assert: Validate the response
assertTrue(response.contains("John Doe"));
}
}
Step 2: Writing Provider Tests (Service B)
Service B will verify that it meets the expectations defined in the contract. This is done by running the contract against the actual service implementation.
Here’s an example using Pact with Spring Boot:
import au.com.dius.pact.provider.junit.PactProviderRule;
import au.com.dius.pact.provider.junit.State;
import au.com.dius.pact.provider.junit.loader.PactBroker;
import au.com.dius.pact.provider.junit.loader.PactFolder;
import au.com.dius.pact.provider.junit.provider.PactVerification;
import au.com.dius.pact.provider.junit.provider.PactVerificationInvocationContextProvider;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@PactFolder("pacts") // or use @PactBroker(url = "http://broker.url")
public class UserServiceProviderTest {
@Rule
public PactProviderRule provider = new PactProviderRule("UserService", this);
@State("User 123 exists")
public void toUser123ExistsState() {
// Set up data or mocks for user 123
}
@Test
@PactVerification(value = "UserService")
public void verifyContract() {
// This will verify if the provider meets the expectations of the consumer
}
}
Workflow
Benefits of This Approach
Tools for Contract Testing
There are many different tools that can be used for contract testing, though below are some of the biggest ones.
Pact
Spring Cloud Contract
Postman
Hoverfly
Contract First
Summary
Contract testing solves the critical problem of ensuring reliable and consistent communication between services in a distributed system. It addresses the challenges of integration breakages, unclear expectations, slow testing processes, and scaling in large systems, making it an essential practice for maintaining the health and stability of service-oriented architectures.