Sitemap
Feature photo by Steve Johnson on Unsplash.

Contract Tests — APIs Guided by Consumers

--

Integrating APIs is an activity that requires careful analysis, as it involves the dependency of a microservice on an external resource. This integration adds a level of risk to operations, as discrepancies between expected and received data can result in integration failures. Ensuring that the API integration is working correctly is a crucial priority, considering that any eventual problem can impact the end user.

An approach that generates value in scenarios involving API integration is the Contract Test. These types of tests verify, through requests, whether the calls to an external service align with the expected data. They help prevent failures by using assertions that allow for early feedback on potential problems caused by a code change that breaks the contract.

In this article, we’ll demonstrate a use case of contract tests in practice with three integrated microservices. The text has three divisions, as demonstrated below:

  • Development of three integrated microservices.
  • Adding contract tests to verify the integration between the microservices.
  • Simulating some breaking changes to demonstrate the failure of the contract tests.

The project code is available in the pact-contract-test GitHub repository.

Book Sales System

For this article, we’ll use an example system with three microservices: person-data-service, book-data-service, and book-sales-service.

The microservice book-sales-service is responsible for performing book sales operations. To make a sale, the service depends on person-data-service to retrieve person data and book-data-service to retrieve book data and update the stock.

Services of book sales system.

Person Data Service

The person-data-service has an API that receives the person code and returns the corresponding data. A request to retrieve the person data can be made as demonstrated in the curl command below.

curl --location --request GET 'http://localhost:8082/persons/1001'

The request result displays the person data as demonstrated below.

{ 
"id" : 35,
"name" : "John",
"country" : "Brazil",
"passportNumber" : FT875654
}

The service has a simple structure composed of Controller, Service, and Repository. The purpose of this exercise is to test the contract between the APIs without integrating a database. The data is persisted in an in-memory collection. Below is a diagram illustrating the main components.

Components of person-data-service

Book Data Service

The book-data-service consists of two APIs: one that receives the book code and returns the corresponding data, and another API responsible for updating the stock. The code is structured similarly to the person-data-service, with Controllers, Services, and Repositories, as illustrated in the image below.

Components of book-data-service.

Get book data

The curl command below demonstrates how to make a request to retrieve book data.

curl --location --request GET 'http://localhost:8081/books/201'

The response is in JSON format, as demonstrated below.

{ 
"id": 202,
"name": "Refactoring: Improving the Design of Existing Code",
"stock": 0,
"isbn": "0201485672"
}

Book stock update

The book stock update is performed through a POST request, as demonstrated below.

curl --location 'http://localhost:8081/books/updateStock' \ 
--header 'Content-Type: application/json' \
--data '{"id": 202,"quantity": 1}'

The API will return the status of the operation as either SUCCESS or FAILURE, depending on the result. In the case of success:

{ 
"status": "SUCCESS",
"message":"The stock of the book 202 was update to 2."
}

In case of an issue, an error will be returned, as demonstrated below, such as in a scenario with insufficient stock.

{ 
"status": "FAILURE",
"message": "Failed to update stock of book 202 because the new stock will be less than 0."
}

Book Sales Service

The primary service is book-sales-service. This service is responsible for conducting sales operations and orchestrating the integration between the other microservices.

To make a sale, it’s necessary to send a request with the book ID and person ID. The service will then retrieve the person data from person-data-service and the book data from book-data-service. At the end of the operation, a new request is made to book-data-service to update the stock. This operation can be performed using the curl command below.

curl -X POST --location 'http://localhost:8080/book-sales' \ 
--header 'Content-Type: application/json' \
--data '{"personId": 1001,"bookId": 203}'

If the sale is successful, a message will be returned with the operation results, as demonstrated below.

{ 
"status": "SUCCESS",
"message": "The sale to person John with passport number FT8966563 was successful."
}

For any sales issues, a message with more details will be returned. In the example below, a book sale is attempted when there is no stock available.

{ 
"status": "OUT_OF_STOCK",
"message": "The current stock of book 201 is 0 and is not sufficient to make the sale."
}

In addition to Controller, Service, and Repository, this service includes components responsible for integrating with the providers: BookDataServiceWebClient and PersonDataServiceWebClient. The diagram below illustrates how these components are organized.

Components of book-sales-service.

Contract Test

The book-sales-service depends on both the book-data-service and person-data-service to conduct sales operations. Therefore, it’s essential to ensure communication between these services.

Contract Testing is an efficient way to ensure integration, avoiding issues stemming from contract breaches. Pact is a tool that has the necessary features for working with contract tests, offering functionalities that enable consumer-driven testing, meaning consumers define which contract should be followed.

