Tag Archives: pact

Adding PACT Contract Testing to an existing golang code base

Here’s how to add a Contract Test to a Go microservice, a provider in pact terminology, using pact-go This post uses v1 of pact-go, as the V2 version is stil in beta.

Install PACT cli tools on your dev machine

A linux or dev-container based environment can just use the same approach as the CI pipeline documented later on in this post.

For a Windows dev machine, install the pact-cli tools like this:

  1. Use browser to download https://github.com/pact-foundation/pact-ruby-standalone/releases/download/v2.0.2/pact-2.0.2-windows-x86.zip
  2. unzip to c:\Repos
  3. Change PATH to include c:\repos\pact\bin
  4. Restart any editors or terminals
  5. Run go install gopkg.in/pact-foundation/pact-go.v1

Create a unit test to validate the service meets the contract/pact

Add unit test like below. Notice these settings and how the environment variables affect them. This helps when tests need to run both on the local development machine as well as on CI/CD machines.

Setting Effect
PublishVerificationResults Controls if pact should publish the verification result to the broker. For my local dev machine I dont publish. From the CI pipeline I do publish
ProviderBranch The name of the branch for this provider version.
ProviderVersion The version of this provider. My CI pipeline ensures variable PACT_PROVIDER_VERSION contains the unique build number. On my local machine its just set to 0.0.0
package app
import (
    "fmt"
    "bff/itemrepository"
    "os"
    "strconv"
    "strings"
    "testing"

    "github.com/pact-foundation/pact-go/dsl"
    "github.com/pact-foundation/pact-go/types"
    "github.com/stretchr/testify/assert"
)

func randomItem(env string, name string) itemrepository.item{
    return itemrepository.item{
        Environment:               env,
        Name:                      name,
    }
}

func getEnv(name string, defaultVal string) string {
    tmp := os.Getenv(name)
    if len(tmp) > 0 {
        return tmp
    } else {
        return defaultVal
    }
}

func getProviderPublishResults() bool {
    tmp, err := strconv.ParseBool(getEnv("PACT_PROVIDER_PUBLISH", "false"))
    if err != nil {
        panic(err)
    }
    return tmp
}

func TestProvider(t *testing.T) {
    //Arrange: Start the service in the background.
    port, repo, _ := startApp(false)
    pact := &dsl.Pact{ Consumer: "MyConsumer", Provider: "MyProvider", }

    //Act: Let pact spin-up a mock client to verifie our service.
    _, err := pact.VerifyProvider(t, types.VerifyRequest{
            ProviderBaseURL:            fmt.Sprintf("http://localhost:%d", port),
            BrokerURL:                  getEnv("PACT_BROKER_BASE_URL", ""),
            BrokerToken:                getEnv("PACT_BROKER_TOKEN", ""),
            PublishVerificationResults: getProviderPublishResults(),
            ProviderBranch:             getEnv("PACT_PROVIDER_BRANCH", ""),
            ProviderVersion:            getEnv("PACT_PROVIDER_VERSION", "0.0.0"),
            StateHandlers: types.StateHandlers{
                "I have a list of items": func() error {
                    repo.Set("env1", []itemrepository.item{randomItem("env1", "tenant1")})
                        return nil
                },
            },
        })
    pact.Teardown()

    //Assert
    assert.NoError(t, err)
}

Ensure the CI pipeline runs the test and publishes verification results

For my CI builds, I run this to make sure the CI machine has the pact-cli tools installed
cd /opt
curl -fsSL https://raw.githubusercontent.com/pact-foundation/pact-ruby-standalone/master/install.sh | bash
export PATH=$PATH:/opt/pact/bin
go install github.com/pact-foundation/pact-go@v1
...
pipeline already runs the unit tests
...

Check `can-i-deploy` in the CD pipeline

Change the CD pipeline to

  • verify this version of the service has passed the contract test, Otherwise do not deploy to production
  • After successful deployment, inform broker which new version is running in production.

My CD pipeline runs on different machines, so it again has to ensure PACT is installed, just like the CI pipeline:

# pipeline variables
PACT_PACTICIPANT=MyProvider
PACT_ENVIRONMENT=production
PACT_BROKER=https://yourtenant.pactflow.io
PACT_BROKER_TOKEN=...a read/write token...preferably a system user to represent CI/CD actions...

