Contract Testing in Go

A real implementation of Contract Testing in Go—simple, yet effective.

  ·   14 min read

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.

Sequence diagram for a user who ask for their info for the system to greet them
Sequence diagram for the scenario where a user ask a system to greet them

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.

TDD using a contract approach to get the implementation done and a contract file
TDD using a contract approach to get the implementation done and a contract file

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:

make consumer

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:

.
├── Makefile
├── cmd
│   └── main.go
├── go.mod
├── go.sum
├── internal
│   ├── dependency
│   │   └── greeter.go
│   ├── handler
│   │   └── h_greet_user.go
│   ├── middleware
│   │   ├── mw.go
│   │   └── routes.go
│   ├── model
│   │   └── user.go
│   ├── services
│   │   └── users
│   │       ├── types.go
│   │       ├── users_client.go
│   │       └── users_client_test.go
│   └── usecase
│       ├── mock_user_getter_test.go
│       ├── uc_greet_user.go
│       └── uc_greet_user_test.go
├── make
│   └── config.mk
└── pacts
    └── GoConsumerService-GoProviderService.json

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:

Proposed architecture hierarchy for the Consumer system
Proposed architecture hierarchy for the Consumer system

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.

Publishing the Consumer’s Contract File to the Broker
Publishing the Consumer’s Contract File to the Broker

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.

make broker
make broker-stop
make broker-down

Contract Broker running in the Docker container
Contract Broker running in the Docker container

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.

Contract published to the Contract Broker
Contract published to the Contract Broker

Let’s review the current state of the contract using the Matrix View.

Pact state between the Consumer and the Provider
Pact state between the Consumer and the Provider

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.

Contract in a human-readable representation
Contract in a 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:

make provider

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.

The pact at http://localhost:8080/pacts/provider/GoProviderService/consumer/GoConsumerService/pact-version/b295e661022cd6466cb1d215ca6b77a0d8dba774 is being verified because the pact content belongs to the consumer version matching the following criterion:
    * latest version of GoConsumerService that has a pact with GoProviderService (f8d19f0243b86d1686720fe29edae79c492b4e84)

Verifying a pact between GoConsumerService and GoProviderService

  A request to get a user (232ms loading, 142ms verification)
     Given Service is unavailable
    returns a response which
      has status code 500 (FAILED)
      has a matching body (OK)

  A request to get a user (232ms loading, 177ms verification)
     Given User is not authenticated
    returns a response which
      has status code 401 (FAILED)
      includes headers
        "Content-Type" with value "application/json" (FAILED)
        "X-Api-Correlation-Id" with value "100" (FAILED)
      has a matching body (OK)

  A request to login with user 'drwho' (232ms loading, 146ms verification)
     Given User drwho does not exist
    returns a response which
      has status code 404 (OK)
      includes headers
        "X-Api-Correlation-Id" with value "100" (FAILED)
        "Content-Type" with value "application/json" (FAILED)
      has a matching body (OK)

  A request to login with user 'drwho' (232ms loading, 136ms verification)
     Given User drwho exists
    returns a response which
      has status code 200 (FAILED)
      includes headers
        "X-Api-Correlation-Id" with value "100" (FAILED)
        "Content-Type" with value "application/json" (FAILED)
      has a matching body (FAILED)


Failures:

1) Verifying a pact between GoConsumerService and GoProviderService Given Service is unavailable - A request to get a user
    1.1) has status code 500
           expected 500 but was 404
2) Verifying a pact between GoConsumerService and GoProviderService Given User is not authenticated - A request to get a user
    2.1) has status code 401
           expected 401 but was 404
    2.2) includes header 'Content-Type' with value 'application/json'
           Expected header 'Content-Type' to have value 'application/json' but was 'text/plain; charset=utf-8'
           Expected header 'X-Api-Correlation-Id' to have value '"100"' but was ''
3) Verifying a pact between GoConsumerService and GoProviderService Given User drwho does not exist - A request to login with user 'drwho'
    3.1) includes header 'X-Api-Correlation-Id' with value '"100"'
           Expected header 'X-Api-Correlation-Id' to have value '"100"' but was ''
           Expected header 'Content-Type' to have value 'application/json' but was 'text/plain; charset=utf-8'
4) Verifying a pact between GoConsumerService and GoProviderService Given User drwho exists - A request to login with user 'drwho'
    4.1) has a matching body
           expected a body of 'application/json' but the actual content type was 'text/plain;charset=utf-8'
    4.2) has status code 200
           expected 200 but was 404
    4.3) includes header 'X-Api-Correlation-Id' with value '"100"'
           Expected header 'X-Api-Correlation-Id' to have value '"100"' but was ''
           Expected header 'Content-Type' to have value 'application/json' but was 'text/plain; charset=utf-8'

There were 4 pact failures

