In my previous article, The Integration Test Trap: A Recipe for Inefficiency[1], I discussed why integration testing often falls short and how contract testing provides a far more effective approach. Now, I want to demonstrate how two systems can establish a shared contract that drives development—using a trivial yet instructive example.
The code for this article is publicly available in my repository: https://github.com/mesirendon/contract-testing. Throughout this article, I’ll reference specific repository tags to build a logical and coherent narrative of how the implementation was constructed.
The Scenario
The setup is straightforward: an actor requests the Consumer system to greet a user based on their ID. This request includes an authentication token derived from the current date and time. If the token is invalid, the actor receives an unauthorized exception; otherwise, they see a greeting—provided the user exists.
However, the Consumer system doesn’t store any user records; it simply generates greetings. To retrieve user details, it makes a request to the Provider system for the user the actor is asking for.
The Consumer
Test and build the User Service (Step 1)
Make sure you checkout the step_1 tag.
As outlined in the scenario, the Consumer system must communicate with the Provider system to retrieve user data through an internal service dedicated to this purpose. To keep this example straightforward, the user service handles only three cases (please, ignore the ‘service unavailable’ scenario 🤦♂️):
- A request to get a user given user is not authenticated.
- A request to login with user ‘drwho’ given user drwho exists.
- A request to login with user ‘drwho’ given user drwho does not exist.
By writing the contract test in an isolated fashion (i.e., unit testing), the service is not only defining its expectations and stubbed responses—ensuring it behaves correctly when called internally—but also establishing the contract that the yet-to-be-implemented Provider system will later adopt to fulfill its role.
As shown in consumer/internal/services/users/users_client_test.go
, contract testing with Pact revolves around defining interactions that establish clear expectations for both the inputs and the outputs. Each test case specifies a scenario where, given a particular request—including its description, expected HTTP method, path, and possibly specific headers—the counterpart system is expected to respond with a predefined status, headers, and body.
1//go:build contracts
2
3package users
4
5import (
6 "fmt"
7 "net/http"
8 "os"
9 "strconv"
10 "testing"
11
12 "github.com/pact-foundation/pact-go/v2/consumer"
13 "github.com/pact-foundation/pact-go/v2/log"
14 "github.com/pact-foundation/pact-go/v2/matchers"
15 "github.com/stretchr/testify/assert"
16)
17
18func TestUserClientPact_GetUser(t *testing.T) {
19 _ = log.SetLogLevel("INFO")
20
21 mockProvider, err := consumer.NewV2Pact(consumer.MockHTTPProviderConfig{
22 Consumer: os.Getenv("CONSUMER_NAME"),
23 Provider: os.Getenv("PROVIDER_NAME"),
24 LogDir: os.Getenv("LOG_DIR"),
25 PactDir: os.Getenv("PACT_DIR"),
26 })
27
28 t.Run("the user exists", func(t *testing.T) {
29 id := 10
30
31 err = mockProvider.
32 AddInteraction().
33 Given("User drwho exists").
34 UponReceiving("A request to login with user 'drwho'").
35 WithRequestPathMatcher("GET", matchers.Regex("/user/"+strconv.Itoa(id), "/user/[0-9]+"), func(vrb *consumer.V2RequestBuilder) {
36 vrb.Header("Authorization", matchers.Like("Bearer 2016-01-01T05:43"))
37 }).
38 WillRespondWith(http.StatusOK, func(vrb *consumer.V2ResponseBuilder) {
39 vrb.BodyMatch(user{}).
40 Header("Content-Type", matchers.Term("application/json", `application\/json`)).
41 Header("X-Api-Correlation-Id", matchers.Like("100"))
42 }).
43 ExecuteTest(t, func(msc consumer.MockServerConfig) error {
44 u := fmt.Sprintf("http://%s:%d", msc.Host, msc.Port)
45 client, _ := NewUsersClient(u)
46 client.SetToken("2016-01-01T05:43")
47
48 user, err := client.GetUser(id)
49 if user.ID != id {
50 return fmt.Errorf("wanted user with ID %d but got %d", id, user.ID)
51 }
52
53 return err
54 })
55
56 assert.NoError(t, err)
57 })
58}
The consumer
project includes a Makefile
that simplifies running the tests. To execute these tests, you only need to run the following command:
This will produce the test results, provided the implementation is correct. Additionally, it will generate a contract file that you can review at consumer/pacts/GoConsumerService-GoProviderService.json
. A JSON file structuring the expectations the Provider must fulfill.
Test and Build the Rest of the Consumer System (Step 2)
Make sure to check out the step_2 tag.
This step focuses on completing the Consumer system so it can handle user requests—even before the Provider system is ready. Here, you’ll find the various dependencies and layers, along with their corresponding tests. I won’t delve too deeply into this step, as it primarily ensures the system is fully built and functional.
The final project structure is as follows:
The diagram below illustrates the hierarchy between these layers, showing how the components interact within the Consumer system, where <aw>
stands for Allowed to Use, and <use>
stands for Uses for dependency injection:
The Contract Broker (Step 3)
Make sure you checkout the step_3 tag.
With everything in place on the Consumer side, it’s time to deliver the contract file to the counterpart system. But how do we do that? The answer: the Contract Broker.
Simply put, the Contract Broker is a centralized repository for contracts. It serves as the location where the Consumer publishes its contract—defining all expectations—hoping that a future Provider will fulfill the counterpart. At the same time, it provides the Provider with a reference point to fetch the contract and ensure compliance with the agreed-upon pact.
In this setup, the Contract Broker runs in the background as a Docker container, enabling the Consumer to publish the contract seamlessly.
Within the broker
project, you’ll find a Makefile
that simplifies managing the container, allowing you to start, pause, and terminate it with ease.
Publishing the contract to the Contract Broker
With the Contract Broker in place, the Consumer system gains new capabilities through the Makefile
:
make install_cli
– Installs the utility that communicates with the Contract Broker.make publish
– Publishes the Consumer’s contract to the Contract Broker.make check-consumer-deploy
– Verifies whether the published contract is safe to deploy to production.make record-consumer-deploy
– Confirms and records the deployment of the contract to production.
At this point, we can proceed with using the install_cli
and publish
options to push our contract to the Contract Broker.
Let’s review the current state of the contract using the Matrix View.
The Matrix View provides a clear visualization of the relationship between the Consumer and the Provider. As shown in the output, there are no Provider-side statuses yet—indicating that no system has fulfilled the contract on the other end.
On the Consumer-side, the contract has not been deployed to production, as it still depends on a Provider to implement and verify the expected interactions. Before moving forward, we need the Provider to adopt and fulfill the contract.
Additionally, we can inspect the contract directly in the Contract Broker to see how it has been structured. The following image displays the contract in a Swagger-like format, providing a clear, human-readable representation.
The Provider (Step 4)
Make sure you checkout the step_4 tag.
Now it’s time to build the Provider system. This initial approach focuses on setting up the scaffolding needed to develop the API. The most interesting aspect here is the contract testing counterpart.
In the test file (provider/cmd/provider_service_test.go
), the testing utility communicates with the Contract Broker to retrieve contracts for the Provider system. When running the Provider’s contract tests, it fetches the contract and executes each test case. Since this is the first run and the Provider hasn’t implemented anything yet, all assertions will naturally fail.
As with the Consumer, the Provider system includes a Makefile
to simplify command execution. Running:
will fetch the Consumer’s contract and attempt to validate it—resulting in expected failures until the Provider implements the necessary functionality. Of course, the next logical step is to implement the necessary code to make the tests pass.
Something interesting can be seen in the Matrix View: the Provider has failed to verify fulfilling the contract.
Fulfilling the Contract (Step 5)
Make sure you checkout the step_5 tag.
An important caveat: since the Unavailable service
scenario was removed from the contract, it must be re-tested and re-published. This ensures that the code in the Step 5
tag can be properly fulfilled by the Provider.
Having said that, in this step, the Provider implements all the necessary code to satisfy the contract. As the project’s architecture evolves, additional unit tests are introduced to ensure that different layers and components function as expected. Running these tests is highly recommended to verify that every part operates correctly. You can execute the following series of commands:
I won’t show the unit test results, but the key takeaway from this step is the outcome of the contract testing. Beyond ensuring that the implementation works as expected, it also validates the contract, providing confirmation to the Contract Broker that the Provider has successfully fulfilled its obligations—as reflected in the Matrix View.
Final Steps
The Consumer can now verify whether the contract is ready for production by running:
However, as shown in the following image, deployment is not yet possible because the Provider has not published its implementation to the Contract Broker.
So, we need to return to the Provider and verify whether it can be deployed. If it passes the checks, we can proceed with the deployment by running the following commands:
Additionally, we can confirm in the Contract Broker’s Matrix View that the Provider has been successfully deployed to production.
As the Provider has fulfilled its part of the pact, we can now return to the Consumer and verify if it’s ready for production. This can be done by running:
Finally, we can verify in the Contract Broker’s Matrix View that the Consumer has been successfully deployed to production.
Going Live to Production with Both Systems
As both the Consumer and Provider have fulfilled the pact, it’s now time to run both systems simultaneously and interact as a user to retrieve a response.
Open a terminal for the Provider and run:
In a separate terminal for the Consumer, execute:
Now, in a third terminal, use cURL
to test the three use cases.
Unauthorized user
User does not exist
Deliberately, the design ensures that the Provider responds with a 404
status code, while the Consumer returns a 500
. This distinction is intentional and serves a didactic purpose.
User does exist
Conclusion
In this technical article, we explored how to implement a Contract-Driven Development strategy that aligns with Test-Driven Development and Unit Testing principles. Through a simple yet effective hands-on example, we demonstrated the importance of the Contract Broker as a programmatic bridge that, using a unified language, enables all parties to implement only the minimal necessary code to fulfill their agreement efficiently.
In an upcoming article, I plan to showcase another example that connects a mobile application to a backend service.
Bibliography
- M. Rendon,
The integration test trap: A recipe for inefficiency,
mesirendon.com, 2025. [Online]. Available: https://mesirendon.com/articles/the-integration-test-trap-a-recipe-for-inefficiency/