Don’t break the contract chain
In my previous article about the effective usage of test doubles, I described how mocks and stubs can be introduced to define contracts between internal components of a closed system (e.g. a microservice). This post will show you how to extend this approach to external contracts and how to integrate consumer-driven contracts in the development flow with test doubles.
What is a contract?
Wikipedia describes a contract as “a legally binding document between at least two parties that defines and governs the rights and duties of the parties to an agreement”. Let’s translate this definition into the realm of software components:
- parties — consumer, the requestor of the contract, and producer, the fulfiller of the contract
- agreement on the rights and duties — what the consumer expects from the producer and how these expectations will be delivered
The agreement defines the interface between parties composed of:
- operations (verbs)
- protocol — how to perform the operations (e.g. REST over HTTP or Java method call) and some details specific to the protocol (e.g. URI/URL of the resource)
- data types and formats
- inputs and return values
The contract can define dependencies between operations. For example, if operation A performs a state change in the producer then the contract describes how the effect will be observable by the consumer in the execution of operation B. For example, if we create a user, then we should expect that user to be returned in the get user query.
External and internal contracts
Contracts can be external or internal.
External contracts agree on the collaboration between components communicating via a Remote Procedure Call (I’m throwing REST in the same RPC basket).
Internal contracts describe how modules residing inside the same address space work together.
Conceptually, they are very similar, although the way they are implemented is different. External contracts can be implemented and automatically verified using tools that market themselves as following the Consumer-Driven Contracts approach (Pact, Spring Cloud Contract). Some protocols, like gRPC, force you to define statically typed service contracts with Protocol Buffers IDL. Internal contracts are expressed using mocking libraries (put your favorite tool here).
Contract chain
In mechanics, gears never work alone. They transmit force directly (arranged in gear trains) or with couplings. Similarly, software components are always dependent, exchanging messages or changing observable state.
Look at external and internal contracts not as separate and independent entities, but as a chain of agreements and component behaviors.
An external contract should be mirrored by behavior in the entry point on the remote supplier side. This behavior rarely fulfills the contract alone — it requires a network of collaborating modules. Internal contracts specify the collaboration until you hit the system boundary. Then you start the contract chain again, defining another external contract, now from the consumer perspective.
How can you combine external and internal contracts to ensure that software system pieces work together as expected? Let me illustrate the idea of contract chaining with an elaborated example. We’ll be implementing a pricing engine service for an e-shop with sophisticated business logic. The engine will be connected to an inventory system as we want to give the total price only for available items. We’ll be using Pact for external contracts and Mockk (for Kotlin) as the mocking library for internal contracts.
Shop UI wants to know the price
The shop frontend stores articles in a basket. It wants to get the total price of the basket and the price breakdown (including the number of available items). The frontend doesn’t know anything about pricing. It needs that feature from the remote pricing service. There is also an inventory-keeping service telling how many items we have in stock. The pricing service is going to collaborate with the inventory.
Before we start, let’s make some assumptions:
- There is fluent communication between consumer and producer teams. Consumers can request a change in the service on the producer side and it will be picked up promptly. In DDD terms, there is a partnership or consumer-supplier relationship between parties.
- The consumer owns the contract and proposes changes on the remote side. The provider is willing to fulfill the new contract. Though it’s not consumer dictatorship — close collaboration is required between parties.
The shop UI wants to perform an HTTP POST request to the /cart/total
endpoint specifying two cases: cart with existing SKUs and cart with some missing SKUs. It generates request/response examples for those cases as a Pact document (called just as pact) and uploads it to the Pact broker — a middleman between the consumer (UI) and the backend service.
Below you find the interaction for the cart with existing SKUs:
The code that creates the full Pact document is implemented in the PricingClientPactTest. To generate it, execute ./gradlew test
. The pact is located in thebuild/pacts
directory. Then, start the Pact broker withdocker-compose up
. Upload the pact with gradle pactPublish
. Inspect the pact by going to http://localhost:9292/. Notice that the contract is not verified yet on the provider side.
What we did so far?
Dear provider, your turn!
The provider (pricing team) starts working on the contract implementation. It begins with the provider Pact integration test. The test downloads the contract from the broker and makes HTTP calls to the endpoints taken from the pact.
The Pact provider test drives the implementation of the supplier. The remote contract provides direct inputs. Pact machinery will verify the direct outputs of the entry layer — JSON responses. Our job is to provide the entry point implementation — a controller/resource in our case.
We’ll use the mockist approach making some design assumptions:
- pricing logic is quite complex and should be placed outside of the entry point
- the entry point (controller) should be as thin as possible and mediate only between the Spring framework and the to-be-made domain
We don’t know how the controller collaborators will look like. We’ll design them with mocks and establish the internal contract:
- what the collaborator interface should look like (function signatures, input/output data types)
- collaborator behavior changes after executing its API functions (in case the dependency is meant to be stateful)
Each of the examples from the external contract requires a test case on the provider side. Every test case defines an interaction example between the controller and its collaborator — PriceCartUseCase
. The external contract triggered the creation of the internal contract between the controller and the use case. We have now our first link in the contract chain — shop UI-pricing contract and PricingController
-PriceCartUseCase
internal contract.
The final provider Pact test looks as follows:
When this test passes, Pact connects to the broker and marks the contract as verified:
Bear in mind that this example is contrived — the controller just delegates to the domain and we’re using the same model both for the JSON and the domain representation.
What we did so far?
Time to do some pricing math
Test doubles are for the internal contracts as Pact documents are for the external contracts — they define what we expect from our not-yet-implemented collaborator. In our case, wedefined two behaviors making up the internal contract between PricingController
and PriceCartUseCase
:
- Pricing of a cart with existing SKUs:
every { useCase.execute(cart) } returns
PricedCart(
items = ...,
total = ...,
)
2. Error case — cart cannot be priced:
every { useCase.execute(any()) } returns null
Now, when implementing the use case (provider), we use the test double invocations (mock examples) to define test cases for the provider behavior. The first case (cart with existing SKUs) corresponds to the some cart items available
test case, the second case (cart cannot be priced) to the none of cart items available
test case as in the PriceCartUseCase
unit test code below:
The use case (provider in the controller — use case internal contract) needs either some supporting behaviors from other collaborators, or it hits the system boundary. In the former case, we’ll repeat the process of defining internal contracts with mocks. In the latter case, we’ll define the external contract with Pact and pass it to an external party through the broker. Sooner or later the contract will be fulfilled by the external party and the contract chain will be completed.
In our example, two more internal contracts emerged: use case — pricing engine and use case — inventory. We can imagine that the pricing engine is completely contained in the boundaries of the pricing service, so there’ll be more internal contracts added to the chain. Inventory makes use of the inventory service, so there’ll be a chain termination with a new external contract. We’re going to focus on this case since it’s more interesting to illustrate the concept of contract chaining.
What we did so far?
Are products on stock?
Can you see the pattern with the internal contracts? Once again, we’ll use mocks as the source for the InventoryRepository
tests. There’ll be two cases: cart with some items available, and cart with all items out of stock (or maybe not in the inventory):
// some items available
every { inventory.check(cartToPrice) } returns
Cart(listOf(CartItem(quantity = 1), CartItem(quantity = 0)))// all items out of stock
every { inventory.check(cartToPrice) } returns
Cart(listOf(CartItem(quantity = 0), CartItem(quantity = 0)))
With the repository component, we’re reaching the system boundary — we need behavior we cannot build in the pricing service. We cannot hook another internal contract with mock — we’ll implement the InventoryRepository
test as the Pact consumer integration test:
After executing the test, Pact document is generated and we can upload it to the broker with ./gradlew pactPublish
. Checking the broker (http://localhost:9292/), we see that our pact was registered and it is pending the verification on the provider (inventory) side.
What we did so far?
Closing the circle
We’re back where we started our journey — having another contract handed over to the broker and waiting to be fulfilled by the provider. To reach that point, we started with the pact from the shop UI and we implemented it quickly on the pricing backend. Then we continued digging down until we stopped on the pricing system boundary and we finished by writing our “wishlist” for the inventory system.
By following the contract chain workflow, we achieved:
- Quick iterations and integrations (minutes!). Our tests are boring — there is no much going on, setups are easy to write and we can mentally connect the dots between the provided inputs and the expected outputs.
- Interfaces with matching expectations and responsibilities. Both consumer and supplier sides are checked and there is a notary that scrutinizes the (mis-)match
- Substantial reduction of end-to-end tests (or we can even ditch them completely). We don’t have to spin up everything to make sure that the system elements work well together — chain rule ensures that.
Final considerations
Shallow or deep provider Pact tests?
If Pact 5 minute guide, the recommended approach is to test all the layers from the entry point up to, but not including the external dependencies (APIs, storage). That should give you “some of the benefits of an integration test without the high costs of maintenance”. In reality, it yields deep, integrated, hard-to-maintain tests where it’s difficult to track the relationship between the inputs from the external contract and the indirect inputs from the external services or databases.
I personally recommend the shallow integrated test approach involving only the outermost layer and stubbing out entry point dependencies. The resulting test is dead-simple. We can get to green very quickly (minutes, not hours or days) delivering a small deployable increment. In our example, we could provide a fake implementation of the use case returning some hardcoded values. Our shop UI could start integrating immediately even if the prices are created out of thin air. And, last but not least, our integration provider test drives the implementation of the rest of the system.
Contract tests vs acceptance tests
You may noticed that in our example the shop UI — pricing contract test was passing but the price cart implementation was not completed at that time. We built only the entry point (controller) and faked the rest. You may complain that the contract was marked as verified but the pricing service was not doing what expected from it. Yes, you’re right.
Fulfilling the contract doesn’t mean the overall behaviour is acceptable. A verified contract certifies that the exchange format and relationships between stateful operations on the system boundaries is as it was agreed between the consumer and the producer. The consumer of the contract has now at least a limited working implementation and can start using it for the initial exploratory testing. However, the behaviour may not be completed at the contract verification time and could be switched off by a toggle. You may need some additional acceptance tests to ensure that the functionality is working as expected from the customer point of view.
To recap — contract tests may be necessary, but they not sufficient to give you confidence about the new behaviour. The combination of contract, acceptance and exploratory testing ensures that the feature is ready for prime time
Mocks are not for external contracts
It is important not to use mocks to simulate the external dependency — we are defining a contract that has no chance to be verified and implemented on the other side of the boundary. We are just crying in the wild — nobody will take notice. You may refer to my article Effective Test Doubles for a more in-depth explanation about why you shouldn’t mock what is not yours.
Icons by Eucalyp, Kiranshastry, Freepik, Good Ware and Pixel perfect from Flaticon.