Don’t break the contract chain

Photo by Karine Avetisyan on Unsplash

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:

  • partiesconsumer, 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.

External and internal contract chain

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:

Pact document for the cart with existing SKUs interaction

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.

Unverified contract between shop UI and pricing backend
Shop UI defines an external contract (Pact) and uploads it to the Pact broker

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:

PricingController provider Pact test (source code)

When this test passes, Pact connects to the broker and marks the contract as verified:

Verified contract between the shop UI and the pricing backend

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.

Pricing verifies the external contract with the shop UI. A new internal contract with the use case is defined.

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:

  1. 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:

PriceCartUseCase test (source code)

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.

The internal contract pricing controller-use case is completed. New internal contracts with use case dependencies are defined.

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:

InventoryRepositoryPactTest, partial availability case (source code)

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.

Unverified contract between pricing and inventory.
The internal contract use case-inventory repository is completed. There is a new external contract between pricing and inventory uploaded to the Pact broker.

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.
Full contract chain

Final considerations

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.

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

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.

Enduring software engineer and long-distance cyclist