Skip to content
gohacks.dev
APIs

Contract Testing Go APIs With Pact and Consumer Tests

Learn consumer driven contract testing for Go APIs by building the mechanism by hand, then see where Pact adds a broker, matchers and can-i-deploy.

10 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Integration tests that spin up every service are slow, flaky and owned by nobody. Mocks in each team's own tests are fast and fiction: they encode what that team believes the other service does. The result is the classic failure: both test suites are green, the services are deployed, and production breaks because the mock and the reality drifted apart.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

Consumer driven contract testing closes the gap between the two without bringing every service up together. Each consumer records what it expects from a provider, as data. The provider replays that data against its real code in its own CI. If the provider changes in a way that breaks a consumer, the provider's build fails, before anything is deployed.

Pact is the best known tool for this and has a Go library. Before reaching for it, this article builds the mechanism in about a hundred lines so you can see exactly what a contract is and is not. Then it covers what Pact adds and when it is worth the extra moving parts. It sits next to OpenAPI contracts in the same family of ideas, and uses the table-driven handler tests style on the provider side. It ran on Go 1.25.6.

What a contract is

A contract is a list of interactions. Each one says: when I send this request, I expect a response with this status and these fields of these types. The important word is these. The consumer lists only what it actually uses. A billing service that reads id and name from a user does not care about plan or email, so it does not mention them, and the provider is free to change them.

That is the main difference from a schema like OpenAPI. A schema describes everything the provider promises, so every field is part of the promise. A consumer-driven contract describes what one consumer relies on. The provider's real obligations are the union of its consumers' contracts, and a field nobody depends on is a field it can safely remove.

The pieces

The lab has three parts: a record format, a verifier, and a pair of services.

contract.goGo
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sort"
)

// Interaction is what one consumer expects from one provider call. Only the fields it lists are part of the contract.
type Interaction struct {
	Description string            `json:"description"`
	Method      string            `json:"method"`
	Path        string            `json:"path"`
	Status      int               `json:"status"`
	BodyTypes   map[string]string `json:"body_types,omitempty"` // field -> "string" | "number"
}

type Contract struct {
	Consumer     string        `json:"consumer"`
	Provider     string        `json:"provider"`
	Interactions []Interaction `json:"interactions"`
}

// Verify replays every interaction against a real handler and reports each way the provider breaks the consumer.
func Verify(c Contract, provider http.Handler) []string {
	var failures []string
	for _, in := range c.Interactions {
		rec := httptest.NewRecorder()
		provider.ServeHTTP(rec, httptest.NewRequest(in.Method, in.Path, nil))
		resp := rec.Result()
		body, _ := io.ReadAll(resp.Body)
		if resp.StatusCode != in.Status {
			failures = append(failures, fmt.Sprintf("%s: status %d, contract says %d", in.Description, resp.StatusCode, in.Status))
			continue
		}
		var got map[string]any
		json.Unmarshal(body, &got)
		fields := make([]string, 0, len(in.BodyTypes))
		for f := range in.BodyTypes {
			fields = append(fields, f)
		}
		sort.Strings(fields)
		for _, f := range fields {
			v, ok := got[f]
			switch {
			case !ok:
				failures = append(failures, fmt.Sprintf("%s: field %q is missing", in.Description, f))
			case typeName(v) != in.BodyTypes[f]:
				failures = append(failures, fmt.Sprintf("%s: field %q is %s, contract says %s", in.Description, f, typeName(v), in.BodyTypes[f]))
			}
		}
	}
	return failures
}

func typeName(v any) string {
	switch v.(type) {
	case string:
		return "string"
	case float64:
		return "number"
	case bool:
		return "boolean"
	}
	return "other"
}

Interaction holds the method, path, status and a map of field to type. Verify replays each interaction against any http.Handler, checks the status, and for every listed field checks it is present with the right JSON type. It deliberately ignores fields it was not told about.

On the consumer side, the code under test is an ordinary client that calls GET /users/{id} and decodes id and name. The consumer's test suite writes down the interactions it exercises. In a real project, the recording is a side effect of the consumer's own tests against a mock server: the mock is configured from the same interactions, so the mock cannot disagree with the contract.

