Contract testing as a way to help maintain system stability – Spring Cloud Contract
Introduction
Currently microservices architecture is the most common way of creating systems. Because of that every developer in his everyday job encounters more than ever before one of the simplest scenario in the IT world – producer and consumer relation. Contract testing is a good tool to maintain compatibility between these two.
Producer is a service that exposes API and Consumer is a service that uses this API. And here comes the question – how should you write a test for it? One of the approaches would be to deploy both applications and run end to end tests. But it can be problematic, sometimes even impossible and for sure the feedback cycle is quite long as you need to wait for both apps to deploy etc.
Another possibility would be to use a library like Wiremock and configure a stub – a fake server that is configured to send exactly the same response as the real one. That’s quite a good idea and this is what we typically do.
But things can go wrong even if we’ve implemented such tests. Let’s imagine that you are a developer on the consumer’s side and you’ve received a message from producer’s side developers that they have implemented brand a brand new endpoint with url /newEndpoint. All the requirements are there so you implement the missing code and integration tests with stubbed responses; everything is ‘green’ so you’re ready to deploy your service. And then of course failure occurs. Maybe they made a typo in url and you saw 404, maybe they made a typo in object name or maybe they forgot to tell you about one more required field.
The problem here is that stubs were built on the consumer side and have nothing to do with the real API. Because of that you can’t be sure that they are correct. Also they can’t be reused so every consumer must repeat the same work.
And here is where contract testing comes with help. Contract is an agreement between the producer and the consumer what the API / message will look like. This contract is located on the producer side and cosumer downloads it every time when tests are triggered. Because of that if the producer makes some changes in the existing API the next build on the consumer’s side will fail and inform the developer that something has changed and you need to repair that.
Example
Let’s see how it works on an example. I’ve built two simple services – a producer that exposes an endpoint which returns a movie and a consumer which uses this endpoint.
Producer
Starting from the procuder’s side we need two things in pom – dependency for spring-cloud-contract and a plugin that will generate tests for us. Those tests can be generated with a few frameworks like JUNIT, Spock and others.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<scope>test</scope>
</dependency>
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>3.1.5</version>
<extensions>true</extensions>
<configuration>
<testFramework>JUNIT5</testFramework>
<baseClassForTests>com.example.contract.BaseTestClass</baseClassForTests>
</configuration>
</plugin>
As you can see in the plugin definition we need to specify baseClassForTests which we will talk about a bit later.
We need Movie model class. I’ve added lombok annotations which will create getters, setters and a contructor.
Data
@AllArgsConstructor
public class Movie {
private Long id;
private String title;
}
The next thing is MovieService
@Service
public class MovieService {
private final Map<Long, Movie> movieMap;
public MovieService() {
movieMap = new HashMap<>();
movieMap.put(1L, new Movie(1L, "Kiler"));
movieMap.put(2L, new Movie(2L, "Into the wild"));
}
Movie findMovieById(Long id) {
return movieMap.get(id);
}
}
As it is only for tutorial purposes HashMap will be our storage. Next there is a simple method that returns Movie by given id.
And then the enpoint which the consumer will use.
@RestController
public class MovieController {
private final MovieService movieService;
public MovieController(MovieService MovieService) {
this.movieService = MovieService;
}
@GetMapping("movie/{id}")
public Movie findMovieById(@PathVariable("id") Long id) {
Movie movie = movieService.findMovieById(id);
if (movie == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "Not Found"
);
}
return movie;
}
}
A simple controller that calls MovieService and returns Movie if it finds it and the 404 error if it doesn’t.
And now the most important part – contracts must be defined. To do so you can use a few different languages, I used groovy because I think it’s the most intuitive and readable one. Files need to be placed in src/test/resources/contracts. I’ve created two contracts – one for a successfull path and second for a failure.
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "should return movie with id=1"
request {
method 'GET'
url '/movie/1'
}
response {
status OK()
headers {
contentType applicationJson()
}
body (
id: 1,
title: "Movie1"
)
}
}
In Contract we need to define what the request should look like and then what the response to that request should look like. So we write that if the request with GET method on url /movie/1 will come the answer for that should be OK(which is http code 200), in headers there need to be applicationJson and the body needs to have those two variables with specified values.
Contract.make {
description "should return 404 for movie with id=3"
request {
url "/movie/3"
method GET()
}
response {
status 404
}
}
The second contract is for a failure scenario and it tells us that when the request is for movie with id=3 the 404 NOT FOUND status will be returned.
Ok right now let’s get back to baseClassForTests property in the plugin definition. This is a base class that loads the Spring context. Tests generated by Spring cloud contract will inherit from this class.
@SpringBootTest(classes = ProducerApplication.class)
public abstract class BaseTestClass {
@Autowired
MovieController movieController;
@MockBean
MovieService movieService;
@BeforeEach
public void setup() {
RestAssuredMockMvc.standaloneSetup(movieController);
Mockito.when(movieService.findMovieById(1L))
.thenReturn(new Movie(1L, "Movie1"));
Mockito.when(movieService.findMovieById(2L))
.thenReturn(new Movie(2L, "Movie2"));
}
}
In our base class let’s autowire MovieController and mock responses from MovieService.
Now we are ready to generate stubs that the consumer will use. To do so we need to build our project using the command mvn clean install. After analyzing the console output we can see three important things. The first is that the test class ContractVerifierTest has been created, the second one is that stubs in json format have been created and the third one is that a jar with those stubs also has been created and pushed to a local maven repository:
[INFO] Installing C:\Users\...\producer\target\producer-0.0.1-SNAPSHOT-stubs.jar to C:\Users\...\.m2\repository\com\example\producer\0.0.1-SNAPSHOT\producer-0.0.1-SNAPSHOT-stubs.jar
So right now a jar with stubs is ready to be pushed on the remote repository so that external services can use it. In this tutorial we will use a local maven repository.
public class ContractVerifierTest extends BaseTestClass {
@Test
public void validate_find_movie_by_id1() throws Exception {
// given:
MockMvcRequestSpecification request = given();
// when:
ResponseOptions response = given().spec(request)
.get("/movie/1");
// 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(1);
assertThatJson(parsedJson).field("['title']").isEqualTo("Movie1");
}
@Test
public void validate_find_movie_by_id3() throws Exception {
// given:
MockMvcRequestSpecification request = given();
// when:
ResponseOptions response = given().spec(request)
.get("/movie/3");
// then:
assertThat(response.statusCode()).isEqualTo(404);
}
}
This is what generated test class looks like. From now every build will launch those tests and they will ensure that the contract is fulfilled.
Consumer
On the consumer side we need the same Spring cloud contract dependency but the plugin is not needed. The next thing that we need is the same Movie model class and controller class with an endpoint that will call the producer service and then return the movie to the client.
@RestController
public class MovieConsumerController {
@Value( "${producer.port}" )
private Integer producerPort;
private final RestTemplate restTemplate;
MovieConsumerController(RestTemplateBuilder restTemplateBuilder) {
this.restTemplate = restTemplateBuilder.build();
}
@RequestMapping("/borrowMovie/{id}")
String getMessage(@PathVariable("id") Long movieId) {
HttpHeaders headers = new HttpHeaders();
HttpEntity<Void> requestEntity = new HttpEntity<>(headers);
ResponseEntity<Movie> response =
restTemplate.exchange("http://localhost:"+producerPort+
"/movie/{id}",HttpMethod.GET, requestEntity, Movie.class, movieId);
Movie movie = response.getBody();
return "Here is your movie with title: " + movie.getTitle();
}
}
As you can see there is borrowMovie endpoint that with the use of RestTemplate calls producer service to get the movie. Now we are ready to write a test that will use the producer contract in the generated stub jar.
@SpringBootTest
@AutoConfigureStubRunner(ids = {"com.example:producer:+:stubs:9000"}, stubsMode = StubsMode.LOCAL)
public class ContractIntegrationTest {
@Test
public void get_hat1() {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<Movie> responseEntity =
restTemplate.getForEntity("http://localhost:9000/movie/1", Movie.class);
assertThat(responseEntity.getStatusCodeValue()).isEqualTo(200);
Movie movie = responseEntity.getBody();
assertThat(movie.getId()).isEqualTo(1);
assertThat(movie.getTitle()).isEqualTo("Movie1");
}
@Test
public void get_hat3() {
try {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<Movie> responseEntity =
restTemplate.getForEntity("http://localhost:9000/movie/3", Movie.class);
}
catch (HttpClientErrorException ex) {
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}
}
The most important here is the annotation AutoConfigureStubRunner. First we define here a project that we want to use com.example:producer, next with + sign we say that latest version should be taken, next that the stubs jar should be used and than that StubRunner should run Wiremock server on port 9000. Then we define that the stubs should be taken from the local repository. And that’s it – we are ready to run tests.
When we analyze the console output of a launched test we can see that Spring downloaded the producer stubs jar and then started server on port 9000 with mappings taken from the stubs jar file.
INFO 2568 --- [main] o.s.c.c.stubrunner.AetherStubDownloader : Resolved artifact [com.example:producer:jar:stubs:0.0.1-SNAPSHOT] to C:\Users\...\.m2\repository\com\example\producer\0.0.1-SNAPSHOT\producer-0.0.1-SNAPSHOT-stubs.jar
INFO 2568 --- [main] o.s.c.contract.stubrunner.StubServer : Started stub server for project [com.example:producer:0.0.1-SNAPSHOT:stubs] on port 9000 with [2] mappings
INFO 2568 --- [main] o.s.c.c.stubrunner.StubRunnerExecutor : All stubs are now running RunningStubs [namesAndPorts={com.example:producer:0.0.1-SNAPSHOT:stubs=9000}]
Ok everything works like it should now let’s see what will happen when some developer will have an idea that field “title” should be named “movieTitle”.
@Data
@AllArgsConstructor
public class Movie {
private Long id;
private String movieTitle;
}
After changing Movie class project is ready to build.
[ERROR] validate_find_movie_by_id1 Time elapsed: 0.754 s <<< ERROR!
java.lang.IllegalStateException: Parsed JSON [{"id":1,"movieTitle":"Movie1"}] doesn't match the JSON path [$[?(@.['title'] == 'Movie1')]]
at com.example.contract.ContractVerifierTest.validate_find_movie_by_id1(ContractVerifierTest.java:36)
Build Failed. Like I wrote before generated tests guard our application so that it fullfills the implemented contract. Right now the contract definition needs to be corrected as well so that build can successfully be finished. When we do this the updated stub jar will be pushed to the repository and when the developer on the consumer side will build a project, a new stub will be downloaded and tests will fail. That way the developer on the consumer side will know that some changes happened and his code needs to be updated according to those changes.
Here is the console output from the test on the consumer side when a new stub with “movieTitle” change was used.
org.opentest4j.AssertionFailedError:
expected: "Movie1"
but was: null
Expected :"Movie1"
Actual :null
The value is null because the test checks value of the variable “title” but such a variable no longer exists. The code and tests need to be corrected.
Summary
On that simple example I showed you how contract testing can help with maintaining compatibility between the producer and the consumer and protect us from failure in production or the tests environments.