Introdution
In the previous article, we described the principles of the Consumer-Driven Contract approach. We went through the potential use cases of CDC and scenarios where the CDC doesn’t fit. Since the Consumer-Driven Contract is a concept, it may be implemented in different ways.
This article focuses on the implementation based on Pact and using the following tools/frameworks:
- Spring Boot-based applications as API provider and consumer,
- pactflow.io as a Contracts Broker,
- Pact libraries used by the API provider and API consumer, and responsible for publishing (consumer) and verifying (provider) Contracts,
- Junit5,
- Gradle.
Sample Project
For the purpose of this article, we prepared a sample API. It’s a simple HTTP API exposing sensors data. Three methods are exposed here:
- GET: /api/sensors - get all sensors
- GET: /api/sensors/{sensorId} - get single sensor
- POST: /api/sensors - create sensor
Each sensor has two properties: id and name.
Producing a Contract
Everything starts from a technical perspective here. Before the agreement between provider and consumer is made, the documentation defined by the provider needs to be shared with the consumer. But the CDC flow begins with the consumer defining the contract. Before we start writing code, we have to add the following dependency to our project:
au.com.dius:pact-jvm-consumer-junit5:4.0.2
Defining the contract takes the form of an integration test. Let’s stop thinking about the CDC for a moment and consider preparing an integration test for the component that makes HTTP calls. You need two things: the stub for the API and the test. Let’s start with the stub.
@Pact(consumer = "sensor_management") public RequestResponsePact sensors(PactDslWithProvider builder) { Map headers = new HashMap<>(); headers.put("Content-Type", "application/json;charset=UTF-8"); return builder .given("Two sensors exist") .uponReceiving("List of sensors") .path("/api/sensors") .method("GET") .willRespondWith() .status(OK.value()) .headers(headers) .body(loadFile("sensors.json")) .toPact(); }
Almost everything is self-explanatory. It’s an API stub definition prepared using the Pact fluent API. We haven’t touched on the topic of the CDC yet. It’s very similar to what we can do with HTTP-based API simulators like Wiremock. We can define the input which is HTTP GET method against the /api/sensors path and the output - in this case, following the JSON body (sensors.json):
[ { "id": 1, "name": "Sensor in the kitchen" }, { "id": 2, "name": "Sensor in the bedroom" } ]
The second part is a test:
@Test @PactTestFor(pactMethod = "sensors") void shouldPassSensorsRetrievedFromProvider() { Collection<Sensor> sensors = sensorsFacade.getSensors(); assertThat(sensors).isEqualTo(ImmutableList.of( sensor("1", "Sensor in the kitchen"), sensor("2", "Sensor in the bedroom"))); }
SensorsFacade is a proxy class that translates methods invocation into an HTTP call using the RestTemplate. After removing boilerplate code, it’s as simple as that:
Collection<Sensor> getSensors() { return restTemplate.exchange("http://localhost:8090/api/sensors") }
As above, if we skip the @PactTestFor annotation, it’s just a simple test. It has the standard “given, when, then” test structure (the given is the stub we have prepared earlier). We can stop here, and it will still bring us some value. Pact can be used as a mock server, similarly to Wiremock, which we mentioned earlier.
Let’s focus on two annotations now. The first one (@Pact) defines the consumer name and marks the method as a Pack method. The second annotation (@PactTestFor) connects the Pact method with a test case. That’s how the test knows how to stub API.
The last thing we need to add before we run the tests is the annotations used to let the test class know that we want to bring up the Spring context and enable Pact.
@SpringBootTest @ExtendWith({PactConsumerTestExt.class}) @PactTestFor(providerName = "sensor_provider", port = "8090") public class SensorsFacadeIntegrationTest {
The test passed, and that’s the last point that CDC testing and testing with a Mock Server have in common. That’s because apart from the verification of our test case, the JSON file containing a contract has been generated in the target directory (target/pacts).
{ "provider": { "name": "sensor_provider" }, "consumer": { "name": "sensor_management" }, "interactions": [ { "description": "Create sensor", "request": { "method": "POST", "path": "/api/sensors", "headers": { "Content-Type": "application/json;charset\u003dUTF-8" }, "body": { "name": "Sensor in the bathroom" } }, "response": { "status": 201, "headers": { "Content-Type": "application/json;charset\u003dUTF-8" }, "body": { "id": "3" } }, "providerStates": [ { "name": "Sensor with name does not exist" } ] }, { "description": "List of sensors", "request": { "method": "GET", "path": "/api/sensors" }, "response": { "status": 200, "headers": { "Content-Type": "application/json;charset\u003dUTF-8" }, "body": [ { "id": 1, "name": "Sensor in the kitchen" }, { "id": 2, "name": "Sensor in the bedroom" } ] }, "providerStates": [ { "name": "Two sensors exist" } ] } ], "metadata": { "pactSpecification": { "version": "3.0.0" }, "pact-jvm": { "version": "4.0.2" } } }
As you can see, the contract is quite concise. Apart from json boilerplate, it contains only useful information about the expected API behavior.
Note that this contract has been simplified. Usually, the interactions node has many more children, each one describing a single Pact test case. I choose one GET and one POST request. Every interaction entry has:
- description
- provider state - it allows the provider to set up its state when validating the contract
- request - made by the consumer
- response - expected, returned by the provider
Everything until this moment happened on the consumer's side. Now we need to send the contract to the provider and offer them a chance to validate it.
Sharing the Contract
There are many ways we can share our contracts. It’s possible to use almost any way that includes S3/FTP/SCP. But Pact offers a component for storing and sharing contracts called Pact Broker. It provides many features out-of-the-box, which we would need to implement manually in other solutions (for example, S3).
We can use several different options for implementing the Pact Broker into the CDC flow. It can be deployed into our existing infrastructure because it’s an open-source project. It can also be used like a Software-as-as-Service available on https://pactflow.io/. In this article, we’re going to use the SaaS version of Pact Broker. After the registration, we get our own Pact Broker URL and API tokens (read and read/write).
Pact Broker exposes HTTP API we can use to manage contracts. But there are also plugins that make the process of sharing contracts easy to integrate with the existing build process.
Here’s sample Gradle configuration:
plugins { id 'au.com.dius.pact' version '4.0.2' } pact { publish { pactDirectory = 'arget/pacts' pactBrokerUrl = 'https://solidstudio.pact.dius.com.au' pactBrokerToken = ‘token_value’ } }
Let’s define the Gradle test task:
test { useJUnitPlatform() systemProperties['pact.rootDir'] = "$buildDir/pacts" }
And we’re ready to generate a contract:
./gradlew clean test --tests io.solidstudio.dev.cdc.consumer.SensorsContractTest
and then publish it to Pact Broker:
./gradlew pactPublish > Task :pactPublish Publishing 'sensor_management-sensor_provider.json' ... HTTP/1.1 201 Created
That concludes everything we need to do on the consumer side. Now it’s time for the provider part.
Verifying the Contract
The consumer generates a contract that describes what they want rather than referring to the provider’s real capacity to fulfill it. Each contract has to be verified by the provider to be treated as applicable. In our case, the provider is a simple Spring Boot-based Java app that exposes two HTTP endpoints. It has the logic needed to validate the contract by design because is has some domain implemented. But we need to tweak the provider a bit to be able to fetch the contract and use it as an input to a test suite.
We need to do two things. First, we have to add a dependency to the Pact provider library (in our case, it’s a version for Junit5, but there are many more options):
'au.com.dius:pact-jvm-provider-junit5:4.0.2'
Then we need to prepare code capable of verifying the contract.
@PactBroker(host = "solidstudio.pact.dius.com.au", 2 port = "443", 3 scheme = "https", 4 authentication = @PactBrokerAuth(username = "token_here", scheme = "Bearer")) 5 @Provider("sensor_provider") 6 @SpringBootTest(webEnvironment = RANDOM_PORT) 7 @ExtendWith(SpringExtension.class) 8 class ProviderTest { 9 10 @Autowired 11 private SensorRepository sensorRepository; 12 13 @LocalServerPort 14 private int port; 15 16 @BeforeEach 17 void setupTestTarget(PactVerificationContext context) { 18 context.setTarget(new HttpTestTarget("localhost", port, "/")); 19 } 20 21 @BeforeClass 22 void enablePublishingPact() { 23 System.setProperty("pact.verifier.publishResults", "true"); 24 } 25 26 @TestTemplate 27 @ExtendWith(PactVerificationInvocationContextProvider.class) 28 void pactVerificationTestTemplate(PactVerificationContext context) { 29 context.verifyInteraction(); 30 } 31 32 @State("Two sensors exist") 33 public void twoSensorsExist() { 34 sensorRepository.save(sensor("Sensor in the kitchen")); 35 sensorRepository.save(sensor("Sensor in the bedroom")); 36 } 37 38 @State("Sensor does not exist") 39 public void sensorDoesNotExists() {3} 40 41 @State("Invalid format of sensor id is passed") 42 public void invalidSensorIdPassed() { 43 } 44 45 private Sensor sensor(String name) { 46 Sensor sensor = new Sensor(); 47 sensor.setName(name); 48 return sensor; 49 } 50 }
This is the Junit5 test that can be divided into two parts: lines 1-5 are responsible for setting up Pact. We need to define how to connect with Pact Broker. We have also to tell Pact where it can expect the provider API (lines 16-19). Finally, we have also enabled the publishing verification process result to Pact Broker.
The second part manages the test flow. We inform the JUnit of how to perform the test in lines 26-30. It’s one line delivered by the Pact library we have added.
The next three methods are responsible for setting up the system to the state expected by the contract. Each case written in a contract defines the state in which the system has to be to act in some way. For instance, the contract we have prepared started with the following lines:
return builder .given("Two sensors exist")
We now have to define this state so that the two sensors are stored and then returned by the API.
Some states don’t require any specific set up steps because the system is able to handle some behaviors on its own - for example, validating requests and managing access to non-existing resources.
Let’s verify whether the consumer expectations and producer capabilities have met.
./gradlew test --tests io.solidstudio.dev.cdc.provider.VerifyContractTest
The Pact junit library has quite good logs. Among them, the most important one for us is the following:
Published verification result of 'au.com.dius.pact.core.pactbroker.TestResult$Ok@7fd4b9ec' for consumer 'Consumer(name=sensor_management)
Let’s briefly check the pactflow.io dashboard to check if everything we did is reflected here:
As you can see, the contract has been shared and verified by the provider.
Next Steps
You may have noticed that even though we used a few tools, we still several manual steps we needed to take to produce, share, and verify the contract. More importantly, we didn’t do anything with the result yet. The ultimate goal of the CDC is to integrate it with the CI/CD pipelines and make everything work automatically.
In the end, the most important outcome of this process is the decision to go/no go to production. We’re going to explore this topic further in the next article of this short series about Consumer-Driven Contracts. Stay tuned!