Contract testing

Michał Koźmiński
Beekeeper Technology Blog
6 min readJun 6, 2023

--

Why?

In today’s world of software development, ensuring that your product is reliable, efficient, and error-free is critical. Contract testing is a testing methodology that has become increasingly popular in the world of microservices architecture. Microservices are small, independently deployable services that communicate with each other over APIs. One of the challenges of developing and maintaining a microservices-based system like the one we use in Beekeeper [small plug] is ensuring that each service communicates correctly with the other services. Contract testing is an effective way to address this challenge by ensuring that the interactions between services are tested thoroughly and efficiently. In this article, we’ll explore the concept of contract testing and how it can be used to test microservices.

When to implement contract testing

Firstly, contract testing ensures that different components or services in an application communicate correctly. In a software application, components and services often communicate with each other through APIs or other interfaces. Contract testing verifies that these interfaces work correctly, ensuring that the components and services can communicate seamlessly. This, in turn, ensures that the application works as intended, and users can use it without any issues.

Contract testing can also help avoid errors when integrating third-party components or services. It is a testing methodology that is often compared to integration testing. While both types of testing involve testing the interactions between different services or components, they have some key differences.

How does it work?

            +---------------------+           +---------------------+
| Consumer Service | | Provider Service |
+---------------------+ +---------------------+
| |
| Sends Request |
| |
+---------------------+ +---------------------+
| Contract Test | | Contract Test |
| (Consumer Side) | | (Provider Side) |
+---------------------+ +---------------------+
| |
| Verified Response |
| |
+---------------------+ +---------------------+
| Provider API | | Provider API |
+---------------------+ +---------------------+

Integration testing typically involves testing the interactions between multiple components or services in a system. In a microservices architecture, this might involve testing how different services interact with each other over APIs or messaging systems. Integration tests are often implemented as end-to-end tests, which test the entire system from the user interface down to the database. Integration tests can be useful for identifying issues with the overall system architecture, but they can also be slow and brittle, as they require the entire system to be set up and configured for testing.

Contract testing, on the other hand, focuses on testing the interactions between individual services or components. In a microservices architecture, this might involve testing how a consumer service interacts with a provider service over an API. Contract tests are often implemented as unit tests, which test a specific unit of code in isolation. Contract tests are typically faster and more reliable than integration tests, as they only test a small piece of code and do not require the entire system to be set up and configured for testing.

Benefits of using contract testing

One of the main benefits of contract testing over integration testing is that it can help identify issues with service contracts before they become problems in the overall system. By testing the interactions between services at the contract level, developers can ensure that their services are communicating correctly and that they are conforming to the expected behavior. This can help prevent issues such as mismatched data formats or incompatible APIs, which can cause problems in the overall system.

Secondly, contract testing reduces the risk of errors and failures. As software applications become more complex, the risk of errors and failures increases. Contract testing catches errors early on in the development process, which reduces the risk of failures when the application is deployed. This means that developers can identify and fix issues before they become major problems, saving time and money in the long run.

Thirdly, contract testing enables faster delivery of software applications. When components and services are tested independently, it allows for faster development and testing of the application as a whole. This is because developers can work on different components simultaneously, knowing that they will work together seamlessly.

Lastly, contract testing ensures that changes to components or services do not break the application. As applications evolve and change over time, it is essential to ensure that changes to one component or service do not affect the entire application. Contract testing ensures that changes do not break existing functionality, allowing for the application to evolve and grow over time.

Real life example

Let’s say you are developing a mobile app that uses a payment gateway API from a third-party vendor. The API requires specific input data, and it returns data in a particular format. However, due to miscommunication or a misunderstanding, your app sends incorrect data to the API, causing it to crash. Let’s prevent it using Pact.

In this section, we will discuss the implementation of contract testing using the Pact framework. The objective of contract testing is to verify the compatibility and reliability of the communication between service providers and consumers, without incurring the high costs associated with traditional integration tests.

To achieve this goal, we will use the Pact framework, which provides a robust and efficient mechanism for creating and verifying contracts between service providers and consumers. The framework allows us to define the expected behavior of the provider and consumer services, and generates contracts based on these specifications. These contracts can then be shared and verified to ensure that the services can communicate effectively.

To illustrate the implementation of contract testing using the Pact framework, we have provided sample code for both the provider and consumer services. The code demonstrates how to define the interactions between the services and generate the corresponding contracts using the Pact framework.

By implementing contract testing using the Pact framework, we can ensure that our services are compatible and reliable, without the need for expensive integration tests. This approach allows us to streamline our testing process and reduce costs, while maintaining a high level of quality and reliability in our services.

Here code examples for both Provider and Consumer

import au.com.dius.pact.consumer.dsl.PactDslJsonBody;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.dsl.PactDslWithState;
import au.com.dius.pact.consumer.dsl.PactDslWithState.State;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.core.model.annotations.Pact;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "Service B", port = "8080")
public class ServiceAContractTest {

@Pact(provider = "Service B", consumer = "Service A")
public PactDslWithState createPact(PactDslWithProvider builder) {
return builder
.given("resource exists")
.uponReceiving("a POST request with an 'id' field")
.path("/api/resource")
.method("POST")
.body(new PactDslJsonBody()
.stringValue("id", "12345"))
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringValue("name", "John Doe"));
}

@Test
void testServiceA(ServiceBClient serviceB) {
String response = serviceB.processRequest("12345");
assertThat(response, equalTo("John Doe"));
}
}
import au.com.dius.pact.core.model.annotations.PactFolder;
import au.com.dius.pact.provider.junit5.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;

@Provider("Service B")
@PactFolder("../pacts")
class ServiceBContractTest {

@State("resource exists")
void resourceExists() {}

@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}

@Pact(provider = "Service B", consumer = "Service A")
public RequestResponsePact createPact(PactDslWithProvider builder) {
return builder
.given("resource exists")
.uponReceiving("a POST request with an 'id' field")
.path("/api/resource")
.method("POST")
.body(new PactDslJsonBody()
.stringValue("id", "12345"))
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringValue("name", "John Doe"))
.toPact();
}

@Test
void testServiceB() {
ServiceB serviceB = new ServiceB();
Map<String, Object> request = new HashMap<>();
request.put("id", "12345");
Map<String, Object> response = serviceB.processRequest(request);
assertThat(response.get("name"), equalTo("John Doe"));
}
}

Contract testing also enables the use of continuous integration and continuous delivery (CI/CD) pipelines, which automate the testing and deployment of applications, further increasing the speed of delivery. CI/CD pipelines allow developers to test and deploy changes to an application automatically, ensuring that the application is always up-to-date and functioning correctly.

Now with contract testing implemented we can catch regression errors. Regression errors occur when changes to one component or service affect the functionality of another component or service. Contract testing ensures that these errors are caught early, allowing developers to fix them before they become major problems.

Summary

In conclusion, contract testing is a crucial aspect of software development and deployment. It allows teams to catch potential issues early, at the interface level, enhancing reliability and reducing the risk of unexpected failures. The mechanisms behind contract testing ensure that both parties — the provider and consumer — meet their obligations, thereby creating a more efficient and effective development process.

Through the highlighted real-life example, we saw how contract testing can not only prevent misunderstandings and discrepancies between teams but also lead to faster, safer releases and smoother collaborations. As the demand for speed and reliability in software delivery continues to escalate, the adoption of contract testing can be a game-changer for many organizations.

--

--