logo logo

Consumer-Driven Contract Testing using Spring Cloud Contract

Consumer-Driven Contract Testing using Spring Cloud Contract

Spring Cloud Contract is an umbrella project that holds solutions to help users implement contract tests. It has two main modules:

  • Spring Cloud Contract Verifier, which is used mainly by the producer side.
  • Spring Cloud Contract Stub Runner, which is used by the consumer side.

Spring Cloud Contract Verifier creates a stub from the producer service which can be used by the consumer service to mock the calls. This provides both faster feedback and makes sure our tests actually reflect the code. This ensures the contract between a Producer and a Consumer, for both HTTP-based and message-based interactions.

Spring Cloud Contracts allows us to define contracts using Groovy DSL, YAML, PACT JSON, Spring Rest Docs.

In the Consumer-Driven Contract testing approach, consumers suggest the contracts in strong cooperation with the producers. These Contracts are usually stored in a Producer repository or in a centralized repository where both consumers and producers can have access to it.

Setup

To demonstrate the concept of Spring Cloud Contracts, we have the following back-end services:

  • Date Provider – REST Service with the endpoint /provider/validDate validates whether the given date is valid or not.
  • Age Consumer – REST Service with the endpoint /age-calculate calculates the age of a person from the given birth date.

Table of Contents – The Ultimate Guide to Testing Microservices

  1. Introduction to Consumer Contract Testing
  2. Consumer-Driven Contract Testing using Pact.js
  3. Consumer-Driven Contract Testing using Pact Java
  4. You’re here → Consumer-Driven Contract Testing using Spring Cloud Contracts
  5. Event Driven Architecture: How to Perform Contract Testing in Kafka/pubSub
  6. Integrating Contract Testing in Build Pipelines

Step-by-step Guide to Consumer-Driven Contracts (CDC) with Contracts on the Producer Side

Consumer Side:

Below is the UML diagram followed by a developer at the consumer side i.e at the Age Calculator end.

Consumer UML Diagram

Start doing TDD by writing a test:

Below is the sample integration test for calculating the age of a person whose valid birth date is given:

@Test
public void shouldCalculateAgeForAGivenValidBirthDate()
        throws Exception {

    mockMvc.perform(MockMvcRequestBuilders.get("/age-calculate")
            .header("content-type", "application/json")
            .param("birthDate", "2001-02-03"))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().contentTypeCompatibleWith(APPLICATION_JSON))
            .andExpect(MockMvcResultMatchers.jsonPath("$.years").exists())
            .andExpect(MockMvcResultMatchers.jsonPath("$.months").exists())
            .andExpect(MockMvcResultMatchers.jsonPath("$.days").exists());
}

 Write the Missing Implementation:

Age Calculator service has to send a request to the Date provider service i.e to the GET endpoint /provider/validDate to check the given birth date is a valid date or not.

String uri = UriComponentsBuilder.fromHttpUrl(dateProviderServiceURL)
        .path("provider/validDate")
        .queryParam("date", birthDate)
        .toUriString();
HttpHeaders headers = new HttpHeaders();
headers.add("content-type", "application/json");

ResponseEntity<DateResponse> responseEntity = new RestTemplate().exchange(uri, HttpMethod.GET,
        new HttpEntity<>(headers), DateResponse.class);

Once the code for the missing implementation is completed, clone the Date Provider repository and start playing around with the server-side contract.

Define Consumer-Driven Contracts:

As specified above, consumer-driven contracts can be specified in any available ways i.e in Groovy DSL, YAML, PACT JSON, Spring Rest Docs. The following example shows the contract written in Groovy:

package contracts

import org.springframework.cloud.contract.spec.Contract

Contract.make {
    description("Should return valid as true if valid date is input")
    request {
        method('GET')
        urlPath('/provider/validDate') {
            queryParameters {
                parameter("date", $(consumer(isoDate()), producer('2001-02-03')))
            }
        }
        headers {
            contentType('application/json')
        }
    }
    response {
        status(200)
        headers {
            contentType('application/json')
        }
        body(
                givenDate: $(consumer('2001-02-03'), producer(isoDate())),
                year: $(consumer('2001'), producer(regex("(190[0-9]|19[5-9]\\d|200\\d|2020)"))),
                month: $(consumer('2'), producer(regex("(1[0-2]|[1-9])"))),
                day: $(consumer('3'), producer(regex("(3[01]|[12][0-9]|[1-9])"))),
                isValidDate: true,
                message: "date parsed successfully"
        )
    }
}

