Pact
Pact is a tool for consumer-driven contract testing. It provides a way of testing the integration between microservices without needing to perform end-to-end testing.
"Consumer-driven" means that the consumer of a service sets expectations about behaviour it needs from the provider. For example, a providing API could be set up to run Pact. Any consumer of the providing API would be able to create contracts using the Pact tooling. The providing API's deployment pipeline could then check that the consumers' contracts were still met by any new builds.
For those unfamiliar with the concept of contract testing, an excellent article on its advantages can be found on Martin Fowler's website .
As powerful as a tool like Pact can be during the development lifecycle, it's important to note that Pact is not intended as a replacement for communication between consumers and providers. In fact, the usage of a tool like Pact requires fairly disciplined lines of communication, and so will naturally be unsuitable for some projects.
For more information on when Pact should and shouldn't be used, see the Pact documentation on the subject.
How does Pact work?
In its simplest form, you have a single producer, a single consumer and a transport mechanism (as of the time of writing, HTTP and Messages are supported).
Consumers & Providers
Pact holds the concept of two different types of entities — consumers and providers:
- A consumer is a client that wants to receive some data. A consumer could be a web front-end, or a message-receiving endpoint.
- A provider is a service or server that provides this data. A provider could be an API on a server that provides the data the client needs, or the service that sends the messages to the receiving endpoint.
HTTP
When we talk about Pact being used in the context of HTTP communications i.e. a GET/PUT/POST/PATCH request is sent from one service to another:
- The consumer is the service sending the GET/PUT/POST/PATCH request. This service will drive the contract. It will define what it expects the response to contain, placing requirements on the schema.
- The provider is the service receiving the GET/PUT/POST/PATCH request and sending the response. This service has to comply to the contract placed by the consumer and upholding this contract should be a top priority. A service provider may have one or more HTTP endpoints, and should be thought of as a "deployable unit" - endpoints that get deployed together should be considered part of the same provider.
Messages
If we are considering Pact in a message-driven environment i.e. a service puts a message on a queue which is ingested and processed by another service, then:
- The consumer is the service that ingests the message, i.e. the reader of the queue. This service will drive the contract. It will define what it expects the response to contain, placing requirements on the schema.
- The provider is the service that places messages on the queue, i.e. the writer of the queue. This service has to comply to the contract placed by the consumer and upholding this contract should be a priority.
Pact
A contract between a consumer and provider is called a pact. Each pact is a collection of interactions. Each interaction describes:
- An expected request, describing what the consumer is expected to send to the provider. This is always present for synchronous interactions like HTTP requests, but not required for asynchronous interactions like message queues.
- A minimal expected response, describing the parts of the response the consumer wants the provider to return.
The first step in writing a Pact test is to describe this interaction.
Consumer testing
- Consumer Pact tests operate on each interaction described earlier to say "assuming the provider returns the expected response for this request, does the consumer code correctly generate the request and handle the expected response?".
- Each interaction is tested using the Pact framework, driven by the unit test framework inside the consumer codebase
Pact interaction
As Pact is consumer-driven, the consumer doesn't need to validate against the Pact broker. It simply places the requirement on the provider to produce the expected output given the request from the consumer. The only validation the consumer needs to do is to internally make sure that given the response from the provider, it can successfully execute its business logic. With this in mind, the Pact test flow boils down to:
- Using the Pact DSL, the expected request and response are registered with the mock service.
- The consumer test code fires a real request to a mock provider (created by the Pact framework).
- The mock provider compares the actual request with the expected request, and emits the expected response if the comparison is successful.
- The consumer test code confirms that the response was correctly understood.
Pact tests are only successful if each step completes without error.
Usually, the interaction definition and consumer test are written together, such as this example from a Pact walkthrough guide.
Although there is conceptually a lot going on in a pact interaction test, the actual test code is very straightforward. This is a major selling point of Pact.
In Pact, each interaction is considered to be independent. This means that each test only tests one interaction. If you need to describe interactions that depend on each other, you can use provider states to do it. Provider states allow you describe the preconditions on the provider required to generate the expected response - for example, the existence of specific user data. This is explained further in the provider verification section below.
Instead of writing a test that says "create user 123, then log in", you would write two separate interactions - one that says "create user 123", and one with provider state "user 123 exists" that says "log in as user 123".
Once all of the interactions have been tested on the consumer side, the Pact framework generates a pact file, which describes each interaction, this pact file can be used to verify the provider.
HTTP Consumer Test Flow
Message Consumer Test Flow
Additional notes
- Assuming your CI/CD pipeline is set up to block the deployment of a provider should the new version break a consumer contract (highly advisable), then publishing a consumer pact without a corresponding provider pact will block the deployment of the provider. If possible, write the provider pact before publishing the consumer pact.
- It is of paramount importance to place requirements only on the essential data in a response. Placing constraints on fields that are not needed by the service will greatly hinder the flexibility of the provider(s).
Provider verification
In contrast to the consumer tests, provider verification is entirely driven by the Pact framework.
In provider verification, each request is sent to the provider, and the actual response it generates is compared with the minimal expected response described in the consumer test.
Provider verification passes if each request generates a response that contains at least the data described in the minimal expected response.
In many cases, your provider will need to be in a particular state (such as "user 123 is logged in", or "customer 456 has an invoice #678"). The Pact framework supports this by letting you set up the data described by the provider state before the interaction is replayed.
If we pair the test and verification process for each interaction, the contract between the consumer and provider is fully tested without having to spin up the services together.
HTTP
Message
Additional Notes
- The Pact framework will complain if there exist provider tests with no valid consumer tests (as it is consumer-driven).
- When setting provider states, make sure to limit the mocking of code to a minimum. If code is mocked too much, it will no longer represent the actual workings of the service and thus the pact will just become a piece of test-theatre (testing with little-to-no value). A good baseline is only mocking calls to external services (i.e. repositories or clients).
Pactfile versioning
Out of the box, the Pact Broker allows uploading of pact files with semver style versions (eg 2.0.1). For our usage, we wanted to be able to upload Pact files from various branches in addition the released versions so that our branch builds of consumers can verify their pactfiles with the producers.
Pact Broker allows us to implement our own versioning scheme by providing a custom version parser.
- The consumer application version number should include an identifier to the git commit hash of the associated code.
- The consumer application version number should change, when the pact contract changes (consumer expectations have been updated).
- Avoid having random data in the contracts, this will cause a unique pact contract per commit and lose the advantage of Pact duplication contract detection.
- The versions must be known at deploy times.
- Feature branches will have different versions to the main branch.
- We can identify and checkout the production version of the consumer if required.
- Master branches of the consumer should tag builds with {master} so that provider/consumer can run can-i-deploy as part of CI.
- Prod versions of the consumer should tag builds with {prod} so that provider/consumer can run can-i-deploy as part of CI.
Multiple Language Support
The base implementation of Pact is written in Ruby, however there are several other implementations written by and maintained by the Pact Foundation and its team of open source volunteers.
- Ruby - Pact-Ruby
- JavaScript - Pact-Js
- Go - pact-go
- C++ - Pact-Cplusplus
- .Net - Pact-net
- Python - Pact-Python or Pactman
- PHP - Pact-PHP
- JVM - Pact-JVM
Ways of working
- Pact is not a replacement for communication between teams and is not meant to replace documentation. It is there merely to make sure no sudden schema changes occur that would break the consumer.
- Due to Pact being consumer-driven, the consumer will need to drive conversations with providers on implementing Pact in the provider codebases/teams.
- Pact does nothing to guarantee data quality outside of schema validation. At best it's a foundational block for starting conversations around data quality.