Running it

main.goGo
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

// ---- Consumer: the billing service. It reads only id and name. ----

type UserClient struct {
	base string
	http *http.Client
}

type User struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

func (c *UserClient) Get(id int) (User, int, error) {
	resp, err := c.http.Get(fmt.Sprintf("%s/users/%d", c.base, id))
	if err != nil {
		return User{}, 0, err
	}
	defer resp.Body.Close()
	var u User
	if resp.StatusCode == 200 {
		err = json.NewDecoder(resp.Body).Decode(&u)
	}
	return u, resp.StatusCode, err
}

// recordContract is what the consumer's test suite writes: its own calls and the parts of the answer it relies on.
func recordContract() Contract {
	return Contract{Consumer: "billing", Provider: "users", Interactions: []Interaction{
		{Description: "a user that exists", Method: "GET", Path: "/users/42", Status: 200,
			BodyTypes: map[string]string{"id": "number", "name": "string"}},
		{Description: "a user that does not exist", Method: "GET", Path: "/users/999", Status: 404},
	}}
}

// ---- Provider: the users service, in several versions. ----

func providerV(version string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !strings.HasSuffix(r.URL.Path, "/42") {
			status := http.StatusNotFound
			if version == "404 becomes 410" {
				status = http.StatusGone
			}
			http.Error(w, `{"error":"not found"}`, status)
			return
		}
		u := map[string]any{"id": 42, "name": "Ada", "email": "[email protected]", "plan": "pro"}
		switch version {
		case "adds a field":
			u["locale"] = "en-GB"
		case "removes email":
			delete(u, "email")
		case "renames name":
			u["full_name"] = u["name"]
			delete(u, "name")
		case "id becomes a string":
			u["id"] = "usr_42"
		}
		json.NewEncoder(w).Encode(u)
	})
}

func main() {
	contract := recordContract()
	b, _ := json.MarshalIndent(contract, "", "  ")
	fmt.Println(string(b))

	// The consumer's own unit test runs against a stub that answers exactly what the contract says.
	stub := httptest.NewServer(providerV("current"))
	c := &UserClient{base: stub.URL, http: stub.Client()}
	u, status, _ := c.Get(42)
	fmt.Printf("\nconsumer against stub: %d %+v\n\n", status, u)
	stub.Close()

	for _, v := range []string{"current", "adds a field", "removes email", "renames name", "id becomes a string", "404 becomes 410"} {
		fails := Verify(contract, providerV(v))
		if len(fails) == 0 {
			fmt.Printf("provider %-20s ok\n", v)
			continue
		}
		fmt.Printf("provider %-20s BREAKS billing\n", v)
		for _, f := range fails {
			fmt.Println("    ", f)
		}
	}
}
Text
{
  "consumer": "billing",
  "provider": "users",
  "interactions": [
    {
      "description": "a user that exists",
      "method": "GET",
      "path": "/users/42",
      "status": 200,
      "body_types": {
        "id": "number",
        "name": "string"
      }
    },
    {
      "description": "a user that does not exist",
      "method": "GET",
      "path": "/users/999",
      "status": 404
    }
  ]
}

consumer against stub: 200 {ID:42 Name:Ada}

provider current              ok
provider adds a field         ok
provider removes email        ok
provider renames name         BREAKS billing
     a user that exists: field "name" is missing
provider id becomes a string  BREAKS billing
     a user that exists: field "id" is string, contract says number
provider 404 becomes 410      BREAKS billing
     a user that does not exist: status 410, contract says 404

The program prints the contract as JSON first. That file is the artefact that moves between teams: the consumer publishes it, the provider downloads it.

It then checks the consumer's own behaviour against a stub built from the current provider (the consumer is happy: 200 {ID:42 Name:Ada}), and replays the contract against six versions of the provider.

What the verification tells you

Additive changes pass. The provider that added locale is fine. Consumers must tolerate unknown fields; the contract does not pin the whole body, so extra data never fails it.