Once you are ready to check the API in practice in the integration tests, you need to install the stubs locally.

Add the Spring Cloud Contract Verifier Plugin:

In below example, we added the Spring Cloud Contract Verifier Maven plugin:

<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>2.2.2.RELEASE</version>
    <extensions>true</extensions>
    <configuration>
        <baseClassForTests>
            com.example.dateprovider.springcloudcontract.BaseTestClass
        </baseClassForTests>
        <testFramework>JUNIT5</testFramework>
    </configuration>
</plugin>

Being a consumer of date-provider service, we don’t want to generate tests but we want to generate and install stubs by skipping the tests like below:

mvn clean install -DskipTests -pl date-provider

Once we run the above command, we can notice that in maven logs that stubs are getting installed:

[INFO] --- maven-install-plugin:2.5.2:install (default-install) @ date-provider ---
[INFO] Installing /Users/sekars/workspace/ContractTestingBoilerplate/date-provider/target/date-provider-0.0.1-SNAPSHOT.jar to /Users/sekars/.m2/repository/com/example/date-provider/0.0.1-SNAPSHOT/date-provider-0.0.1-SNAPSHOT.jar
[INFO] Installing /Users/sekars/workspace/ContractTestingBoilerplate/date-provider/pom.xml to /Users/sekars/.m2/repository/com/example/date-provider/0.0.1-SNAPSHOT/date-provider-0.0.1-SNAPSHOT.pom
[INFO] Installing /Users/sekars/workspace/ContractTestingBoilerplate/date-provider/target/date-provider-0.0.1-SNAPSHOT-stubs.jar to /Users/sekars/.m2/repository/com/example/date-provider/0.0.1-SNAPSHOT/date-provider-0.0.1-SNAPSHOT-stubs.jar

It confirms that the stubs of the date-provider have been installed in the local repository.

Running the Integration Tests at Consumer end using Stubs:

Add spring-cloud-contract-stub-runner maven dependencies in age-consumer pom.xml as like below:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-stub-runner</artifactId>
    <version>2.2.2.RELEASE</version>
</dependency>

Annotate the integration tests at the consumer side with @AutoConfigureStubRunner. Within the annotation specify the group id and artifact id of the provider stub for added stub -runner dependency to download the stubs from the local m2 repository.

@AutoConfigureStubRunner(
        stubsMode = StubRunnerProperties.StubsMode.LOCAL,
        ids = "com.example:date-provider:+:stubs:8080")