Pact

Pact enables testing the integration between providers and data consumers. It offers a straightforward and practical approach, consisting of three main components: Broker, Provider, and Consumer.

The broker centralizes the contracts and serves as the platform where Consumers publish their contracts. Providers also use the broker, but their aim is to validate whether their APIs meet the contracts defined by consumers.

The diagram below, provided by Pact documentation, illustrates in more detail the steps involved in the testing process.

Source: https://docs.pact.io/

Testing Contracts with Pact

The microservice responsible for consuming the APIs needs to create and publish the contract. In this scenario, the book-sales-service acts as the Consumer. The microservices book-data-service and person-data-service, responsible for providing the data, are the providers.

Consumers

The initial step involves setting up the service to utilize Pact. In this example, Gradle is used, requiring the addition of the plugin and dependencies to the build.gradle file, as demonstrated below.

plugins { 
[...]
id 'au.com.dius.pact' version "4.3.15"
}
dependencies { 
[...]
testImplementation 'au.com.dius.pact.consumer:junit5:4.3.15'
}

It’s necessary to include the broker URL, as demonstrated below.

pact { 
publish {
pactBrokerUrl = "http://localhost:9292"
}
}

Contract with person-data-service

To create the contract with person-data-service, it's necessary to create a new class that extends PactConsumerTestExt.

@ExtendWith(PactConsumerTestExt.class) 
public class BookSalesConsumerForPersonDataContractTest

A new method with the contract details needs to be created, where the consumer’s requirements are defined. As demonstrated below, person-data-service needs to return the ID, name, and passport number.

@Pact(consumer = CONSUMER_BOOK_SALES_SERVICE_GET_PERSONS, provider = PROVIDER_PERSON_DATA_SERVICE)
public V4Pact whenRequestPersonById_thenReturnsPerson(PactDslWithProvider builder) {
PactDslJsonBody bodyResponse = new PactDslJsonBody()
.integerType("id", A_PERSON_ID)
.stringType("name", A_PERSON_NAME)
.stringType("passportNumber", A_PERSON_PASSPORT_NUMBER);

return builder
.given("it has a person and status code is 200")
.uponReceiving("a request to retrieve a person by id")
.path("/persons/" +A_PERSON_ID)
.method("GET")
.willRespondWith()
.headers(Collections.singletonMap("Content-Type", "application/json"))
.status(OK.value())
.body(bodyResponse)
.toPact(V4Pact.class);
}

It’s also necessary to include a second method to verify the test. MockServer with WebClient is used for this purpose, as demonstrated below.

@PactTestFor(providerName = PROVIDER_PERSON_DATA_SERVICE, 
pactMethod = "whenRequestPersonById_thenReturnsPerson",
providerType = SYNCH)
@Test
public void whenRequestPersonById_thenReturnsPerson(MockServer mockServer) {
// given
WebClient webClient = WebClient.builder()
.baseUrl(mockServer.getUrl())
.build();

// when
PersonDataServiceWebClient personDataServiceWebClient =
new PersonDataServiceWebClient(webClient, "/persons/{personId}");
PersonDataResponse personDataResponse = personDataServiceWebClient.retrievePerson(A_PERSON_ID);

// then
assertThat(personDataResponse.getId()).isInstanceOf(Long.class).isEqualTo(A_PERSON_ID);
assertThat(personDataResponse.getName()).isInstanceOf(String.class).isEqualTo(A_PERSON_NAME);
assertThat(personDataResponse.getPassportNumber()).isInstanceOf(String.class).isEqualTo(A_PERSON_PASSPORT_NUMBER);
}

Contract with book-data-service

In the book-data-service, there are two dependencies: one to retrieve the book data and another to request the stock update.

@Pact(consumer = CONSUMER_BOOK_SALES_SERVICE_GET_BOOKS, provider = PROVIDER_BOOK_DATA_SERVICE)
public V4Pact whenRequestBookById_thenReturnsBook(PactDslWithProvider builder) {
PactDslJsonBody bodyResponse = new PactDslJsonBody()
.integerType("id", A_BOOK_ID)
.integerType("stock", A_BOOK_STOCK);

return builder
.given("it has a book and status code is 200")
.uponReceiving("a request to retrieve a book by id")
.path("/books/" + A_BOOK_ID)
.method("GET")
.willRespondWith()
.headers(Collections.singletonMap("Content-Type", "application/json"))
.status(OK.value())
.body(bodyResponse)
.toPact(V4Pact.class);
}