Removing an unused field passes. The provider without email verified clean, because billing never listed email. This is the pay-off. Without the contract, the provider team would have to guess whether any consumer uses email, and the safe guess is "yes", which is how APIs grow without ever shrinking. With contracts from every consumer, "no contract mentions it" is evidence.

Renaming a relied-on field fails. The provider that renamed name to full_name produced field "name" is missing. The failure appears in the provider's pipeline with the consumer's name attached (billing), which is the message the developer needs: not "a test failed" but "this change breaks billing".

Changing a type fails. id becoming a string such as usr_42 would be a reasonable change on its own. The contract says number, so it fails with field "id" is string.

Status codes are part of the contract. Changing the not-found response from 404 to 410 broke the second interaction. Consumers branch on status, so this is a real break. Many contract suites forget error cases; include at least the 404 and the validation failure that your consumer handles.

Where the recorded contract comes from

The honest version of this in a real codebase has the consumer's tests generate the contract. The flow:

  1. The consumer's test says "when I call GET /users/42, the provider will answer 200 with id and name".
  2. The test framework starts a mock server that answers that way and runs the consumer code against it. If the consumer code misbehaves, the consumer's test fails.
  3. After the run, the framework writes the interactions to a file.
  4. CI publishes the file, tagged with the consumer's version and branch.

Because the mock and the contract are generated from the same statements, the consumer cannot claim a behaviour in its tests that it did not record. That is the property that separates this from hand-maintained fixtures.

What Pact adds

Pact is a specification, a set of libraries and a server. The Go library (github.com/pact-foundation/pact-go/v2) wraps a shared core written in Rust, which means it needs native libraries installed on your machine and in CI. I did not run it here, so what follows is from its documentation, not from a run.

Matchers. Instead of exact values, an interaction can say "any integer" or "a string matching this regex" or "an array with at least one element shaped like this". The lab's two types are a crude version of that.

A broker. The Pact Broker (or the hosted PactFlow) stores contracts and verification results, keyed by consumer, provider and version. Providers pull the latest contracts for the consumers that matter, such as whatever is deployed to production, and publish their verification results back.

can-i-deploy. A command that asks the broker whether a given version of a service is compatible with the versions currently deployed in an environment. A deployment pipeline runs it before release, so a provider cannot ship a change that a deployed consumer has not verified against.

Provider states. An interaction can require a precondition, "given a user 42 exists". The provider supplies a setup function per state, so verification runs against a database in the right condition. The lab sidesteps this because its handler has fixed data; a real provider with a database needs it.

Messages. Pact also supports contracts for asynchronous messages, such as events on a queue, with the same record-and-replay idea.

When it is worth it

Contract tests pay off when there are several teams, several deployables, and independent release cycles. If one team owns both sides and deploys them together, a shared integration test or an OpenAPI check is cheaper. If the consumers are unknown (a public API), you cannot collect their contracts, and a schema plus a deprecation policy (deprecating endpoints) is the right tool.

The costs to weigh: every consumer must write and publish contracts, provider states take upkeep, and the broker is another service to run or buy. The benefit is a class of failure that other tests do not catch at all. A reasonable path is to start with the hand-rolled version for your two most fragile integrations, see whether the discipline sticks, and adopt Pact when the number of contracts makes a broker worth it.

Some pitfalls

Over-specifying. A contract that pins whole response bodies is a brittle copy of the provider's tests. List the fields the consumer reads, nothing more.

Under-specifying errors. Consumers handle 404, 409, 422 and 429 differently. If their behaviour depends on it, record it.

Treating the contract as documentation. It documents what is used, which is not what is offered. Keep your OpenAPI or reference docs for the second job.

Skipping verification in CI. A contract nobody verifies is a fixture. Make the provider job fail on a broken contract, and make the consumer job fail when it cannot publish.

The Pact project documents the full model at docs.pact.io, including the Go library setup and the broker, and the provider-side replay in this article is the same idea as its verification step. For the handler tests that sit underneath a provider verification, the standard library's httptest package is all you need.

Also inTesting
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.