Contract testing with Spring Cloud Contract (part 2)
October 18, 2020
- contract-testing
- spring-boot
- pact
In the last part, I have briefly introduced the concept of contract testing and why we need it. This article will look into how we can use the Spring Cloud Contract to enforce contract testing between Spring-boot microservice apps.
If you want to quickly dive into the code, feel free to check out the example source code in Github: https://github.com/alexthered/spring-boot-contract-testing-demo.
In this example, we will implement two services: producer (where the API is implemented) and consumer (where the API is consumed). The goal is to enforce a fixed, well-defined contract between the producer and the consumer. If any breaking code change is introduced in the producer, the contract test should fail instead of going to the production and break the consumer code later (and that’d be very bad).
Setup the producer
Let’s add a dummy API endpoint to the producer:
// UserController.java
@RestController
@RequestMapping*(*"/users"*)
*public class UserController *{
*@GetMapping*(*"/{userId}"*)
*public User getMe*(*@PathVariable Integer userId*) {
*return User.*builder()
*.id*(*userId*)
*.firstName*(*"John"*)
*.lastName*(*"Doe"*)
*.email*(*"john.doe@gmail.com"*)
*.build*()*;
*}
}*
// User.java
@Value
@Builder
public class User *{
*Integer id;
String firstName;
String lastName;
String email;
*}*
Now to generate a cloud contract for this API endpoint, we need to add the following dependency:
*<*dependencies*>
<*dependency*>
<*groupId*>*org.springframework.cloud*</*groupId*>
<*artifactId*>*spring-cloud-starter-contract-verifier*</*artifactId*>
<*version*>*2.2.4.RELEASE*</*version*>
<*scope*>*test*</*scope*>
</*dependency*>
</*dependencies*>
<*build*>
<*plugins*>
<*plugin*>
<*groupId*>*org.springframework.cloud*</*groupId*>
<*artifactId*>*spring-cloud-contract-maven-plugin*</*artifactId*>
<*extensions*>*true*</*extensions*>
<*version*>*2.2.4.RELEASE*</*version*>
<*configuration*>
<*baseClassForTests*>
*me.alexthered.contracttesting.producer.controller.IntegrationTestBase
*</*baseClassForTests*>
<*testFramework*>*JUNIT5*</*testFramework*>
</*configuration*>
</*plugin*>
<*plugin*>
<*groupId*>*org.springframework.boot*</*groupId*>
<*artifactId*>*spring-boot-maven-plugin*</*artifactId*>
</*plugin*>
</*plugins*>
</*build*>*
Note that we can add a base test class IntegrationTestBase, which will later be used by Spring to generate test classes.
Now, let’s add a contract definition for this endpoint:
// find_user_by_id.groovy
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "should return correct user by id=1000"
request {
url "/users/1000"
method GET()
}
response {
status OK()
headers {
contentType applicationJson()
}
body(
id: 1000,
firstName: "John",
lastName: "Doe",
email: "john.doe@gmail.com"
)
}
}
Basically, this file defines the expected response that the consumer can expect when calling our dummy endpoint including the status, header, and the body. Now if we run the build, the test classes will be generated:
package me.alexthered.contracttesting.producer.controller;
import me.alexthered.contracttesting.producer.controller.IntegrationTestBase;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification;
import io.restassured.response.ResponseOptions;
import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.*assertThat*;
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*;
import static com.toomuchcoding.jsonassert.JsonAssertion.*assertThatJson*;
import static io.restassured.module.mockmvc.RestAssuredMockMvc.*;
@SuppressWarnings*(*"rawtypes"*)
*public class UsersTest extends IntegrationTestBase *{
*@Test
public void validate_find_user_by_id*() *throws Exception *{
*// given:
MockMvcRequestSpecification request = *given()*;
// when:
ResponseOptions response = *given()*.spec*(*request*)
*.get*(*"/users/1000"*)*;
// 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']"*)*.isEqualTo*(*1000*)*;
*assertThatJson(*parsedJson*)*.field*(*"['firstName']"*)*.isEqualTo*(*"John"*)*;
*assertThatJson(*parsedJson*)*.field*(*"['lastName']"*)*.isEqualTo*(*"Doe"*)*;
*assertThatJson(*parsedJson*)*.field*(*"['email']"*)*.isEqualTo*(*"john.doe@gmail.com"*)*;
*}
}*
That’s cool, right? So whenever someone introduces a breaking change to the endpoint, the test here would fail.
Setup the consumer
*<*dependencies*>
<*dependency*>
<*groupId*>*org.springframework.cloud*</*groupId*>
<*artifactId*>*spring-cloud-contract-wiremock*</*artifactId*>
<*version*>*2.2.4.RELEASE*</*version*>
<*scope*>*test*</*scope*>
</*dependency*>
<*dependency*>
<*groupId*>*org.springframework.cloud*</*groupId*>
<*artifactId*>*spring-cloud-contract-stub-runner*</*artifactId*>
<*version*>*2.2.4.RELEASE*</*version*>
<*scope*>*test*</*scope*>
</*dependency*>
</*dependencies*>*
Let’s implement another simple endpoint in the consumer, which actually calls our producer’s dummy endpoint.
@RestController
public class HelloController *{
*public static final String *FIRST_NAME *= "firstName";
private final RestTemplate restTemplate;
@Value*(*"${producer.url}"*)
*private String producerUrl;
public HelloController*() {
*this.restTemplate = new RestTemplateBuilder*()
*.build*()*;
*}
*@GetMapping*(*"/hello/{userId}"*)
*public String helloUser*(*@PathVariable Integer userId*) {
*return "Hello " + getUserFirstName*(*userId*)*;
*}
*private String getUserFirstName*(*Integer userId*) {
*Map*<*String, String*> *responseEntity = restTemplate.getForObject*(
*producerUrl + "/users/{userId}",
Map.class,
userId*)*;
return responseEntity.get*(FIRST_NAME)*;
*}
}*
and we write a test for this consumer’s endpoint:
@ExtendWith*(*SpringExtension.class*)
*@SpringBootTest*(*webEnvironment = SpringBootTest.WebEnvironment.*RANDOM_PORT)
*@AutoConfigureStubRunner*(
*stubsMode = StubRunnerProperties.StubsMode.*LOCAL*,
ids = "me.alexthered.contract-testing:producer:+:stubs:8080"
*)
*public class HelloControllerTest *{
*@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Test
void should_get_hello_string_correctly*() {
assertThat(*this.restTemplate.getForObject*(*"http://localhost:" + port + "/hello/1000", String.class*))
*.isEqualTo*(*"Hello John"*)*;
*}
}*
Several not-so-magical things happen here. Firstly in the test, we simply call the consumer’s endpoint, but as it depends on the producer, we need to tell Junit what we expect from the producer. And how we can do that? Well, remember that we have the contract defined above right?
@AutoConfigureStubRunner*(
*stubsMode = StubRunnerProperties.StubsMode.*LOCAL*,
ids = "me.alexthered.contract-testing:producer:+:stubs:8080"
*)*
This piece of code tells Spring to look for the contract between the consumer and producer to get the correct response when we call the producer’s endpoint. In the underlying, Spring runs a stub Wiremock server at the port 8080 and load the contract to determine what to return as the response.
Now, again when a developer changes some code in the producer, which will change the contract (e.g in this case to not return the first name of the user), it will also cause the test here in the consumer to fail. Thus, the team can detect the issue way before it is deployed to production to fail silently.🔥
Spring Cloud Contract’s approach is more like a producer-driven contract (the contract is written and generated by the producer). In reality, a consumer-driven contract can be a better approach as only the consumer knows which fields are used and what format it expects. Any change if needed, should be driven by the consumer rather than the producer.
Another disadvantage of Spring Cloud Contract is the limitation to the JVM world. Even though there has been some progress to support non-JVM frameworks and languages, it is still somewhat limited.
If you have used Spring Cloud Contract in production, feel free to leave a comment here and tell us how it works out for your team.
Written by Hiep Doan, a software engineer living in the beautiful Switzerland. I am passionate about building things which matters and sharing my experience along the journey. I also selectively post my articles in Medium. Check it out!