Consumer-Driven Contract Testing With Spring Cloud Contract
Implement CDC testing with Spring Cloud Contract and two sample Spring Boot applications to understand the communication between microservices.
Join the DZone community and get the full member experience.
Join For FreeIntroduction
The article demonstrates how to write a contract between the producer & the consumer and how to implements the producer and the consumer side test cases for Spring Cloud Contract through an HTTP request between two microservices.
Producer/Provider
The producer is a service that exposes an API (e.g. rest endpoint) or sends a message (e.g. Kafka Producer which publishes the message to Kafka Topic)
Consumer
The consumer is a service that consumes the API that is exposed by the producer or listens to a message from the producer (e.g. Kafka Consumer which consumes the message from Kafka Topic)
Contract
The contract is an agreement between the producer and consumer how the API/message will look like.
- What endpoints can we use?
- What input do the endpoints take?
- What does the output look like?
Consumer-Driven Contract
Consumer-driven contract (CDD) is an approach where the consumer drives the changes in the API of the producer.
Consumer-driven contract testing is an approach to formalize above mentioned expectations into a contract between each consumer-provider pair. Once the contract is established between Provider and Consumer, this ensures that the contract will not break suddenly.
Spring Cloud Contract
Spring Cloud Contract is a project of spring-cloud that helps end-users in successfully implementing the Consumer Driven Contracts (CDC) approach. The Spring Cloud Contract Verifier is used as a tool that enables the development of Consumer Driven Contracts. Spring Cloud Contract Verifier is used with Contract Definition Language (DSL) written in Groovy or YAML.
Demo Application
To understand the concept of the Spring Cloud Contract, I have implemented two simple microservices. The code for these applications can be found on GitHub account.
Create-employee-application MS
It is the first microservice responsible for creating an employee's profile based on the given details. We are only passing the FirstName, LastName, and Identification Number (e.g. National ID) of the employee. This microservice is calling another microservice to first check, based on the Identity Number, whether the profile has already been created for the employee.
Get-employee-application MS
This is the second microservice service that is just checking if an employee profile already exists. If the employee profile is matching with the Identification Number provided in the database, it will return the profile else return an empty profile with the EMPLOYEE_NOT_FOUND status.
The create-employee-application microservice is having a dependency on get-employee-application microservice, so we have written a contract of get-employee-application. We are not using any database here to store or retrieve employee details so that written simple logic which will help us to fetch the existing employee profile.
Setup
We are going to understand how we have done the setup for these applications. We are going to discuss the setup in each microservice one by one.
Provider Side Setup of Get-Employee-Application
Maven Dependencies and Plugin
We are supposed to add the Spring Cloud Contract Verifier dependency and plugin to our pom.xml, as the following example shows:
xxxxxxxxxx
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<version>${spring-cloud-contract.version} </version>
<scope>test</scope>
</dependency>
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>>${spring-cloud-contract.version} </version>
<extensions>true</extensions>
<configuration>
<baseClassForTests>
com.jd.spring.cloud.contract.get.BaseClass
</baseClassForTests>
</configuration>
</plugin>
BaseClass Setup
We need to use this BaseClass as the base class of the generated test. We should configure this class in the spring-cloud-contract-maven-plugin shown as above.
xxxxxxxxxx
public abstract class BaseClass {
private int port;
"${app.employeeBaseURI:http://localhost}") (
String employeeBaseURI;
"${app.employeeBasePath:/employee-management/employee}") (
String employeeBasePath;
public void setup() {
RestAssured.useRelaxedHTTPSValidation();
RestAssured.baseURI = employeeBaseURI;
if (RestAssured.baseURI.contains("localhost")) {
RestAssured.port = port;
}
}
public String getUrlPath() {
return employeeBasePath;
}
}
GetEmployeeController
This is the controller being used to return employee profiles based on the given identification number.
xxxxxxxxxx
public class GetEmployeeController {
private DBRepository dbRepository;
value="/employee/{identityCardNo}", method = RequestMethod.GET) (
public ResponseEntity<Employee> getEmployee( String identityCardNo)
{
if (!identityCardNo.startsWith("0")){
return new ResponseEntity<Employee>(dbRepository.getEmployee(identityCardNo), HttpStatus.OK);
}
Employee employee = new Employee();
employee.setStatus("EMPLOYEE_NOT_FOUND");
return new ResponseEntity<Employee>(employee, HttpStatus.OK);
}
}
Contracts
There are two scenarios that we are going to cover here so two contracted groovy files are written. The contract groovy files are located under src/test/resources/contracts.employee directory where contracts.employee is the name of the package mentioned in the groovy files.
Scenario 1 (shouldReturnExistingEmployee.groovy):
xxxxxxxxxx
package contracts.employee
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "should return an employee profile for given details."
request {
method(GET())
urlPath("/employee-management/employee/")
urlPath($(
consumer(regex("/employee-management/employee/[1-9][0-9]{0,}"))
, producer("/employee-management/employee/1234567890")
))
headers {
contentType(applicationJson())
accept(applicationJson())
}
}
response {
status OK()
headers {
contentType applicationJson()
}
body(
"id": "${(regex('[1-9][0-9]{0,}'))}",
"firstName": anyAlphaUnicode(),
"lastName": anyAlphaUnicode(),
"identityCardNo": fromRequest().path(2),
"status": "EMPLOYEE_FOUND"
)
}
}
Scenario 2 (shouldReturnNotFoundEmployee.groovy):
xxxxxxxxxx
package contracts.employee
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "should return an empty employee profile for given details"
request {
method(GET())
urlPath("/employee-management/employee/")
urlPath($(
consumer(regex("/employee-management/employee/[0][0-9]{0,}"))
, producer("/employee-management/employee/0123456789")
))
headers {
contentType(applicationJson())
accept(applicationJson())
}
}
response {
status OK()
headers {
contentType applicationJson()
}
body("status": "EMPLOYEE_NOT_FOUND")
}
}
Consumer Side Setup of Create-Employee-Application
Maven Dependency
To run the stubs, we need to add below dependency on the consumer side of create-employee-application.
x
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<version>${spring-cloud-contract.version} </version>
<scope>test</scope>
</dependency>
CreateEmployeeController
This is the controller being used to create employee profiles based on the given details of the employee.
xxxxxxxxxx
public class CreateEmployeeController {
("${location.getEmployee.url:http://localhost:8180/employee-management}")
private String location;
private DBRepository dbRepository;
(value= "/employee", method = RequestMethod.POST)
public ResponseEntity<Employee> createEmployee( Employee employee)
{
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers=new HttpHeaders();
headers.add("Content-Type","application/json");
headers.add("accept","application/json");
ResponseEntity<Employee> response = restTemplate
.exchange(location+"/employee/"+employee.getIdentityCardNo(), GET, new HttpEntity<>(headers), Employee.class);
if("EMPLOYEE_FOUND".equals(response.getBody().getStatus())){
return response;
}
return new ResponseEntity<Employee>(dbRepository.createEmployee(employee), HttpStatus.CREATED);
}
}
Test Class
This test is consuming the get-employee-test-provider-contract stub and running the mock service on 8180 port. There are two tests in this class that is covering both scenarios written in the contract of get-employee-application.
We are using the StubsMode as StubRunnerProperties.StubsMode.LOCAL. We can control the stub downloading via the stubsMode switch. The following options can be used to download the stub:-
- StubRunnerProperties.StubsMode.CLASSPATH (default value) - This is the default mode. It will scan the classpath and pick stubs from there. We need to add the dependency of the stub with classifier as a stub in the pom.xml with test scope.
- StubRunnerProperties.StubsMode.LOCAL - It will pick stubs from a local m2 repository.
- StubRunnerProperties.StubsMode.REMOTE - It will pick stubs from a remote location e.g. Nexus. We need to initialize repositoryRoot property with the URL of the remote repository in the AutoConfigureStubRunner annotation.
xxxxxxxxxx
SpringRunner.class) (
(
classes = CreateEmployeeApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
(
ids = {"com.jd.spring:get-employee-test-provider-contract:8180"}
, stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class ConsumeGetEmployeeUsingStubTest {
private int port;
"${app.createEmployeeBaseURI:http://localhost}") (
String createEmployeeBaseURI;
"${app.createEmployeeBasePath:/employee-management/employee}") (
String createEmployeeBasePath;
public void setup() {
RestAssured.useRelaxedHTTPSValidation();
RestAssured.baseURI = createEmployeeBaseURI;
if (RestAssured.baseURI.contains("localhost")) {
RestAssured.port = port;
}
}
public void testShouldCreateNewEmployee() throws Exception {
// given:
RequestSpecification request = given()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body(
"{\"firstName\":\"Jagdish\",\"lastName\":\"Raika\",\"identityCardNo\":\"0123456789\"}");
// when:
Response response = given().spec(request)
.post(createEmployeeBasePath);
// then:
assertThat(response.statusCode()).isEqualTo(201);
assertThat(response.header("Content-Type")).matches("application/json.*");
System.out.println("testShouldCreateNewEmployee: "+response.getBody().asString());
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("['id']").matches("[1-9][0-9]{0,}");
assertThatJson(parsedJson).field("['firstName']").matches("Jagdish");
assertThatJson(parsedJson).field("['lastName']").matches("Raika");
assertThatJson(parsedJson).field("['identityCardNo']").isEqualTo("0123456789");
assertThatJson(parsedJson).field("['status']").matches("NEW_EMPLOYEE_CREATED");
}
public void testShouldReturnExistingEmployee() throws Exception {
// given:
RequestSpecification request = given()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body(
"{\"firstName\":\"Jagdish\",\"lastName\":\"Raika\",\"identityCardNo\":\"1234567890\"}");
// when:
Response response = given().spec(request)
.post(createEmployeeBasePath);
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).matches("application/json.*");
System.out.println("testShouldReturnExistingEmployee: "+response.getBody().asString());
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("['id']").matches("[1-9][0-9]{0,}");
assertThatJson(parsedJson).field("['firstName']").matches("[\\p{L}]*");
assertThatJson(parsedJson).field("['lastName']").matches("[\\p{L}]*");
assertThatJson(parsedJson).field("['identityCardNo']").isEqualTo("1234567890");
assertThatJson(parsedJson).field("['status']").matches("EMPLOYEE_FOUND");
}
}
Generated Source and Stubs at the Provider Side
When we trigger the build of the provider, the spring-cloud-contract-maven-plugin automatically generates some test classes and stubs by using contract groovy files. Let's have a look at the directory structure of the provider project after triggering the build.
Generated Test
When we trigger the build, the spring-cloud-contract-maven-plugin automatically generates a test class named EmployeeTest that extends our BaseClass and puts it in /target/generated-test-sources/contracts.
The name of the test class will be on the basis of the last keyword of the groovy package (employee) and it will be appended with the 'test' keyword so the full name of the generated class will be 'EmployeeTest'. The name of the tests under this class will be starting with the keyword 'validate' appended with '_' and the name of the groovy file.
Example:
- Validate_shouldReturnExistingEmployee
- Validate_shouldReturnNotFoundEmployee
xxxxxxxxxx
"rawtypes") (
public class EmployeeTest extends BaseClass {
public void validate_shouldReturnExistingEmployee() throws Exception {
// given:
RequestSpecification request = given()
.header("Content-Type", "application/json")
.header("Accept", "application/json");
// when:
Response response = given().spec(request)
.get("/employee-management/employee/1234567890");
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).matches("application/json.*");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("['id']").matches("[1-9][0-9]{0,}");
assertThatJson(parsedJson).field("['firstName']").matches("[\\p{L}]*");
assertThatJson(parsedJson).field("['lastName']").matches("[\\p{L}]*");
assertThatJson(parsedJson).field("['identityCardNo']").isEqualTo("1234567890");
assertThatJson(parsedJson).field("['status']").isEqualTo("EMPLOYEE_FOUND");
}
public void validate_shouldReturnNotFoundEmployee() throws Exception {
// given:
RequestSpecification request = given()
.header("Content-Type", "application/json")
.header("Accept", "application/json");
// when:
Response response = given().spec(request)
.get("/employee-management/employee/0123456789");
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).matches("application/json.*");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("['status']").isEqualTo("EMPLOYEE_NOT_FOUND");
}
}
Generated Stubs
When we trigger the build, the spring-cloud-contract-maven-plugin automatically generates a stub inside the target/stubs directory. The stub is the generated WireMock of the contracts that will be converted into JSON format by the spring-cloud-contract-maven-plugin. We can refer above image for the directory structure of the provider project. This stub is being used to mock the provider side.
This stubs will be packed inside a jar in the package phase of the maven with a classifier as stubs.
How the Contract Will Be Broken
If we are making any changes on the provider side without updating or informing the consumer side, it will take us to a result where the contract will be broken and the test will fail.
Let's take an example, we are making some changes in the controller side such as changing the URL to fetch the employee profile. If we do not inform the consumer, they will keep trying on the old URL and they will get 404 Not Found instead of the desired response and this will lead to the failure of the contract.
Source Code
The source code for this post can be found on the GitHub account.
Opinions expressed by DZone contributors are their own.
Comments