@Pact(consumer = CONSUMER_BOOK_SALES_SERVICE_UPDATE_STOCK, provider = PROVIDER_BOOK_DATA_SERVICE)
public V4Pact whenUpdateBookStock_thenReturnsStatus(PactDslWithProvider builder) {
PactDslJsonBody responseBody = new PactDslJsonBody()
.stringType("status", "SUCCESS")
.stringType("message", "The stock of the book " + A_BOOK_ID + " was update to 16");

PactDslJsonBody requestBody = new PactDslJsonBody()
.integerType("id", A_BOOK_ID)
.integerType("quantity", A_BOOK_QUANTITY)
.asBody();

return builder
.given("it has a book and stock can be updated")
.uponReceiving("a request to update the stock of book")
.method(HttpMethod.POST.name())
.path(PATH_UPDATE_STOCK)
.headers(Collections.singletonMap("Content-Type", "application/json"))
.body(requestBody)
.willRespondWith()
.headers(Map.of("Content-type", "application/json"))
.status(OK.value())
.body(responseBody)
.toPact(V4Pact.class);
}

Providers

With the contracts defined by the Consumers, the next step is to prepare the verification for the providers. The providers are book-data-service and person-data-service, requiring the creation of specific tests for each of them. In this case, the inclusion of Pact depends on the dependency and the configuration in the build.gradle file, as demonstrated below.

dependencies {
[...]
testImplementation 'au.com.dius.pact.provider:junit5spring:4.5.6'
}

Some variables need to be defined for the test execution. They are: the url, the version, and if the results need to be published with the test execution.

tasks.named('test') {
useJUnitPlatform()

systemProperties["pactbroker.url"] = "http://localhost:9292"
systemProperties["pact.provider.version"] = version
systemProperties["pact.verifier.publishResults"] = "true"
}

Contract verification by person-data-service

To avoid the need to start the entire application, @WebMvcTest is used. A mock of the service class is added so that the test context is specific to the Controller.

@WebMvcTest
@Provider("person-data-service")
@PactBroker
public class PersonDataProviderForBookSalesContractTest {
@Autowired
private MockMvc mockMvc;

@MockBean
private PersonService personService;

@BeforeEach
public void setUp(PactVerificationContext context){
context.setTarget(new MockMvcTestTarget(mockMvc));
}

//[...]
}

The mock must be included in the method annotated with @State, representing the part of the code where it connects with the Consumer. In the @State, it's important that the string defined matches the one defined in the consumer. In the case of a person, it's included in the V4Pact builder as given("it has a person and status code is 200").

@State("it has a person and status code is 200")
public void itHasPersonWithIdAndStatusIs200() {
when(personService.getPersonById(A_PERSON_ID))
.thenReturn(Person.builder()
.id(A_PERSON_ID)
.name(A_PERSON_NAME)
.passportNumber(A_PERSON_PASSPORT_NUMBER)
.build());
}

It’s necessary to add the code responsible for verifying the contract.

@TestTemplate
@ExtendWith(PactVerificationSpringProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}

Contract verification by book-data-service

The same must be done for book-data-service, the difference in this case there are two contracts: one responsible for retrieving book data and another responsible for updating the stock. Therefore, two @State annotations are required, as demonstrated below.

@State("it has a book and status code is 200")
public void itHasBookWithIdAndStatusIs200() {
when(bookService.getBookById(A_BOOK_ID))
.thenReturn(Book.builder()
.id(A_BOOK_ID)
.stock(A_BOOK_STOCK)
.build());
}

@State("it has a book and stock can be updated")
public void aRequestToUpdateTheStockOfBook() {
when(bookService.updateStock(A_BOOK_ID, A_BOOK_QUANTITY_UPDATE_STOCK))
.thenReturn(UpdateStockResult.builder()
.message("The stock of the book " + A_BOOK_ID + " was update to 16")
.status(SUCCESS)
.build());
}

Contract Publishing and Verification

With the tests created, the next step is to publish the contract to the Pact Broker, allowing the providers to perform verification. For this, we need an instance of the broker, and we are using a container for this purpose. Below is the code responsible for starting a container, which is part of the docker-compose used in other services.

services:
pactbroker:
image: pactfoundation/pact-broker:2.104.0.0
environment:
PACT_BROKER_DATABASE_ADAPTER: sqlite
PACT_BROKER_DATABASE_NAME: pactbroker
ports:
- "9292:9292"
[...]

To start only the Pact Broker, run the following command. The broker will be available at http://localhost:9292/.

docker-compose up pactbroker -d

