Golang Tutorial: Contract Testing with PACT
- 13 minutes read - 2648 wordsThe Story about eliminating integration issues from our environments.

My favorite part of software development is writing tests, whether they are unit tests or integration tests. I enjoy the process immensely. There’s a certain satisfaction in creating a test case that uncovers a function’s failure. It brings me joy to discover a bug during development, knowing that I’ve fixed it before anyone encounters it in a test environment or, worse, in production. Sometimes, I stay up late just to write more tests; it’s like a hobby. I even spent around 30 minutes on my wedding day writing unit tests for my personal project, but don’t tell my wife!
The only thing that used to bother me was dealing with integration issues between multiple Microservices. How could I ensure that two Microservices, each with specific versions, wouldn’t face integration problems? How could I be certain that a new version of a Microservice didn’t break its API interface, rendering it unusable for others? This information was crucial to have before launching extensive scenarios in our end-to-end testing pipeline. Otherwise, we’d end up waiting for an hour just to receive feedback that we’d broken the JSON schema.
Then, one day in the office, I heard a rumor that we were planning to use Contract Testing. I quickly checked the first article I found, and I was amazed. It was a breakthrough.
Contract Testing
There are many excellent articles about Contract testing,
but the one I like the most is from Pactflow. Contract
testing ensures that two parties can communicate effectively by testing them in isolation to verify if both sides support
the messages they exchange. One party, known as the Consumer
, captures the communication with the other party, referred
to as the Provider
, and creates the Contract
. This Contract
serves as a specification for the expected requests from the
Consumer
and the responses from the Provider
. Application code automatically generates Contracts
, typically during the
unit testing phase. Automatic creation ensures that each Contract
accurately reflects the latest state of affairs.
After the Consumer
publishes the Contract, the Provider
can use it. In its code, likely within unit tests, the
Provider
conducts Contract
verification and publishes the results. In both phases of Contract testing, we work solely
on one side, without any actual interaction with the other party. Essentially, we are ensuring that both parties can
communicate with each other within their separate pipelines. As a result, the entire process is asynchronous and
independent. If either of these two phases fails, both the Consumer
and Provider
must collaborate to resolve
integration issues. In some cases, the Consumer
may need to adapt its integrational code, while in others, the
Provider
may need to adjust its API.
It’s essential to note that Contract testing is NOT Schema testing. Schema testing is confined to one party without any connection to another. In contrast, Contract testing verifies interactions on both sides and ensures compatibility between desired versions of both parties. Additionally, Contract testing is NOT End-to-End testing. End-to-End testing involves testing a group of services running together, typically evaluating the entire system, from the UI down to storage. In contrast, Contract testing conducts tests against each service independently. These tests are isolated and do not require more than one service to be running simultaneously.
Now, let’s delve deeper into what Contract testing entails.
PACT
PACT is a tool designed for Contract testing, and we utilize it to facilitate the
validation of communication between Consumers
and Providers
over the HTTP
protocol. It also extends its support to
the testing of message queues such as SQS
, RabbitMQ
, Kafka
, and more.
On the consumer side, we create Contracts
using the PACT DSL
tailored for a specific programming language. These
Contracts
encompass interactions that define expected requests and their corresponding minimal responses.
During the test execution, the Consumer
sends requests to a Mock Provider
, which employs the defined interactions
to compare actual and expected HTTP
requests. When the requests align, the Mock Provider
returns the expected minimal
response. This allows the Consumer
to verify whether it meets the anticipated criteria.
On the Provider
side, we employ the previously created Contracts
to ascertain if the server can fulfill the expected
requirements. The outcomes of this verification process can be published to maintain a record of which versions of
Consumers
and Providers
are compatible.
During Provider-side testing, a Mock Consumer
is responsible for sending the expected request to the Provider
. The
Provider
then assesses whether the incoming HTTP
request aligns with the expectations and subsequently generates a
response. In the final step, the Mock Consumer
compares the actual response with the anticipated minimal response and
delivers the result of the verification process.
All Contracts
and verification results can be stored on the PACT Broker.
The PACT Broker
is a tool that developers usually need to host and maintain themselves on most projects. Alternatively,
a public option like PactFlow is available for use.
Simple server and client in Go
To write the first Contract
, we need to provide some code for a simple server and client. In this case, the server should
have one endpoint, /users/:userId
, for returning Users
by their ID
. The code generates the result to avoid the need
for more complex logic, such as communication with a database.
File /pkg/server/server.go
type User struct {
ID string `json:"id"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
}
func GetUserByID(ctx *gin.Context) {
id := ctx.Param("userId")
ctx.JSON(http.StatusOK, User{
ID: id,
FirstName: fmt.Sprintf("first%s", id),
LastName: fmt.Sprintf("last%s", id),
})
}
File /cmd/main.go
func main() {
router := gin.Default()
router.GET("/users/:userId", server.GetUserByID)
router.Run(":8080")
}
The complete code for the server is split into two files: server.go
and main.go
. For this demonstration, I’ve used
the Gin web framework for Go, but any other framework (or even no framework
at all) would suffice. The client code is even simpler and consists of two files: client.go
and main.go
. It creates
and sends a GET request
to the /users/:userId
endpoint. Upon receiving the result, it parses the JSON body into the
User
struct.
File /pkg/client/client.go
func GetUserByID(host string, id string) (*server.User, error) {
uri := fmt.Sprintf("http://%s/users/%s", host, id)
resp, err := http.Get(uri)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var user server.User
err = json.NewDecoder(resp.Body).Decode(&user)
if err != nil {
return nil, err
}
return &user, nil
}
File /cmd/main.go
func main() {
user, err := client.GetUserByID("localhost:8080", "1")
if err != nil {
panic(err)
}
fmt.Println(user)
}
In both the client and server code, I have included dedicated functions for handling requests and responses. This code structure is crucial to facilitate the creation of specific test functions for the code we intend to test later.
PACT test for the client
Writing a PACT test in Go is similar to writing unit tests. In this case, we should also depend on the [package](https://github.com/pact-foundation/pact-go “package) from the Pact Foundation. Installation is a straightforward process, and it supports Go Modules.
File /pkg/client/client_test.go
import (
//
// some imports
//
"github.com/pact-foundation/pact-go/types"
"github.com/pact-foundation/pact-go/dsl"
)
func TestClientPact_Local(t *testing.T) {
// initialize PACT DSL
pact := dsl.Pact{
Consumer: "example-client",
Provider: "example-server",
}
// setup a PACT Mock Server
pact.Setup(true)
t.Run("get user by id", func(t *testing.T) {
id := "1"
pact.
AddInteraction(). // specify PACT interaction
Given("User Alice exists"). // specify Provider state
UponReceiving("User 'Alice' is requested"). // specify test case name
WithRequest(dsl.Request{ // specify expected request
Method: "GET",
// specify matching for endpoint
Path: dsl.Term("/users/1", "/users/[0-9]+"),
}).
WillRespondWith(dsl.Response{ // specify minimal expected response
Status: 200,
Body: dsl.Like(server.User{ // pecify matching for response body
ID: id,
FirstName: "Alice",
LastName: "Doe",
}),
})
// verify interaction on client side
err := pact.Verify(func() error {
// specify host anf post of PACT Mock Server as actual server
host := fmt.Sprintf("%s:%d", pact.Host, pact.Server.Port)
// execute function
user, err := GetUserByID(host, id)
if err != nil {
return errors.New("error is not expected")
}
// check if actual user is equal to expected
if user == nil || user.ID != id {
return fmt.Errorf("expected user with ID %s but got %v", id, user)
}
return err
})
if err != nil {
t.Fatal(err)
}
})
// write Contract into file
if err := pact.WritePact(); err != nil {
t.Fatal(err)
}
// stop PACT mock server
pact.Teardown()
}
As you can see in the example above, I have provided the PACT test in Go as a simple unit test. At the beginning of the
test, I have defined a PACT DSL
and run the Mock Server
. The critical point of the test is defining an Interaction
.
An Interaction
contains the state of the Provider
, the name of a test case, the expected request, and the expected
minimal response. We can define many attributes for both the request and response, including body
, headers
, query
,
status code
, etc.
After defining the Interaction
, the next step is Verification
. The PACT test runs our client, which now sends a request
to the PACT Mock Server
instead of a real one. I have ensured this by providing the Mock Server
’s host to the
GetUserByID
method. If the actual request matches the expected one, the Mock Server
sends back the expected minimal
response. Inside the test, we can make a final check if our method returns the correct User
after extracting it from
the JSON body.
The last step involves writing the Interaction
in the form of a Contract
. PACT stores the Contract
inside the pacts
folder by default, but we can change that during PACT DSL
initialization. After executing the code, the final output
should look like this:
Client testing console output
=== RUN TestClientPact_Local
2021/08/29 13:55:25 [INFO] checking pact-mock-service within range >= 3.5.0, < 4.0.0
2021/08/29 13:55:26 [INFO] checking pact-provider-verifier within range >= 1.31.0, < 2.0.0
2021/08/29 13:55:26 [INFO] checking pact-broker within range >= 1.22.3
2021/08/29 13:55:27 [INFO] INFO WEBrick 1.4.2
2021/08/29 13:55:27 [INFO] INFO ruby 2.6.3 (2019-04-16) [universal.x86_64-darwin19]
2021/08/29 13:55:27 [INFO] INFO WEBrick::HTTPServer#start: pid=21959 port=56423
--- PASS: TestClientPact_Local (2.31s)
=== RUN TestClientPact_Local/get_user_by_id
2021/08/29 13:55:27 [INFO] INFO going to shutdown ...
2021/08/29 13:55:28 [INFO] INFO WEBrick::HTTPServer#start done.
--- PASS: TestClientPact_Local/get_user_by_id (0.02s)
PASS
PACT test for the server
Writing PACT tests for the server is easier. The idea is to verify the desired Contracts
that the client already provides.
We also write PACT tests for servers in the form of unit tests.
File /pkg/server/server_test.go
import (
//
// some imports
//
"github.com/pact-foundation/pact-go/types"
"github.com/pact-foundation/pact-go/dsl"
)
func TestServerPact_Verification(t *testing.T) {
// initialize PACT DSL
pact := dsl.Pact{
Provider: "example-server",
}
// verify Contract on server side
_, err := pact.VerifyProvider(t, types.VerifyRequest{
ProviderBaseURL: "http://127.0.0.1:8080",
PactURLs: []string{"../client/pacts/example-client-example-server.json"},
})
if err != nil {
t.Log(err)
}
}
The client has already provided a Contract
as a JSON file that contains all interactions. Here, we also need to define
PACT DSL
and then execute the verification of the Contract
. During the Contract
’s verification, PACT Mock Client
sends expected requests to the server, specified in one of the Interactions
in the Contract
. The server receives the
request and returns the actual response. Mock Client
gets the response and matches it with the expected minimal response.
If the whole process of verification is successful, we should get an output similar to this:
Server testing console output
=== RUN TestServerPact_Verification
2021/08/29 14:41:13 [INFO] checking pact-mock-service within range >= 3.5.0, < 4.0.0
2021/08/29 14:41:14 [INFO] checking pact-provider-verifier within range >= 1.31.0, < 2.0.0
2021/08/29 14:41:14 [INFO] checking pact-broker within range >= 1.22.3
--- PASS: TestServerPact_Verification (2.45s)
=== RUN TestServerPact_Verification/Pact_between__and__
--- PASS: TestServerPact_Verification/Pact_between__and__ (0.00s)
=== RUN TestServerPact_Verification/has_status_code_200
pact.go:637: Verifying a pact between example-client and example-server Given User Alice exists User 'Alice' is requested with GET /users/1 returns a response which has status code 200
--- PASS: TestServerPact_Verification/has_status_code_200 (0.00s)
=== RUN TestServerPact_Verification/has_a_matching_body
pact.go:637: Verifying a pact between example-client and example-server Given User Alice exists User 'Alice' is requested with GET /users/1 returns a response which has a matching body
--- PASS: TestServerPact_Verification/has_a_matching_body (0.00s)
PASS
Usage of PACT Broker with PactFlow
As mentioned earlier in this article, the usage of PACT testing cannot be completed without the PACT Broker
. We do not
expect to have access to Contracts
in physical files from clients inside the pipelines of our servers. For that purpose,
development teams should use a standalone PACT Broker
dedicated to that project. It is possible to use the
Docker image provided by PACT Foundation and have
it installed as part of your infrastructure. Also, if you are willing to pay for a PACT Broker
, the solution from
PactFlow is perfect, and registration is simple. For this article, I have been using the trial version of PactFlow,
which allows me to store up to five Contracts
.
To publish Contracts
to PactFlow, I need to make minor adaptations inside the client test. These adaptations include
the new part where I have defined a PACT Publisher
that uploads all Contracts
after the test execution.
Adapted File /pkg/client/client_test.go
func TestClientPact_Broker(t *testing.T) {
pact := dsl.Pact{
Consumer: "example-client",
Provider: "example-server",
}
t.Run("get user by id", func(t *testing.T) {
id := "1"
pact.
AddInteraction().
Given("User Alice exists").
UponReceiving("User 'Alice' is requested").
WithRequest(dsl.Request{
Method: "GET",
Path: dsl.Term("/users/1", "/users/[0-9]+"),
}).
//
// PACT verification
//
})
if err := pact.WritePact(); err != nil {
t.Fatal(err)
}
// specify PACT publisher
publisher := dsl.Publisher{}
err := publisher.Publish(types.PublishRequest{
// a folder with all PACT test
PactURLs: []string{"./pacts/"},
// PACT broker URI
PactBroker: "<PACT BROKER>",
// API token for PACT broker
BrokerToken: "<API TOKEN>",
ConsumerVersion: "1.0.0",
Tags: []string{"1.0.0", "latest"},
})
if err != nil {
t.Fatal(err)
}
pact.Teardown()
}
After registering with PactFlow, you should receive the new host for your PACT Broker
. Additionally, you should use your
API token to complete the Publisher
definition in the code. You can find API tokens within the dashboard overview settings.
When we execute the new client test, it adds the first Contract to PactFlow. This Contract
has tags 1.0.0
, latest
,
and master
(which are added by default).
To create a difference in the client, I adapted the test to send a request to the other endpoint /user/:userId
instead of
/users/:userId
. Additionally, I changed the Tag
and ConsumerVersion
to be 1.0.1
instead of 1.0.0
. After executing
the test, the additional Contract
will appear.
Next, I adapted the server test to accommodate the changes. The new adjustments are made in the Verification
process.
It now includes the PACT Broker host
, API token
, and a decision to publish the verification result.
Adapted File /pkg/server/server_test.go
func TestServerPact_BrokerVerification(t *testing.T) {
pact := dsl.Pact{
Provider: "example-server",
}
_, err := pact.VerifyProvider(t, types.VerifyRequest{
BrokerURL: "<PACT BROKER>",
BrokerToken: "<API TOKEN>",
ProviderBaseURL: "http://127.0.0.1:8080",
ProviderVersion: "1.0.0",
ConsumerVersionSelectors: []types.ConsumerVersionSelector{
{
Consumer: "example-client",
Tag: "1.0.0",
},
},
PublishVerificationResults: true, // publish results of verification to PACT broker
})
if err != nil {
t.Log(err)
}
}
In addition, it should also include a selector for the Consumer
name and version to perform verification with the correct
Contract
version. The first execution, which passed successfully, checks the client version 1.0.0
. However, the second
execution of the test, which failed, is for checking the client version 1.0.1
. The second execution is expected to fail
because the server still listens to the /users/:userId
endpoint.
To fix the integration between the newest client and server, we need to make adjustments to either of them. In this case,
I have decided to modify the server to listen to the new /user/:usersId
endpoint. After updating the server to the new
version 1.0.1
and executing PACT verification once again, the test passes successfully, and it publishes new verification
results on the PACT Broker
.
On PactFlow, as well as on any other PACT Broker
, we can review the history of each Contract
’s verification process
by accessing a specific Contract
from the dashboard overview and then examining its Matrix tab.
Conclusion
Writing PACT tests is a fast and cost-effective way to incorporate into our pipeline. By validating Consumers and Providers early in our CI/CD process, we save time and receive early feedback on our integration outcomes. Contract tests enable us to utilize the current versions of our clients and servers, evaluating them independently to determine their compatibility.