In the previous post we have started to talk about consumer-driven contract testing in the context of the message-based communications. In today's post, we are going to include yet another tool in our testing toolbox but before that, let me do a quick refresher on a system under the microscope. It has two services, Order Service and Shipment Service. The Order Service publishes the messages / events to the message queue and Shipment Service consumes them from there.
The search for the suitable test scaffolding led us to discovery of the Pact framework (to be precise, Pact JVM). The Pact offers simple and straightforward ways to write consumer and producer tests, leaving no excuses to not doing consumer-driven contract testing. But there is another player on the field, Spring Cloud Contract, and this is what we are going to discuss today.
To start with, Spring Cloud Contract fits the best JVM-based projects, built on top of terrific Spring portfolio (although you could make it work in polyglot scenarios as well). In addition, the collaboration flow that Spring Cloud Contract adopts is slightly different from the one Pact taught us, which is not necessarily a bad thing. Let us get straight to the point.
Since we are scoping out to messaging only, the first thing Spring Cloud Contract asks us to do is to define messaging contract specification, written using convenient Groovy Contract DSL.
package contracts org.springframework.cloud.contract.spec.Contract.make { name "OrderConfirmed Event" label 'order' input { triggeredBy('createOrder()') } outputMessage { sentTo 'orders' body([ orderId: $(anyUuid()), paymentId: $(anyUuid()), amount: $(anyDouble()), street: $(anyNonBlankString()), city: $(anyNonBlankString()), state: $(regex('[A-Z]{2}')), zip: $(regex('[0-9]{5}')), country: $(anyOf('USA','Mexico')) ]) headers { header('Content-Type', 'application/json') } } }
It resembles a lot Pact specifications we are already familiar with (if you are not a big fan of Groovy, no real need to learn it in order to use Spring Cloud Contract). The interesting parts here are triggeredBy and sentTo blocks: basically, those outline how the message is being produced (or triggered) and where it should land (channel or queue name) respectively. In this case, the createOrder() is just a method name which we have to provide the implementation for.
package com.example.order; import java.math.BigDecimal; import java.util.UUID; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.contract.verifier.messaging.boot.AutoConfigureMessageVerifier; import org.springframework.integration.support.MessageBuilder; import org.springframework.messaging.MessageChannel; import org.springframework.test.context.junit4.SpringRunner; import com.example.order.event.OrderConfirmed; @RunWith(SpringRunner.class) @SpringBootTest @AutoConfigureMessageVerifier public class OrderBase { @Autowired private MessageChannel orders; public void createOrder() { final OrderConfirmed order = new OrderConfirmed(); order.setOrderId(UUID.randomUUID()); order.setPaymentId(UUID.randomUUID()); order.setAmount(new BigDecimal("102.32")); order.setStreet("1203 Westmisnter Blvrd"); order.setCity("Westminster"); order.setCountry("USA"); order.setState("MI"); order.setZip("92239"); orders.send( MessageBuilder .withPayload(order) .setHeader("Content-Type", "application/json") .build()); } }
There is one small detail left out though: these contracts are managed by providers (or better to say, producers), not consumers. Not only that, the producers are responsible for publishing all the stubs for consumers so they would be able to write the tests against. Certainly a different path than Pact takes, but on the bright side, the test suite for producers are 100% generated by Apache Maven / Gradle plugins.
<plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>2.1.4.RELEASE</version> <extensions>true</extensions> <configuration> <packageWithBaseClasses>com.example.order</packageWithBaseClasses> </configuration> </plugin>
As you may have noticed, the plugin would assume that the base test classes (the ones which have to provide createOrder() method implementation) are located in the com.example.order package, exactly where we have placed OrderBase class. To complete the setup, we need to add a few dependencies to our pom.xml file.
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Greenwich.SR4</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.1.10.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
And we are done with producer side! If we run mvn clean install right now, two things are going to happen. First, you will notice that some tests were run and passed, although we wrote none, these were generated on our behalf.
------------------------------------------------------- T E S T S ------------------------------------------------------- Running com.example.order.OrderTest .... Results : Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
And secondly, the stubs for consumers are going to be generate (and published) as well (in this case, bundled into order-service-messaging-contract-tests-0.0.1-SNAPSHOT-stubs.jar).
... [INFO] [INFO] --- spring-cloud-contract-maven-plugin:2.1.4.RELEASE:generateStubs (default-generateStubs) @ order-service-messaging-contract-tests --- [INFO] Files matching this pattern will be excluded from stubs generation [] [INFO] Building jar: order-service-messaging-contract-tests-0.0.1-SNAPSHOT-stubs.jar [INFO] ....
Awesome, so we have messaging contract specification and stubs published, the ball is on consumer's field now, the Shipment Service. Probably, the most tricky part for the consumer would be to configure the messaging integration library of choice. In our case, it is going to be Spring Cloud Stream however other integrations are also available.
The fastest way to understand how the Spring Cloud Contract works on cosumer side is to start from the end and to look at the complete sample test suite first.
@RunWith(SpringRunner.class) @SpringBootTest @AutoConfigureMessageVerifier @AutoConfigureStubRunner( ids = "com.example:order-service-messaging-contract-tests:+:stubs", stubsMode = StubRunnerProperties.StubsMode.LOCAL ) public class OrderMessagingContractTest { @Autowired private MessageVerifier<Message<?>> verifier; @Autowired private StubFinder stubFinder; @Test public void testOrderConfirmed() throws Exception { stubFinder.trigger("order"); final Message<?> message = verifier.receive("orders"); assertThat(message, notNullValue()); assertThat(message.getPayload(), isJson( allOf(List.of( withJsonPath("$.orderId"), withJsonPath("$.paymentId"), withJsonPath("$.amount"), withJsonPath("$.street"), withJsonPath("$.city"), withJsonPath("$.state"), withJsonPath("$.zip"), withJsonPath("$.country") )))); } }
At the top, the @AutoConfigureStubRunner references the stubs published by producer, effectively the ones from order-service-messaging-contract-tests-0.0.1-SNAPSHOT-stubs.jar archive. The StubFinder helps us to pick the right stub for the test case and to trigger a particular messaging contract verification flow by means of calling stubFinder.trigger("order"). The value "order" is not arbitrary, it should match the label assigned to the contract specification, in our case we have it defined as:
package contracts org.springframework.cloud.contract.spec.Contract.make { ... label 'order' ... }
With that, the test should be looking simple and straightfoward: trigger the flow, verify that the message has been placed into the messaging channel and satisfies the consumer expectations. From the configuration standpoint, we only need to provide this messaging channel to run the tests against.
@SpringBootConfiguration public class OrderMessagingConfiguration { @Bean PollableChannel orders() { return MessageChannels.queue().get(); } }
And again, the name of the bean, orders, is not a random pick, it has to much the destination from the contract specification:
package contracts org.springframework.cloud.contract.spec.Contract.make { ... outputMessage { sentTo 'orders' ... } ... }
Last but not least, let us enumerate the dependencies which are required on consumer side (luckily, there is no need to use any additional Apache Maven or Gradle plugins).
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Greenwich.SR4</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-stream</artifactId> <version>2.2.1.RELEASE</version> <type>test-jar</type> <scope>test</scope> <classifier>test-binder</classifier> </dependency> </dependencies>
A quick note here. The last dependency is quite an important piece of the puzzle, it brings the integration of the Spring Cloud Stream with Spring Cloud Contract. With that, the consumers are all set.
------------------------------------------------------- T E S T S ------------------------------------------------------- Running com.example.order.OrderMessagingContractTest ... Results : Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
To close the loop, we should look back to the one of the core promises of the consumer-driven contract testing: allow the producers to evolve the contracts without breaking the consumers. What that means practically is that consumers may contribute their tests back to the producers, alhough the improtance of doing that is less of the concern with Spring Cloud Contract. The reason is simple: the producers are the ones who write the message contract specifications first and the tests generated out of these specifications are expected to fail against any breaking change. Nonetheless, there are number of benefits for producers to know how the consumers use their messages, so please give it some thoughts.
Hopefuly, it was an interesting subject to discuss. Spring Cloud Contract brings somewhat different perspective of applying consumer-driven contract testing for messaging. It is an appealing alternative to Pact JVM, especially if your applications and services already rely on Spring projects.
As always, the complete project sources are available on Github.