A page like the one demonstrated below will be displayed.

Start page of pact broker.

Contract publication by the consumer

With the broker working, the next step is to publish the contract by the consumer, the book-sales-service. To do this, navigate to the project folder and execute the build command, as demonstrated below.

./gradlew clean build

Some JSON files will be generated in the build\pacts folder. A separate file is generated for each contract, as demonstrated below.

JSON files for the contracts.

Now it’s necessary to publish the contract to the broker, as demonstrated below.

./gradlew pactPublish

When accessing the broker, it will be possible to visualize the three published contracts.

Contracts published by consumer.

Contract verification by the providers

With the contracts published, the next step is to verify them by the provider. During this step, it’s possible to block a release if any changes break the contract.

There are two services that need to verify the contracts. Let’s begin with person-data-service. In the project, you need to run clean and build, and then the tests will be executed.

./gradlew clean build

If everything works correctly, then the verification will display the last verified columns, as demonstrated in the image below.

Contract verification by person-data-service.

The same process must be performed for book-data-service. In the project folder, you need to run clean and build.

./gradlew clean build

If the tests pass, the broker will be updated with the performed verification.

Contract verification by book-data-service.

Simulating Contract Breakage

A major benefit of using contract testing is the ability to ensure that providers can make changes without negatively affecting consumers. To test this scenario, let’s simulate some changes and observe how Pact responds to this type of situation.

Simulation 1: Provider removes field from API

In the first scenario, the person-data-service makes a change by removing the passport number field, which is essential for the book-sales-service. With the removal of the field, the service response looks as follows.

{ 
"id":1002,
"name":"Maria",
"country":"Brazil"
}

This change simulation is available on a branch, to access it simply perform a checkout.

git checkout simulation-1-person-data-remove-passport-number

With the change made, the next step is to run the contract tests for the person-data-service. For that, the command below can be used.

./gradlew clean build

The test will fail, and the reason can be viewed in the generated report.

Report with failure details.

The broker also indicates a contract break in the matrix verifications.

Contract matrix verification.

Further details are available by clicking on the item in the matrix with failures.

Details about failure verifications.

Simulation 2: Provider renames API field

In the second simulation, the book-data-service changes the field name from stock to currentStock. Below is the new response body returned by the API.

{ 
"id":201,
"name":"Domain-Driven Design: Tackling Complexity in the Heart of Software",
"currentStock":0,
"isbn":"9780321125217"
}

This simulation change is also available in a branch.

git checkout simulation-2-book-data-change-stock-field-name

To observe the test failure, it’s necessary to run the project build.

./gradlew clean build

A report with the failure is also generated, providing more details about the contract break.

Report with failure details.

The matrix verification displays more details about the verifications.

Contract matrix verification.

Further details can be accessed by clicking on the item in the matrix with failures.

Details about failure verifications.

Simulation 3: Provider renames field in Post API

In the third simulation, the quantity field is renamed to quantityToUpdate in the request to update the stock in the book-data-service microservice. Below are more details about the new request body.

{ 
"id": 202,
"quantityToUpdate": 1
}

This change simulation is available on a branch.

git checkout simulation-3-book-data-rename-field-name-post-request

To observe more details about the contract break, it’s necessary to build the project.

./gradlew clean build

The report with the failures will be generated, showing more details about the contract break. In this case, success is expected; however, a status code 400 is returned, indicating that the quantityToUpdate field was not sent in the request body.

Report with failure details.

The matrix verification displays more details about the verifications.

Contract matrix verification.

Further details can be accessed by clicking on the item in the matrix with failures.

Details about failure verifications.

Conclusion

Using contract tests in an environment with multiple microservices simplifies the prevention of contract breaks that can impact API integrations. Although it requires additional effort to prepare the environment and create the contracts, once this is done, the services will be safer and less vulnerable to failures. Below are some benefits of adopting contracts in a development flow.

  • Contracts make services less vulnerable to failures, as changes that affect other microservices break the contract.
  • Providers with multiple consumers make each contract explicit, making it easier to understand which services depend on something specific and plan changes more safely.
  • Refactorings are safer because it’s clear exactly what each consumer uses in the API. For example, fields included without a reason and nobody knows why they are present in the API.
  • It’s a way to create a standard with contracts between APIs in a scenario with many teams.
  • Documentation is important but vulnerable to failures. Contract tests are an automated approach that can be configured to break the build if there are any failures.
  • It’s an approach similar to Test-Driven Development (TDD), where the code created in the providers are guided by contract consumer tests.

References

--

--

No responses yet