public class AgeControllerIntegrationTest {

Execute integration tests using below maven command:

mvn clean test -pl age-consumer

Once we execute integration tests we can notice in logs that stubs are downloaded by stub runner from the local repository as shown below:

2020-04-19 16:10:04.565  INFO 50360 --- [           main] o.s.c.c.s.AetherStubDownloaderBuilder    : Will download stubs and contracts via Aether
2020-04-19 16:10:04.570  INFO 50360 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Remote repos not passed but the switch to work offline was set. Stubs will be used from your local Maven repository.
2020-04-19 16:10:04.731  INFO 50360 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Desired version is [+] - will try to resolve the latest version
2020-04-19 16:10:04.754  INFO 50360 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved version is [0.0.1-SNAPSHOT]
2020-04-19 16:10:04.773  INFO 50360 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Resolved artifact [com.example:date-provider:jar:stubs:0.0.1-SNAPSHOT] to /Users/sekars/.m2/repository/com/example/date-provider/0.0.1-SNAPSHOT/date-provider-0.0.1-SNAPSHOT-stubs.jar
2020-04-19 16:10:04.774  INFO 50360 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Unpacking stub from JAR [URI: file:/Users/sekars/.m2/repository/com/example/date-provider/0.0.1-SNAPSHOT/date-provider-0.0.1-SNAPSHOT-stubs.jar]
2020-04-19 16:10:04.785  INFO 50360 --- [           main] o.s.c.c.stubrunner.AetherStubDownloader  : Unpacked file to [/var/folders/f5/fwh8w_ms6q377gn_fb2bmjp40000gp/T/contracts-1587292804774-0]
2020-04-19 16:10:05.817  INFO 50360 --- [           main] wiremock.org.eclipse.jetty.util.log      : Logging initialized @3777ms to wiremock.org.eclipse.jetty.util.log.Slf4jLog
2020-04-19 16:10:05.948  INFO 50360 --- [           main] w.org.eclipse.jetty.server.Server        : jetty-9.4.20.v20190813; built: 2019-08-13T21:28:18.144Z; git: 84700530e645e812b336747464d6fbbf370c9a20; jvm 1.8.0_192-b12
2020-04-19 16:10:05.976  INFO 50360 --- [           main] w.o.e.j.server.handler.ContextHandler    : Started w.o.e.j.s.ServletContextHandler@36068727{/__admin,null,AVAILABLE}
2020-04-19 16:10:05.980  INFO 50360 --- [           main] w.o.e.j.server.handler.ContextHandler    : Started w.o.e.j.s.ServletContextHandler@22bf9122{/,null,AVAILABLE}
2020-04-19 16:10:06.008  INFO 50360 --- [           main] w.o.e.jetty.server.AbstractConnector     : Started NetworkTrafficServerConnector@11b32a14{HTTP/1.1,[http/1.1]}{0.0.0.0:8080}
2020-04-19 16:10:06.008  INFO 50360 --- [           main] w.org.eclipse.jetty.server.Server        : Started @3971ms
2020-04-19 16:10:06.009  INFO 50360 --- [           main] o.s.c.contract.stubrunner.StubServer     : Started stub server for project [com.example:date-provider:0.0.1-SNAPSHOT:stubs] on port 8080

Once the tests pass and the contracts are satisfied, we can raise a pull request to the producer repository and the consumer work is done.

Producer Side:

As a developer of date-provider micro-service, we will review the pull request raised by the consumer and write the missing implementation to deploy the application. Below UML diagram shows the Producer side flow:

producer UMLDiagram

Once the pull requests are taken over, the developer adds the initial implementation of the provider. Then the developer adds the maven dependencies required to auto-generate tests:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-verifier</artifactId>
    <version>2.2.2.RELEASE</version>
    <scope>test</scope>
</dependency>

Once the base class for tests is created, then the developer adds the base class in the spring cloud contract maven plugin for packageWithBaseClasses property. Developer runs below command to auto-generate the tests at producer end:

mvn clean install -pl date-provider

Auto-generated tests look like below:

public class ContractVerifierTest extends BaseTestClass {

  @Test
  public void validate_shouldReturnValidDateWhenRequestParamHasValidDate() throws Exception {
    // given:
      MockMvcRequestSpecification request = given()
          .header("Content-Type", "application/json");

    // when:
      ResponseOptions response = given().spec(request)
          .queryParam("date","2001-02-03")
          .get("/provider/validDate");

    // 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("['givenDate']").matches("(\\d\\d\\d\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])");
      assertThatJson(parsedJson).field("['year']").matches("(190[0-9]|19[5-9]\\d|200\\d|2020)");
      assertThatJson(parsedJson).field("['month']").matches("(1[0-2]|[1-9])");
      assertThatJson(parsedJson).field("['day']").matches("(3[01]|[12][0-9]|[1-9])");
      assertThatJson(parsedJson).field("['isValidDate']").isEqualTo(true);
      assertThatJson(parsedJson).field("['message']").isEqualTo("date parsed successfully");
  }

}

Tests will ideally fail since the implementation is not complete at date-provider micro-service. Missing implementation is added by the developer as below:

@GetMapping("/provider/validDate")
public DateResponse getValidDate(@RequestParam(name = "date") String date) {
    return dateService.getValidDate(date);
}

Once the implementation is complete, we run the tests again it passes. Branch will be merged which would publish both the application and the stubs artifacts using the CI server.

Consumer Side:

As a developer of age-consumer application will merge the changes into master and will switch stubs mode to online using the repository URL.

@AutoConfigureStubRunner(
        stubsMode = StubRunnerProperties.StubsMode.REMOTE,
        repositoryRoot = "https://repo.spring.io/libs-snapshot",
        ids = "com.example:date-provider:+:stubs:8080")
public class AgeControllerIntegrationTest {

After this change, the stubs of the server-side are automatically downloaded from Nexus/Artifactory.

Sample code snippets are available here.

Happy Testing 😎
Srinivasan Sekar & Sai Krishna

About the author

Srinivasan Sekar

Srinivasan Sekar is a Lead Consultant at ThoughtWorks. He loves contributing to Open Source. He is an Appium Member and Selenium Contributor as well. He worked extensively on testing various Mobile and Web Applications. He specializes in building automation frameworks. He has also spoken at various conferences including SeleniumConf, AppiumConf, SLASSCOM, BelgradeTestConf, QuestForQualityConf, and FOSDEM.

Leave a Reply

FacebookLinkedInTwitterEmail