Quite some time ago we have talked about consumer-driven contract testing from the perspective of the REST(ful) web APIs in general and their projection into Java (JAX-RS 2.0 specification) in particular. It would be fair to say that REST still dominates the web API landscape, at least with respect to public APIs, however the shift towards microservices or/and service-based architecture is changing the alignment of forces very fast. One of such disrupting trends is messaging.
Modern REST(ful) APIs are implemented mostly over HTTP 1.1 protocol and are constrained by its request/response communication style. The HTTP/2 is here to help out but still, not every use case fits into this communication model. Often the job could be performed asynchronously and the fact of its completion could be broadcasted to interested parties later on. This is how most of the things work in real life and using messaging is a perfect answer to that.
The messaging space is really crowded with astonishing amount of message brokers and brokerless options available. We are not going to talk about that instead focusing on another tricky subject: the message contracts. Once the producer emits message or event, it lands into the queue/topic/channel, ready to be consumed. It is here to stay for some time. Obviously, the producer knows what it publishes, but what about consumers? How would they know what to expect?
At this moment, many of us would scream: use schema-based serialization! And indeed, Apache Avro, Apache Thrift, Protocol Buffers, Message Pack, ... are here to address that. At the end of the day, such messages and events become the part of the provider contract, along with the REST(ful) web APIs if any, and have to be communicated and evolved over time without breaking the consumers. But ... you would be surprised to know how many organizations found their nirvana in JSON and use it to pass messages and events around, throwing such clobs at consumers, no schema whatsoever! In this post we are going to look at how consumer-driven contract testing technique could help us in such situation.
Let us consider a simple system with 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.
Since Order Service is implemented in Java, the events are just POJO classes, serialized into JSON before arriving to the message broker using one of the numerous libraries out there. OrderConfirmed is one of such events.
public class OrderConfirmed { private UUID orderId; private UUID paymentId; private BigDecimal amount; private String street; private String city; private String state; private String zip; private String country; }
As it often happens, the Shipment Service team was handed over the sample JSON snippet or pointed out some documentation piece, or reference Java class, and that is basically it. How Shipment Service team could kickoff the integration while being sure their interpretation is correct and the message's data they need will not suddenly disappear? Consumer-driven contract testing to the rescue!
The Shipment Service team could (and should) start off by writing the test cases against the OrderConfirmed message, embedding the knowledge they have, and our old friend Pact framework (to be precise, Pact JVM) is the right tool for that. So how the test case may look like?
public class OrderConfirmedConsumerTest { private static final String PROVIDER_ID = "Order Service"; private static final String CONSUMER_ID = "Shipment Service"; @Rule public MessagePactProviderRule provider = new MessagePactProviderRule(this); private byte[] message; @Pact(provider = PROVIDER_ID, consumer = CONSUMER_ID) public MessagePact pact(MessagePactBuilder builder) { return builder .given("default") .expectsToReceive("an Order confirmation message") .withMetadata(Map.of("Content-Type", "application/json")) .withContent(new PactDslJsonBody() .uuid("orderId") .uuid("paymentId") .decimalType("amount") .stringType("street") .stringType("city") .stringType("state") .stringType("zip") .stringType("country")) .toPact(); } @Test @PactVerification(PROVIDER_ID) public void test() throws Exception { Assert.assertNotNull(message); } public void setMessage(byte[] messageContents) { message = messageContents; } }
It is exceptionally simple and straightforward, no boilerplate added. The test case is designed right from the JSON representation of the OrderConfirmed message. But we are only half-way through, the Shipment Service team should somehow contribute their expectations back to the Order Service so the producer would keep track of who and how consumes the OrderConfirmed message. The Pact test harness takes care of that by generating the pact files (set of agreements, or pacts) out of the each JUnit test cases into the 'target/pacts' folder. Below is an example of the generated Shipment Service-Order Service.json pact file after running OrderConfirmedConsumerTest test suite.
{ "consumer": { "name": "Shipment Service" }, "provider": { "name": "Order Service" }, "messages": [ { "description": "an Order confirmation message", "metaData": { "contentType": "application/json" }, "contents": { "zip": "string", "country": "string", "amount": 100, "orderId": "e2490de5-5bd3-43d5-b7c4-526e33f71304", "city": "string", "paymentId": "e2490de5-5bd3-43d5-b7c4-526e33f71304", "street": "string", "state": "string" }, "providerStates": [ { "name": "default" } ], "matchingRules": { "body": { "$.orderId": { "matchers": [ { "match": "regex", "regex": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" } ], "combine": "AND" }, "$.paymentId": { "matchers": [ { "match": "regex", "regex": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" } ], "combine": "AND" }, "$.amount": { "matchers": [ { "match": "decimal" } ], "combine": "AND" }, "$.street": { "matchers": [ { "match": "type" } ], "combine": "AND" }, "$.city": { "matchers": [ { "match": "type" } ], "combine": "AND" }, "$.state": { "matchers": [ { "match": "type" } ], "combine": "AND" }, "$.zip": { "matchers": [ { "match": "type" } ], "combine": "AND" }, "$.country": { "matchers": [ { "match": "type" } ], "combine": "AND" } } } } ], "metadata": { "pactSpecification": { "version": "3.0.0" }, "pact-jvm": { "version": "4.0.2" } } }
The next step for Shipment Service team is to share this pact file with Order Service team so these guys could run the provider-side Pact verifications as part of their test suites.
@RunWith(PactRunner.class) @Provider(OrderServicePactsTest.PROVIDER_ID) @PactFolder("pacts") public class OrderServicePactsTest { public static final String PROVIDER_ID = "Order Service"; @TestTarget public final Target target = new AmqpTarget(); private ObjectMapper objectMapper; @Before public void setUp() { objectMapper = new ObjectMapper(); } @State("default") public void toDefaultState() { } @PactVerifyProvider("an Order confirmation message") public String verifyOrderConfirmed() throws JsonProcessingException { final OrderConfirmed order = new OrderConfirmed(); order.setOrderId(UUID.randomUUID()); order.setPaymentId(UUID.randomUUID()); order.setAmount(new BigDecimal("102.33")); order.setStreet("1203 Westmisnter Blvrd"); order.setCity("Westminster"); order.setCountry("USA"); order.setState("MI"); order.setZip("92239"); return objectMapper.writeValueAsString(order); } }
The test harness picks all the pact files from the @PactFolder and run the tests against the @TestTarget, in this case we are wiring AmqpTarget, provided out of the box, but your could plug your own specific target easily.
And this is basically it! The consumer (Shipment Service) have their expectations expressed in the test cases and shared with the producer (Order Service) in a shape of the pact files. The producers have own set of tests to make sure its model matches the consumers' view. Both sides could continue to evolve independently, and trust each other, as far as pacts are not denounced (hopefully, never).
To be fair, Pact is not the only choice for doing consumer-driven contract testing, in the upcoming post (already in work) we are going to talk about yet another excellent option, Spring Cloud Contract.
As for today, the complete project sources are available on Github.
No comments:
Post a Comment