Consumer Driven Contracts with Pact

Part 1

Pact is a contract testing tool similar to Spring Cloud Contracts.


“Contract testing is a way to ensure that services (such as an API provider and a client) can communicate with each other. Without contract testing, the only way to know that services can communicate is by using expensive and brittle integration tests”

https://docs.pact.io/

In this post I will explain how I was able to leverage Pact using Java as both a Consumer and Provider. There are two schools of thought when integrating with Spring Cloud Contract, the first being the Consumer Driven Approach that Pact is best known for, and the other being the Producer Driven Approach utilizing Spring Cloud Contracts DSL.

This post will specifically target the Consumer Driven Approach as a Java to Java application. Later in the series I will detail how to the Provider Driven Approach and when it makes sense to use each. As well as detailing how to integrate with a Pact Broker and using other technology stacks such as React for the Consumer.

Consumer Driven Approach

This approach means the Consumer will create tests in their application defining the contract that they require. The producer will then pull that contract into their application via the broker, generate tests and verify their API conforms to the contract. With this approach every consumer should define their expectations for the producer.

Consumer Setup

Using JUnit 5

As of this writing Spring Boot 2.x doesn’t use JUnit 5 by default and I decided I wanted to use it, so I need to update my project to use JUnit 5 as a first step.

dependencies {
    ...
    testImplementation 'org.junit.jupiter:junit-jupiter:5.4.2'
}
test {
    useJUnitPlatform()
}

The above code in my build.gradle pulls in the new JUnit 5 libraries and then within the test block I am telling Gradle to use the new JUnit Platform.

Next I want to pull in the required libraries for Pact and since I will be using Spring Cloud Contract to generate my contract tests I will also pull that in.

apply plugin: 'au.com.dius.pact'

buildscript {
    ext { ... }
    repositories { ... }
    dependencies {
        ...
        classpath "au.com.dius:pact-jvm-provider-gradle_2.12:3.6.5"
    }
}
dependencies {
    ...
     implementation "org.springframework.cloud:spring-cloud-starter-contract-verifier:2.1.1.RELEASE"
    testImplementation "au.com.dius:pact-jvm-consumer-junit5_2.12:3.6.5"
    testImplementation "org.springframework.cloud:spring-cloud-contract-pact:2.1.1.RELEASE"
}
pact {
    publish {
        pactBrokerUrl = "<pact_broker_url>"
    }
}

If the Pact Broker Url is https you will need to include a Certificate for the pactPublish task. You can point to the keystore while running a gradle build using -Djavax.net.ssl.trustStore=<path-to-store>

Next I create my tests that will define my contract

@ExtendWith(PactConsumerTestExt.class)
public class AnimalsTest {

    @Pact(consumer= "consumingApp", provider = "producerApi")
    public RequestResponsePact getAnimals(PactDslWithProvider builder) {
        return builder
                .given("")
                    .uponReceiving("Successful get of an Animal")
                    .path("/api/v1/animals")
                    .method("GET")
                    .headers("Accepts", "application/json")
                    .willRespondWith()
                    .status(200)
                    .body(
                            new PactDslJsonBody()
                                  .array("animals")
                                       .stringType("cat")
                                       .stringType("chicken")
                                       .stringType("cow")
                                  .closeArray()
                               .asBody()
                    )
                    .headers(responseHeaders())
                .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "getAnimals", port="1234")
    public void verifyGetAnimalsPact(MockServer mockServer) throws IOException {
        HttpResponse httpResponse = Request.Get(mockServer.getUrl() + "/api/v1/animals").addHeader("Accepts", "application/json").execute().returnResponse();
        assertThat(httpResponse.getStatusLine().getStatusCode(), is(equalTo(200)));
    }
}

The @Pact method defines a single interaction between a consumer and a provider, called a “fragment” of a pact. A test class can contain multiple such fragments which together make up a complete pact. The @Pact annotation tells Pact that we want to define a pact fragment. It contains the names of the consumer and the provider to uniquely identify the contract partners.

