Fetching data from other systems is common in a microservice-based architecture. Besides the downtime of the other service, a broken API contract is one of the worst things that can happen. As a client of an external API, you must trust that the data contract is stable and changes are made known in advance. When it comes to testing the interaction with the external service, you can mock the response of the targeted service using frameworks like WireMock. Manually writing mock results can become quite cumbersome if you call many services and if the response is rather big. Luckily there is a nice solution to tackle these two tasks: Consumer-Driven Contracts with Spring Cloud Contract.
With this blog post, you'll learn how to use and write consumer-driven contracts with Spring Cloud Contract using Java 11, Spring Boot 2.4.6, JUnit 5, and Spring Cloud 2020.0.
Introduction to Spring Cloud Contract
Spring promotes this umbrella project with the following features:
- ensure that HTTP / Messaging stubs (used when developing the client) are doing exactly what actual server-side implementation will do
- promote acceptance test driven development method and Microservices architectural style
- to provide a way to publish changes in contracts that are immediately visible on both sides of the communication
- generate boilerplate test code used on the server side
To demonstrate these features of Spring Cloud Contract, we'll work with a system architecture that contains two services: the book-store-server (aka. producer) and the book-store-client (aka. consumer).
The book store client fetches all available books from the book store server endpoint /books
. During integration tests for the client, we'll use a stub of the book server that we'll automatically generate with Spring Cloud Contract to ensure the API contract:
Setting Up the Server-Side (Book Store Server)
The required Maven dependencies for the book store server are the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | <?xml version="1.0" encoding="UTF-8"?> <project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.4.6</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>de.rieckpil.blog</groupId> <artifactId>book-store-server</artifactId> <version>0.0.1-SNAPSHOT</version> <name>book-store-server</name> <description>Book Store Server</description> <properties> <java.version>11</java.version> <spring-cloud.version>2020.0.2</spring-cloud.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.github.javafaker</groupId> <artifactId>javafaker</artifactId> <version>1.0.2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <build> <!-- explained later --> </build> </project> |
For demonstration purposes, we'll keep our REST endpoint for this example quite simple:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | @RestController public class BookController { private BookService bookService; public BookController(BookService bookService) { this.bookService = bookService; } @GetMapping("/books") public ResponseEntity<List<Book>> getTodos() { return ResponseEntity.ok(bookService.getBooks()); } } |
With the help of Java Faker, ourBookService
returns ten random books:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | @Service public class BookService { public List<Book> getBooks() { Faker faker = new Faker(); List<Book> books = new ArrayList(); for (int i = 0; i < 10; i++) { Book book = new Book(); book.setGenre(faker.book().genre()); book.setTitle(faker.book().title()); book.setIsbn(UUID.randomUUID().toString()); books.add(book); } return books; } } |
Creating Contracts with Spring Cloud Contract
What's next is to define the contracts for our /books
API endpoint. As part of these contracts, we define the specifications of our endpoint and how the response (i.e., API contract) looks like.
We can define these contracts with either Groovy or YAML. By default, Spring Cloud Contract expects our contract files as part of the folder src/test/resources/contracts
.
For this example, we'll create one contract test that will make sure a GET call to /books
returns the given data as application/json
with an HTTP 200 status. Therefore we'll create a shouldReturnBooks.groovy
file and declare the contract:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | package contracts import org.springframework.cloud.contract.spec.Contract Contract.make { description 'Should return one book in a list' request { method GET() url '/books' } response { status OK() body '''\ [ { "title": "Java 11", "genre": "Technology", "isbn": "42" } ] ''' headers { contentType('application/json') } } } |
For Spring Cloud Contract to automatically generate the contracts test, we'll need a base test class later on. As part of this base class, we define our test setup and configure RestAssuredMockMvc
for our controllers.
We'll mock any collaborator of our controller classes with Mockito:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | @ExtendWith(MockitoExtension.class) public abstract class BaseTest { @Mock private BookService mockedBookService; @BeforeEach void setup() { when(mockedBookService.getBooks()) .thenReturn(List.of(new Book("Java 11", "Technology", "42"))); RestAssuredMockMvc.standaloneSetup(new BookController(mockedBookService)); } } |
Next, we need to point to this base class as part of the Spring Cloud Contract Maven plugin configuration:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> <plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>3.0.2</version> <extensions>true</extensions> <configuration> <baseClassForTests>de.rieckpil.blog.BaseTest</baseClassForTests> </configuration> </plugin> </build> |
During the build step (after the maven-compiler-plugin), the Maven plugin will generate a test based on this contract under target/generated-test-sources/contracts
and execute this test during the test step.
In addition a book-store-server-0.0.1-SNAPSHOT-stubs.jar
is created, containing the WireMock stub:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | public class ContractVerifierTest extends BaseTest { @Test public void validate_shouldReturnBooks() throws Exception { // given: MockMvcRequestSpecification request = given(); // when: ResponseOptions response = given().spec(request) .get("/books"); // then: assertThat(response.statusCode()).isEqualTo(200); assertThat(response.header("Content-Type")).matches("application/json.*"); // and: DocumentContext parsedJson = JsonPath.parse(response.getBody().asString()); assertThatJson(parsedJson).array().contains("['genre']").isEqualTo("Technology"); assertThatJson(parsedJson).array().contains("['isbn']").isEqualTo("42"); assertThatJson(parsedJson).array().contains("['title']").isEqualTo("Java 11"); } } |
We have to make sure to run mvn install
so that the created .jar
files (especially the stub jar) are installed in our local Maven (.m2
folder) repository.
To make these stubs available for other developers/the internet, we can upload the XYZ-stubs.jar
to, e.g., Artifactory or Maven Central during our CI pipeline run.
Setting Up the Client-Side for Spring Cloud Contract
The setup for the client-side is a little bit different:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | <?xml version="1.0" encoding="UTF-8"?> <project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.4.6</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>de.rieckpil.blog</groupId> <artifactId>book-store-client</artifactId> <version>0.0.1-SNAPSHOT</version> <name>book-store-client</name> <description>Book Store Client</description> <properties> <java.version>11</java.version> <spring-cloud.version>2020.0.2</spring-cloud.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> |
Instead of the spring-cloud-contract-verifier
, the spring-cloud-contract-stub-runner
is used here. Note that we don't have to include the XYZ-stubs.jar
in our project to make use of it.
We're using the Spring WebFlux WebClient to fetch all available books from our book-store-server (producer):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | @Component public class BookClient { private WebClient webClient; @PostConstruct public void setUpWebClient() { var httpClient = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2_000) .doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(2)) .addHandlerLast(new WriteTimeoutHandler(2))); this.webClient = WebClient.builder() .baseUrl("http://localhost:8080") .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); } public JsonNode getAllAvailableBooks() { JsonNode availableBooks = webClient.get() .uri("/books") .accept(MediaType.APPLICATION_JSON) .retrieve() .bodyToMono(JsonNode.class) .block(); return availableBooks; } } |
For the sake of simplicity, the method returns the data as JsonNode
(Jackson's JSON representation).
What's left is to now write a test that uses the automatically generated stub of our producer to verify our client works with given the API contract:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | @SpringBootTest @AutoConfigureStubRunner( ids = {"de.rieckpil.blog:book-store-server:+:stubs:8080"}, stubsMode = StubRunnerProperties.StubsMode.LOCAL ) class BookClientTest { @Autowired private BookClient cut; @Test void testContractToBookStoreServer() { JsonNode result = cut.getAllAvailableBooks(); assertTrue(result.isArray()); JsonNode firstBook = result.get(0); assertTrue(firstBook.has("genre")); assertTrue(firstBook.has("title")); assertTrue(firstBook.has("isbn")); assertTrue(firstBook.get("isbn").isTextual()); assertTrue(firstBook.get("genre").isTextual()); assertTrue(firstBook.get("title").isTextual()); } } |
The @AutoConfigureStubRunner
annotation takes an array of strings for its ids
attribute to reference the required XYZ-stubs.jar
files with a port definition for the WireMock server.
The stubsMode
attribute defines how these .jar
s are loaded: either from the classpath (requires to add them to the Maven project), local (local .m2/repository
folder) or remote (using your configured Maven repositories). During test execution, the WireMock server will respond with the defined data in the contract on the server-side, and the test will pass.
For more detailed information about consumer-driven contracts with Spring Cloud Contract, have a look at the following resources:
- Spring Guide: Consumer-Driven Contracts
- Project overview Spring Cloud Contract
- Spring Cloud Contract documentation
For more in-depth content and tips about testing Spring Boot applications, consider enrolling in the Testing Spring Boot Applications Masterclass.
You can find the code for the book store server and client on GitHub and further Spring-related resources on my blog.
Have fun writing consumer-driven contracts,
Phil