Provider system fetching the Consumer’s contract from the Contract Broker
Provider system fetching the Consumer’s contract from the Contract Broker

Something interesting can be seen in the Matrix View: the Provider has failed to verify fulfilling the contract.

The Provider has failed to verify it can fulfill the contract
The Provider has failed to verify it can fulfill 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:

make unit
make provider

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.

The pact at http://localhost:8080/pacts/provider/GoProviderService/consumer/GoConsumerService/pact-version/78a9da67e68ef9293302a643251708df38cc82d4 is being verified because the pact content belongs to the consumer version matching the following criterion:
    * latest version of GoConsumerService from the main branch 'main' (a2ccedd05762f0334bcc044db294b4bc16696d37)

Verifying a pact between GoConsumerService and GoProviderService

  A request to get a user (276ms loading, 431ms verification)
     Given User is not authenticated
    returns a response which
      has status code 401 (OK)
      includes headers
        "Content-Type" with value "application/json" (OK)
        "X-Api-Correlation-Id" with value "100" (OK)
      has a matching body (OK)

  A request to login with user 'drwho' (276ms loading, 480ms verification)
     Given User drwho does not exist
    returns a response which
      has status code 404 (OK)
      includes headers
        "Content-Type" with value "application/json" (OK)
        "X-Api-Correlation-Id" with value "100" (OK)
      has a matching body (OK)

  A request to login with user 'drwho' (276ms loading, 400ms verification)
     Given User drwho exists
    returns a response which
      has status code 200 (OK)
      includes headers
        "Content-Type" with value "application/json" (OK)
        "X-Api-Correlation-Id" with value "100" (OK)
      has a matching body (OK)

Contract verified by the Provider
Contract verified by the Provider

Final Steps

The Consumer can now verify whether the contract is ready for production by running:

make check-consumer-deploy

However, as shown in the following image, deployment is not yet possible because the Provider has not published its implementation to the Contract Broker.

The Consumer is not able to deploy to production yet
The Consumer is not able to deploy to production yet

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:

make install_cli
make check-provider-deploy
make record-provider-deploy

The Provider has been able to deploy its part to the Contract Broker
The Provider has been able to deploy its part to the Contract Broker

Additionally, we can confirm in the Contract Broker’s Matrix View that the Provider has been successfully deployed to production.

Provider Version successfully deployed to production
Provider Version 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:

make check-consumer-deploy
make record-consumer-deploy

The Consumer has finally been able to deploy its part to production
The Consumer has finally been able to deploy its part to production

Finally, we can verify in the Contract Broker’s Matrix View that the Consumer has been successfully deployed to production.

Consumer Version successfully deployed to production
Consumer Version 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:

make run-provider

In a separate terminal for the Consumer, execute:

make run-consumer

Now, in a third terminal, use cURL to test the three use cases.

Unauthorized user

❯ curl --verbose --request GET \
  --url http://localhost:8081/user/10
Note: Unnecessary use of -X or --request, GET is already inferred.
* Host localhost:8081 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8081...
* Connected to localhost (::1) port 8081
> GET /user/10 HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 401 Unauthorized
< Content-Type: application/json
< X-Api-Correlation-Id: 464ca78f-0aa1-42d6-bdea-747b0d3cdebe
< Date: Thu, 30 Jan 2025 05:07:56 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact

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.

❯ curl --verbose --request GET \
  --url http://localhost:8081/user/101 \
  --header 'Authorization: Bearer 2025-01-30T00:09'
Note: Unnecessary use of -X or --request, GET is already inferred.
* Host localhost:8081 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8081...
* Connected to localhost (::1) port 8081
> GET /user/101 HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/8.7.1
> Accept: */*
> Authorization: Bearer 2025-01-30T00:09
>
* Request completely sent off
< HTTP/1.1 500 Internal Server Error
< Content-Type: application/json
< X-Api-Correlation-Id: 564e6103-1d1d-46e7-9bc5-751fe5cd4183
< Date: Thu, 30 Jan 2025 05:09:56 GMT
< Content-Length: 0
<
* Connection #0 to host localhost left intact

User does exist

❯ curl --verbose --request GET \
  --url http://localhost:8081/user/10 \
  --header 'Authorization: Bearer 2025-01-30T00:11'
Note: Unnecessary use of -X or --request, GET is already inferred.
* Host localhost:8081 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8081...
* Connected to localhost (::1) port 8081
> GET /user/10 HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/8.7.1
> Accept: */*
> Authorization: Bearer 2025-01-30T00:11
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: application/json
< X-Api-Correlation-Id: eaa60464-9451-4dd7-acbe-7df37abc9cb6
< Date: Thu, 30 Jan 2025 05:11:28 GMT
< Content-Length: 15
<
* Connection #0 to host localhost left intact
Hello John Doe!

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

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