Within the method, we make use of the Pact DSL to create the contract. In the first two lines we describe the state the provider should be in to be able to answer this interaction (“given”) and the request the consumer sends (“uponReceiving”).

Next, we define how the request should look like. In this example, we define a URI and the HTTP method GET. Having defined the request, we go on to define the expected response to this request. Here, we expect HTTP status 20, the content type application/json and a JSON response body .

With @ExtendsWith(PactConsumerTestExt.class) together with the @PactTestFor annotation, we tell pact to start a mock API provider on localhost:8888. This mock provider will return responses according to all pact fragments from the @Pact methods within the test class.

If the request the client sends to the mock provider looks as defined in the pact, the according response will be returned and the test will pass. If the client does something differently, the test will fail, meaning that we do not meet the contract. Once the test has passed, a pact file will be created in the build/pacts folder.

Here is an example

{
  "provider": {
    "name": "producerApi"
  },
  "consumer": {
    "name": "consumingApp"
  },
  "interactions": [
    {
      "description": "Successful get of an Animal",
      "request": {
        "method": "GET",
        "path": "/api/v1/animals",
        "headers": {
          "Accepts": "application/json"
        }
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json; charset\u003dUTF-8"
        },
        "body": {
          "animals": [
            "cat",
            "chicken",
            "cow"
          ]
        },
        "matchingRules": {
          "body": {
            "$.animals[0]": {
              "matchers": [
                {
                  "match": "type"
                }
              ],
              "combine": "AND"
            },
            "$.animals[1]": {
              "matchers": [
                {
                  "match": "type"
                }
              ],
              "combine": "AND"
            },
            "$.animals[2]": {
              "matchers": [
                {
                  "match": "type"
                }
              ],
              "combine": "AND"
            }
          },
          "header": {
            "Content-Type": {
              "matchers": [
                {
                  "match": "regex",
                  "regex": "application/json(;\\s?charset\u003d[\\w\\-]+)?"
                }
              ],
              "combine": "AND"
            }
          }
        }
      },
      "providerStates": [
        {
          "name": ""
        }
      ]
    }
  ],
  "metadata": {
    "pactSpecification": {
      "version": "3.0.0"
    },
    "pact-jvm": {
      "version": "3.6.5"
    }
  }
}

Producer Setup

Add the following to your build.gradle

buildscript {
    ext { ... }
    repositories { ... }
    dependencies {
        ...
        classpath "org.springframework.cloud:spring-cloud-contract-gradle-plugin:${springCloudContractVersion}"
        classpath "org.springframework.cloud:spring-cloud-contract-pact:${springCloudContractVersion}"
    }
}
dependencies {
    ...
    testImplementation("org.springframework.cloud:spring-cloud-starter-contract-verifier") {
        exclude group: 'org.springframework.boot'
    }
    testImplementation('org.springframework.cloud:spring-cloud-contract-wiremock') {
        exclude group: 'org.springframework.boot'
    }
}
contracts {
    contractDependency {
        stringNotation = "${project.group}:${project.name}:+"
    }
    contractsMode = "REMOTE"
    baseClassForTests = "com.example.BaseTest"
    contractRepository {
        repositoryUrl = "pact://https://pact-broker-url.com:443"
    }
}

Create your base test, this is what Spring Cloud contract uses to generate contract tests from

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public abstract class BaseTest {

    @Autowired MockMvc mockMvc;

    // Inject @MockBeans if necessary

    @Before
    public void setup() {
        // Setup any necessary Mocks
        RestAssuredMockMvc.mockMvc(this.mockMvc);
    }
}

With such a setup:

  • Pact files will be downloaded from the Pact Broker
  • Spring Cloud Contract will convert the Pact files into tests and stubs
  • The JAR with the stubs gets automatically created as usual

References:


View the entire example on Github

Leave a Reply