Golang Tutorial: Contract Testing with PACT

- 13 minutes read - 2648 words

The 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.

Contract testing

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.

Consumer Testing

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.

Provider Testing

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.

PactFlow overview

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).

PactFlow first contract

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.

PactFlow second contract

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.

PactFlow first verification

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.

PactFlow second verification

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.

PactFlow matrix

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.

Useful Resources

comments powered by Disqus