# task to install PACT
cd /opt
curl -fsSL https://raw.githubusercontent.com/pact-foundation/pact-ruby-standalone/master/install.sh | bash
echo "##vso[task.prependpath]/opt/pact/bin"
...
...
# Task to check if the version of the build can be deployed to production
pact-broker can-i-deploy --pacticipant $PACT_PACTICIPANT --version $BUILD_BUILDNUMBER --to-environment $PACT_ENVIRONMENT --broker-base-url $PACT_BROKER --broker-token $PACT_BROKER_TOKEN
...
#Do whatever tasks you need to deploy the build to the environment
...
...
#Task to record the deployment of this version of the producer to the environment
pact-broker record-deployment --environment $PACT_ENVIRONMENT --version $BUILD_BUILDNUMBER --pacticipant $PACT_PACTICIPANT --broker-base-url $PACT_BROKER --broker-token $PACT_BROKER_TOKEN

Adding PACT Contract Testing to an existing TypeScript code base

I like Contract Testing! I added a contract test with PACT-js and Jest for my consumer like this:

Installing PACT

  1. Disable the ignore-scripts setting: npm config set ignore-scripts false
  2. Ensure build chain is installed. Most linux based CI/CD agents have this out of the box. My local dev machine runs Windows; according to the installation guide for gyp the process is:
    1. Install Python from the MS App store. This takes about 5 minutes.
    2. Ensure the machine can build native code. My machine had Visual Studio already so I just added the ‘Desktop development with C++’ workload using the installer from ‘Tools -> Get Tools and Features’ This takes about 15 – 30 minutes
    3. npm install -g node-gyp
  3. Install the PACT js npmn package: npm i -S @pact-foundation/pact@latest
  4. Write a unit test using either the V3 or V2 of the PACT specification. See below for some examples.
  5. Update your CI build pipeline to publish the PACT like this: npx pact-broker publish ./pacts --consumer-app-version=$BUILD_BUILDNUMBER --auto-detect-version-properties --broker-base-url=$PACT_BROKER_BASE_URL --broker-token=$PACT_BROKER_TOKEN

A V3 version of a PACT unit test in Jest

//BffClient is the class implementing the logic to interact with the micro-service.
//the objective of this test is to:
//1. Define the PACT with the microservice
//2. Verify the class communicates according to the pact

import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { BffClient } from './BffClient';

// Create a 'pact' between the two applications in the integration we are testing
const provider = new PactV3({
    dir: path.resolve(process.cwd(), 'pacts'),
    consumer: 'MyConsumer',
    provider: 'MyProvider',
});

describe('GET /', () => {
    it('returns OK and an array of items', () => {
        const exampleData: any = { name: "my-name", environment: "my-environment", };

        // Arrange: Setup our expected interactions. Pact mocks the microservice for us.
        provider
            .given('I have a list of items')
            .uponReceiving('a request for all items')
            .withRequest({method: 'GET', path: '/', })
            .willRespondWith({
                status: 200,
                headers: { 'Content-Type': 'application/json' },
                body: MatchersV3.eachLike(exampleData),
            });
        return provider.executeTest(async (mockserver) => {
            // Act: trigger our BffClient client code to do its behavior 
            // we configured it to use the mock instead of needing some external thing to run
            const sut = new BffClient(mockserver.url, "");
            const response = await sut.get()

            // Assert: check the result
            expect(response.status).toEqual(200)
            const data:any[] = await response.json()
            expect(data).toEqual([exampleData]);
        });
    });
});

A V2 version

import { Pact, Matchers } from '@pact-foundation/pact';
import path from 'path';
import { BffClient } from './BffClient';

// Create a 'pact' between the two applications in the integration we are testing
const provider = new Pact({
    dir: path.resolve(process.cwd(), 'pacts'),
    consumer: 'MyConsumer',
    provider: 'MyProvider',
});

describe('GET', () => {
    afterEach(() => provider.verify());
    afterAll(() => provider.finalize());

    it('returns OK and array of items', async () => {
        const exampleData: any = { name: "my-name", environment: "my-environment", };
        // Arrange: Setup our expected interactions. Pact mocks the microservice for us.
        await provider.setup()
        await provider.addInteraction({
            state: 'I have a list of items',
            uponReceiving: 'a request for all items',
            withRequest: { method: 'GET', path: '/',  },
            willRespondWith: {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
                body: Matchers.eachLike(exampleData),
            },
        })

        // Act: trigger our BffClient client code to do its behavior 
        // we configured it to use the mock instead of needing some external thing to run
        const sut= new BffClient(mockserver.url, "");
        const response = sut.get()
        
        // Assert: check the result
        expect(response.status).toEqual(200)
        const data: any[] = await response.json()
        expect(data).toEqual([exampleData]);
    });
});