Skip to main content

Pact JS workshop

Source Code#

https://github.com/pact-foundation/pact-workshop-js

Introduction#

This workshop is aimed at demonstrating core features and benefits of contract testing with Pact.

Whilst contract testing can be applied retrospectively to systems, we will follow the consumer driven contracts approach in this workshop - where a new consumer and provider are created in parallel to evolve a service over time, especially where there is some uncertainty with what is to be built.

This workshop should take from 1 to 2 hours, depending on how deep you want to go into each topic.

Workshop outline:

NOTE: Each step is tied to, and must be run within, a git branch, allowing you to progress through each stage incrementally. For example, to move to step 2 run the following: git checkout step2

Learning objectives#

If running this as a team workshop format, you may want to take a look through the learning objectives.

Requirements#

Docker

Docker Compose

Node + NPM

Scenario#

There are two components in scope for our workshop.

  1. Product Catalog website. It provides an interface to query the Product service for product information.
  2. Product Service (Provider). Provides useful things about products, such as listing all products and getting the details of an individual product.

Step 1 - Simple Consumer calling Provider#

We need to first create an HTTP client to make the calls to our provider service:

Simple Consumer

The Consumer has implemented the product service client which has the following:

  • GET /products - Retrieve all products
  • GET /products/{id} - Retrieve a single product by ID

The diagram below highlights the interaction for retrieving a product with ID 10:

Sequence Diagram

You can see the client interface we created in consumer/src/api.js:

export class API {
    constructor(url) {        if (url === undefined || url === "") {            url = process.env.REACT_APP_API_BASE_URL;        }        if (url.endsWith("/")) {            url = url.substr(0, url.length - 1)        }        this.url = url    }
    withPath(path) {        if (!path.startsWith("/")) {            path = "/" + path        }        return `${this.url}${path}`    }
    async getAllProducts() {        return axios.get(this.withPath("/products"))            .then(r => r.data);    }
    async getProduct(id) {        return axios.get(this.withPath("/products/" + id))            .then(r => r.data);    }}

After forking or cloning the repository, we may want to install the dependencies npm install. We can run the client with npm start --prefix consumer - it should fail with the error below, because the Provider is not running.

Failed step1 page

Move on to step 2

Step 2 - Client Tested but integration fails#

Now lets create a basic test for our API client. We're going to check 2 things:

  1. That our client code hits the expected endpoint
  2. That the response is marshalled into an object that is usable, with the correct ID

You can see the client interface test we created in consumer/src/api.spec.js:

import API from "./api";import nock from "nock";
describe("API", () => {
    test("get all products", async () => {        const products = [            {                "id": "9",                "type": "CREDIT_CARD",                "name": "GEM Visa",                "version": "v2"            },            {                "id": "10",                "type": "CREDIT_CARD",                "name": "28 Degrees",                "version": "v1"            }        ];        nock(API.url)            .get('/products')            .reply(200,                products,                {'Access-Control-Allow-Origin': '*'});        const respProducts = await API.getAllProducts();        expect(respProducts).toEqual(products);    });
    test("get product ID 50", async () => {        const product = {            "id": "50",            "type": "CREDIT_CARD",            "name": "28 Degrees",            "version": "v1"        };        nock(API.url)            .get('/products/50')            .reply(200, product, {'Access-Control-Allow-Origin': '*'});        const respProduct = await API.getProduct("50");        expect(respProduct).toEqual(product);    });});

Unit Test With Mocked Response

Let's run this test and see it all pass:

โฏ npm test --prefix consumer
PASS src/api.spec.js  API    โœ“ get all products (15ms)    โœ“ get product ID 50 (3ms)
Test Suites: 1 passed, 1 totalTests:       2 passed, 2 totalSnapshots:   0 totalTime:        1.03sRan all test suites.

If you encounter failing tests after running npm test --prefix consumer, make sure that the current branch is step2.

Meanwhile, our provider team has started building out their API in parallel. Let's run our website against our provider (you'll need two terminals to do this):

# Terminal 1โฏ npm start --prefix provider
Provider API listening on port 8080...
# Terminal 2> npm start --prefix consumer
Compiled successfully!
You can now view pact-workshop-js in the browser.
  Local:            http://localhost:3000/  On Your Network:  http://192.168.20.17:3000/
Note that the development build is not optimized.To create a production build, use npm run build.

You should now see a screen showing 3 different products. There is a See more! button which should display detailed product information.

Let's see what happens!

Failed page

Doh! We are getting 404 everytime we try to view detailed product information. On closer inspection, the provider only knows about /product/{id} and /products.

We need to have a conversation about what the endpoint should be, but first...

Move on to step 3

Step 3 - Pact to the rescue#

Unit tests are written and executed in isolation of any other services. When we write tests for code that talk to other services, they are built on trust that the contracts are upheld. There is no way to validate that the consumer and provider can communicate correctly.

An integration contract test is a test at the boundary of an external service verifying that it meets the contract expected by a consuming service โ€” Martin Fowler

Adding contract tests via Pact would have highlighted the /product/{id} endpoint was incorrect.

Let us add Pact to the project and write a consumer pact test for the GET /products/{id} endpoint.

Provider states is an important concept of Pact that we need to introduce. These states help define the state that the provider should be in for specific interactions. For the moment, we will initially be testing the following states:

  • product with ID 10 exists
  • products exist

The consumer can define the state of an interaction using the given property.

Note how similar it looks to our unit test:

In consumer/src/api.pact.spec.js:

import path from "path";import {Pact} from "@pact-foundation/pact";import {API} from "./api";import {eachLike, like} from "@pact-foundation/pact/dsl/matchers";
const provider = new Pact({    consumer: 'FrontendWebsite',    provider: 'ProductService',    log: path.resolve(process.cwd(), 'logs', 'pact.log'),    logLevel: "warn",    dir: path.resolve(process.cwd(), 'pacts'),    spec: 2});
describe("API Pact test", () => {

    beforeAll(() => provider.setup());    afterEach(() => provider.verify());    afterAll(() => provider.finalize());
    describe("getting all products", () => {        test("products exists", async () => {
            // set up Pact interactions            await provider.addInteraction({                state: 'products exist',                uponReceiving: 'get all products',                withRequest: {                    method: 'GET',                    path: '/products'                },                willRespondWith: {                    status: 200,                    headers: {                        'Content-Type': 'application/json; charset=utf-8'                    },                    body: eachLike({                        id: "09",                        type: "CREDIT_CARD",                        name: "Gem Visa"                    }),                },            });
            const api = new API(provider.mockService.baseUrl);
            // make request to Pact mock server            const product = await api.getAllProducts();
            expect(product).toStrictEqual([                {"id": "09", "name": "Gem Visa", "type": "CREDIT_CARD"}            ]);        });    });
    describe("getting one product", () => {        test("ID 10 exists", async () => {
            // set up Pact interactions            await provider.addInteraction({                state: 'product with ID 10 exists',                uponReceiving: 'get product with ID 10',                withRequest: {                    method: 'GET',                    path: '/products/10'                },                willRespondWith: {                    status: 200,                    headers: {                        'Content-Type': 'application/json; charset=utf-8'                    },                    body: like({                        id: "10",                        type: "CREDIT_CARD",                        name: "28 Degrees"                    }),                },            });
            const api = new API(provider.mockService.baseUrl);
            // make request to Pact mock server            const product = await api.getProduct("10");
            expect(product).toStrictEqual({                id: "10",                type: "CREDIT_CARD",                name: "28 Degrees"            });        });    });});

Test using Pact

This test starts a mock server a random port that acts as our provider service. To get this to work we update the URL in the Client that we create, after initialising Pact.

To simplify running the tests, add this to consumer/package.json:

// add it under scripts"test:pact": "CI=true react-scripts test --testTimeout 30000 pact.spec.js",

Running this test still passes, but it creates a pact file which we can use to validate our assumptions on the provider side, and have conversation around.

โฏ npm run test:pact --prefix consumer
PASS src/api.spec.jsPASS src/api.pact.spec.js
Test Suites: 2 passed, 2 totalTests:       4 passed, 4 totalSnapshots:   0 totalTime:        2.792s, estimated 3sRan all test suites.

A pact file should have been generated in consumer/pacts/frontendwebsite-productservice.json

NOTE: even if the API client had been graciously provided for us by our Provider Team, it doesn't mean that we shouldn't write contract tests - because the version of the client we have may not always be in sync with the deployed API - and also because we will write tests on the output appropriate to our specific needs.

Move on to step 4

Step 4 - Verify the provider#

We will need to copy the Pact contract file that was produced from the consumer test into the Provider module. This will help us verify that the provider can meet the requirements as set out in the contract.

Copy the contract located in consumer/pacts/frontendwebsite-productservice.json to provider/pacts/frontendwebsite-productservice.json.

Now let's make a start on writing Pact tests to validate the consumer contract:

In provider/product/product.pact.test.js:

const { Verifier } = require('@pact-foundation/pact');const path = require('path');
// Setup provider server to verifyconst app = require('express')();app.use(require('./product.routes'));const server = app.listen("8080");
describe("Pact Verification", () => {    it("validates the expectations of ProductService", () => {        const opts = {            logLevel: "INFO",            providerBaseUrl: "http://localhost:8080",            provider: "ProductService",            providerVersion: "1.0.0",            pactUrls: [                path.resolve(__dirname, '../../consumer/pacts/frontendwebsite-productservice.json')            ]        };
        return new Verifier(opts).verifyProvider().then(output => {            console.log(output);        }).finally(() => {            server.close();        });    })});

To simplify running the tests, add this to provider/package.json:

// add it under scripts"test:pact": "npx jest --testTimeout=30000 --testMatch \"**/*.pact.test.js\""

We now need to validate the pact generated by the consumer is valid, by executing it against the running service provider, which should fail:

โฏ npm run test:pact --prefix provider
[2020-01-14T06:54:12.572Z]  INFO: pact@9.5.0/12790: Verifying provider[2020-01-14T06:54:12.575Z]  INFO: pact-node@10.2.2/12790: Verifying Pacts.[2020-01-14T06:54:12.576Z]  INFO: pact-node@10.2.2/12790: Verifying Pact Files FAIL  product/product.pact.test.js  Pact Verification    โœ• validates the expectations of ProductService (716ms)
  โ— Pact Verification โ€บ validates the expectations of ProductService
    WARN: Only the first item will be used to match the items in the array at $['body']
    INFO: Reading pact at pact-workshop-js/provider/pacts/frontendwebsite-productservice.json

    Verifying a pact between FrontendWebsite and ProductService      Given products exist        get all products          with GET /products
            returns a response which
              has status code 200
              has a matching body
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8"

      Given product with ID 10 exists
        get product with ID 10

          with GET /products/10            returns a response which
              has status code 200 (FAILED - 1)
              has a matching body (FAILED - 2)
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8" (FAILED - 3)

    Failures:
      1) Verifying a pact between FrontendWebsite and ProductService Given product with ID 10 exists get product with ID 10 with GET /products/10 returns a response which has status code 200         Failure/Error: expect(response_status).to eql expected_response_status
           expected: 200                got: 404
           (compared using eql?)
      2) Verifying a pact between FrontendWebsite and ProductService Given product with ID 10 exists get product with ID 10 with GET /products/10 returns a response which has a matching body         Failure/Error: expect(response_body).to match_term expected_response_body, diff_options, example
           Actual: <!DOCTYPE html>           <html lang="en">           <head>           <meta charset="utf-8">           <title>Error</title>           </head>           <body>           <pre>Cannot GET /products/10</pre>           </body>           </html>

           Diff           --------------------------------------           Key: - is expected                + is actual           Matching keys and values are not shown
           -{           -  "id": "10",           -  "type": "CREDIT_CARD",           -  "name": "28 Degrees"           -}           +<!DOCTYPE html>           +<html lang="en">           +<head>           +<meta charset="utf-8">           +<title>Error</title>           +</head>           +<body>           +<pre>Cannot GET /products/10</pre>           +</body>           +</html>

           Description of differences           --------------------------------------           * Expected a Hash but got a String ("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot GET /products/10</pre>\n</body>\n</html>\n") at $
      3) Verifying a pact between FrontendWebsite and ProductService Given product with ID 10 exists get product with ID 10 with GET /products/10 returns a response which includes headers "Content-Type" which equals "application/json; charset=utf-8"         Failure/Error: expect(header_value).to match_header(name, expected_header_value)           Expected header "Content-Type" to equal "application/json; charset=utf-8", but was "text/html; charset=utf-8"

    2 interactions, 1 failure
    Failed interactions:

    * Get product with id 10 given product with ID 10 exists
      at ChildProcess.<anonymous> (node_modules/@pact-foundation/pact-node/src/verifier.ts:194:58)
Test Suites: 1 failed, 1 totalTests:       1 failed, 1 totalSnapshots:   0 totalTime:        2.043sRan all test suites.

Pact Verification

The test has failed, as the expected path /products/{id} is returning 404. We incorrectly believed our provider was following a RESTful design, but the authors were too lazy to implement a better routing solution ๐Ÿคท๐Ÿปโ€โ™‚๏ธ.

The correct endpoint which the consumer should call is /product/{id}.

Move on to step 5

Step 5 - Back to the client we go#

We now need to update the consumer client and tests to hit the correct product path.

First, we need to update the GET route for the client:

In consumer/src/api.js:

async getProduct(id) {  return axios.get(this.withPath("/product/" + id))  .then(r => r.data);}

Then we need to update the Pact test ID 10 exists to use the correct endpoint in path.

In consumer/src/api.pact.spec.js:

describe("getting one product", () => {  test("ID 10 exists", async () => {
    // set up Pact interactions    await provider.addInteraction({      state: 'product with ID 10 exists',      uponReceiving: 'get product with ID 10',      withRequest: {        method: 'GET',        path: '/product/10'      },
...

Pact Verification

Let's run and generate an updated pact file on the client:

โฏ npm run test:pact --prefix consumer
PASS src/api.pact.spec.js  API Pact test    getting all products      โœ“ products exists (18ms)    getting one product      โœ“ ID 10 exists (8ms)
Test Suites: 1 passed, 1 totalTests:       2 passed, 2 totalSnapshots:   0 totalTime:        2.106sRan all test suites matching /pact.spec.js/i.

Now we run the provider tests again with the updated contract

Copy the updated contract located in consumer/pacts/frontendwebsite-productservice.json to provider/pacts/frontendwebsite-productservice.json.

Run the command:

โฏ npm run test:pact --prefix provider
[2020-01-14T10:58:34.157Z]  INFO: pact@9.5.0/3498: Verifying provider[2020-01-14T10:58:34.161Z]  INFO: pact-node@10.2.2/3498: Verifying Pacts.[2020-01-14T10:58:34.162Z]  INFO: pact-node@10.2.2/3498: Verifying Pact Files PASS  product/product.pact.test.js  Pact Verification    โœ“ validates the expectations of ProductService (626ms)
Test Suites: 1 passed, 1 totalTests:       1 passed, 1 totalSnapshots:   0 totalTime:        2.068sRan all test suites.[2020-01-14T10:58:34.724Z]  WARN: pact@9.5.0/3498: No state handler found for "products exist", ignorning[2020-01-14T10:58:34.755Z]  WARN: pact@9.5.0/3498: No state handler found for "product with ID 10 exists", ignorning[2020-01-14T10:58:34.780Z]  INFO: pact-node@10.2.2/3498: Pact Verification succeeded.

Yay - green โœ…!

Move on to step 6

Step 6 - Consumer updates contract for missing products#

We're now going to add 2 more scenarios for the contract

  • What happens when we make a call for a product that doesn't exist? We assume we'll get a 404.

  • What happens when we make a call for getting all products but none exist at the moment? We assume a 200 with an empty array.

Let's write a test for these scenarios, and then generate an updated pact file.

In consumer/src/api.pact.spec.js:

// within the 'getting all products' grouptest("no products exists", async () => {
  // set up Pact interactions  await provider.addInteraction({    state: 'no products exist',    uponReceiving: 'get all products',    withRequest: {      method: 'GET',      path: '/products'    },    willRespondWith: {      status: 200,      headers: {        'Content-Type': 'application/json; charset=utf-8'      },      body: []    },  });
  const api = new API(provider.mockService.baseUrl);
  // make request to Pact mock server  const product = await api.getAllProducts();
  expect(product).toStrictEqual([]);});
// within the 'getting one product' grouptest("product does not exist", async () => {
  // set up Pact interactions  await provider.addInteraction({    state: 'product with ID 11 does not exist',    uponReceiving: 'get product with ID 11',    withRequest: {      method: 'GET',      path: '/product/11'    },    willRespondWith: {      status: 404    },  });
  const api = new API(provider.mockService.baseUrl);
  // make request to Pact mock server  await expect(api.getProduct("11")).rejects.toThrow("Request failed with status code 404");});

Notice that our new tests look almost identical to our previous tests, and only differ on the expectations of the response - the HTTP request expectations are exactly the same.

โฏ npm run test:pact --prefix consumer
PASS src/api.pact.spec.js  API Pact test    getting all products      โœ“ products exists (24ms)      โœ“ no products exists (13ms)    getting one product      โœ“ ID 10 exists (14ms)      โœ“ product does not exist (14ms)
Test Suites: 1 passed, 1 totalTests:       4 passed, 4 totalSnapshots:   0 totalTime:        2.437s, estimated 3sRan all test suites matching /pact.spec.js/i.

What does our provider have to say about this new test. Again, copy the updated pact file into the provider's pact directory and run the command:

โฏ npm run test:pact --prefix provider
[2020-01-14T11:11:51.390Z]  INFO: pact@9.5.0/3894: Verifying provider[2020-01-14T11:11:51.394Z]  INFO: pact-node@10.2.2/3894: Verifying Pacts.[2020-01-14T11:11:51.395Z]  INFO: pact-node@10.2.2/3894: Verifying Pact Files[2020-01-14T11:11:51.941Z]  WARN: pact@9.5.0/3894: No state handler found for "products exist", ignorning[2020-01-14T11:11:51.972Z]  WARN: pact@9.5.0/3894: No state handler found for "no products exist", ignorning[2020-01-14T11:11:51.982Z]  WARN: pact@9.5.0/3894: No state handler found for "product with ID 10 exists", ignorning[2020-01-14T11:11:51.989Z]  WARN: pact@9.5.0/3894: No state handler found for "product with ID 11 does not exist", ignorning FAIL  product/product.pact.test.js  Pact Verification    โœ• validates the expectations of ProductService (669ms)
  โ— Pact Verification โ€บ validates the expectations of ProductService
    WARN: Only the first item will be used to match the items in the array at $['body']
    INFO: Reading pact at pact-workshop-js/provider/pacts/frontendwebsite-productservice.json
    Verifying a pact between FrontendWebsite and ProductService
      Given products exist        get all products          with GET /products            returns a response which
              has status code 200
              has a matching body
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8"
      Given no products exist
        get all products
          with GET /products
            returns a response which
              has status code 200
              has a matching body (FAILED - 1)
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8"
      Given product with ID 10 exists
        get product with ID 10
          with GET /product/10
            returns a response which
              has status code 200
              has a matching body
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8"
      Given product with ID 11 does not exist
        get product with ID 11
          with GET /product/11
            returns a response which
              has status code 404 (FAILED - 2)

    Failures:
      1) Verifying a pact between FrontendWebsite and ProductService Given no products exist get all products with GET /products returns a response which has a matching body         Failure/Error: expect(response_body).to match_term expected_response_body, diff_options, example
           Actual: [{"id":"09","type":"CREDIT_CARD","name":"Gem Visa","version":"v1"},{"id":"10","type":"CREDIT_CARD","name":"28 Degrees","version":"v1"},{"id":"11","type":"PERSONAL_LOAN","name":"MyFlexiPay","version":"v2"}]
           Diff           --------------------------------------           Key: - is expected                + is actual           Matching keys and values are not shown
           -[,           -           +[           +  {           +    "id": "09",           +    "type": "CREDIT_CARD",           +    "name": "Gem Visa",           +    "version": "v1"           +  },           +  {           +    "id": "10",           +    "type": "CREDIT_CARD",           +    "name": "28 Degrees",           +    "version": "v1"           +  },           +  {           +    "id": "11",           +    "type": "PERSONAL_LOAN",           +    "name": "MyFlexiPay",           +    "version": "v2"           +  },            ]
           Description of differences           --------------------------------------           * Actual array is too long and should not contain a Hash at $[0]           * Actual array is too long and should not contain a Hash at $[1]           * Actual array is too long and should not contain a Hash at $[2]
      2) Verifying a pact between FrontendWebsite and ProductService Given product with ID 11 does not exist get product with ID 11 with GET /product/11 returns a response which has status code 404         Failure/Error: expect(response_status).to eql expected_response_status
           expected: 404                got: 200
           (compared using eql?)

    4 interactions, 2 failures
    Failed interactions:
    * Get all products given no products exist
    * Get product with id 11 given product with ID 11 does not exist
      at ChildProcess.<anonymous> (node_modules/@pact-foundation/pact-node/src/verifier.ts:194:58)
Test Suites: 1 failed, 1 totalTests:       1 failed, 1 totalSnapshots:   0 totalTime:        2.044sRan all test suites.[2020-01-14T11:11:52.052Z]  WARN: pact-node@10.2.2/3894: Pact exited with code 1.npm ERR! Test failed.  See above for more details.

We expected this failure, because the product we are requesing does in fact exist! What we want to test for, is what happens if there is a different state on the Provider. This is what is referred to as "Provider states", and how Pact gets around test ordering and related issues.

We could resolve this by updating our consumer test to use a known non-existent product, but it's worth understanding how Provider states work more generally.

Move on to step 7

Step 7 - Adding the missing states#

Our code already deals with missing users and sends a 404 response, however our test data fixture always has product ID 10 and 11 in our database.

In this step, we will add a state handler (stateHandlers) to our provider Pact verifications, which will update the state of our data store depending on which states the consumers require.

States are invoked prior to the actual test function is invoked. You can see the full lifecycle here.

We're going to add handlers for all our states:

  • products exist
  • no products exist
  • product with ID 10 exists
  • product with ID 11 does not exist

Let's open up our provider Pact verifications in provider/product/product.pact.test.js:

// add this to the Verifier optsstateHandlers: {  "product with ID 10 exists": () => {    controller.repository.products = new Map([      ["10", new Product("10", "CREDIT_CARD", "28 Degrees", "v1")]    ]);  },  "products exist": () => {    controller.repository.products = new Map([      ["09", new Product("09", "CREDIT_CARD", "Gem Visa", "v1")],      ["10", new Product("10", "CREDIT_CARD", "28 Degrees", "v1")]    ]);  },  "no products exist": () => {    controller.repository.products = new Map();  },  "product with ID 11 does not exist": () => {    controller.repository.products = new Map();  },}

Let's see how we go now:

โฏ npm run test:pact --prefix provider
[2020-01-14T11:25:30.775Z]  INFO: pact@9.5.0/4386: Verifying provider[2020-01-14T11:25:30.779Z]  INFO: pact-node@10.2.2/4386: Verifying Pacts.[2020-01-14T11:25:30.780Z]  INFO: pact-node@10.2.2/4386: Verifying Pact Files PASS  product/product.pact.test.js  Pact Verification    โœ“ validates the expectations of ProductService (669ms)
Test Suites: 1 passed, 1 totalTests:       1 passed, 1 totalSnapshots:   0 totalTime:        2.137sRan all test suites.[2020-01-14T11:25:31.442Z]  INFO: pact-node@10.2.2/4386: Pact Verification succeeded.

NOTE: The states are not necessarily a 1 to 1 mapping with the consumer contract tests. You can reuse states amongst different tests. In this scenario we could have used no products exist for both tests which would have equally been valid.

Move on to step 8

Step 8 - Authorization#

It turns out that not everyone should be able to use the API. After a discussion with the team, it was decided that a time-bound bearer token would suffice. The token must be in yyyy-MM-ddTHHmm format and within 1 hour of the current time.

In the case a valid bearer token is not provided, we expect a 401. Let's update the consumer to pass the bearer token, and capture this new 401 scenario.

In consumer/src/api.js:

    generateAuthToken() {        return "Bearer " + new Date().toISOString()    }
    async getAllProducts() {        return axios.get(this.withPath("/products"), {            headers: {                "Authorization": this.generateAuthToken()            }        })            .then(r => r.data);    }
    async getProduct(id) {        return axios.get(this.withPath("/product/" + id), {            headers: {                "Authorization": this.generateAuthToken()            }        })            .then(r => r.data);    }

In consumer/src/api.pact.spec.js:

import path from "path";import {Pact} from "@pact-foundation/pact";import {API} from "./api";import {eachLike, like} from "@pact-foundation/pact/dsl/matchers";
const provider = new Pact({    consumer: 'FrontendWebsite',    provider: 'ProductService',    log: path.resolve(process.cwd(), 'logs', 'pact.log'),    logLevel: "warn",    dir: path.resolve(process.cwd(), 'pacts'),    spec: 2});
describe("API Pact test", () => {

    beforeAll(() => provider.setup());    afterEach(() => provider.verify());    afterAll(() => provider.finalize());
    describe("getting all products", () => {        test("products exists", async () => {
            // set up Pact interactions            await provider.addInteraction({                state: 'products exist',                uponReceiving: 'get all products',                withRequest: {                    method: 'GET',                    path: '/products',                    headers: {                        "Authorization": like("Bearer 2019-01-14T11:34:18.045Z")                    }                },                willRespondWith: {                    status: 200,                    headers: {                        'Content-Type': 'application/json; charset=utf-8'                    },                    body: eachLike({                        id: "09",                        type: "CREDIT_CARD",                        name: "Gem Visa"                    }),                },            });
            const api = new API(provider.mockService.baseUrl);
            // make request to Pact mock server            const product = await api.getAllProducts();
            expect(product).toStrictEqual([                {"id": "09", "name": "Gem Visa", "type": "CREDIT_CARD"}            ]);        });
        test("no products exists", async () => {
            // set up Pact interactions            await provider.addInteraction({                state: 'no products exist',                uponReceiving: 'get all products',                withRequest: {                    method: 'GET',                    path: '/products',                    headers: {                        "Authorization": like("Bearer 2019-01-14T11:34:18.045Z")                    }                },                willRespondWith: {                    status: 200,                    headers: {                        'Content-Type': 'application/json; charset=utf-8'                    },                    body: []                },            });
            const api = new API(provider.mockService.baseUrl);
            // make request to Pact mock server            const product = await api.getAllProducts();
            expect(product).toStrictEqual([]);        });
        test("no auth token", async () => {
            // set up Pact interactions            await provider.addInteraction({                state: 'products exist',                uponReceiving: 'get all products with no auth token',                withRequest: {                    method: 'GET',                    path: '/products'                },                willRespondWith: {                    status: 401                },            });
            const api = new API(provider.mockService.baseUrl);
            // make request to Pact mock server            await expect(api.getAllProducts()).rejects.toThrow("Request failed with status code 401");        });    });
    describe("getting one product", () => {        test("ID 10 exists", async () => {
            // set up Pact interactions            await provider.addInteraction({                state: 'product with ID 10 exists',                uponReceiving: 'get product with ID 10',                withRequest: {                    method: 'GET',                    path: '/product/10',                    headers: {                        "Authorization": like("Bearer 2019-01-14T11:34:18.045Z")                    }                },                willRespondWith: {                    status: 200,                    headers: {                        'Content-Type': 'application/json; charset=utf-8'                    },                    body: like({                        id: "10",                        type: "CREDIT_CARD",                        name: "28 Degrees"                    }),                },            });
            const api = new API(provider.mockService.baseUrl);
            // make request to Pact mock server            const product = await api.getProduct("10");
            expect(product).toStrictEqual({                id: "10",                type: "CREDIT_CARD",                name: "28 Degrees"            });        });
        test("product does not exist", async () => {
            // set up Pact interactions            await provider.addInteraction({                state: 'product with ID 11 does not exist',                uponReceiving: 'get product with ID 11',                withRequest: {                    method: 'GET',                    path: '/product/11',                    headers: {                        "Authorization": like("Bearer 2019-01-14T11:34:18.045Z")                    }                },                willRespondWith: {                    status: 404                },            });
            const api = new API(provider.mockService.baseUrl);
            // make request to Pact mock server            await expect(api.getProduct("11")).rejects.toThrow("Request failed with status code 404");        });
        test("no auth token", async () => {
            // set up Pact interactions            await provider.addInteraction({                state: 'product with ID 10 exists',                uponReceiving: 'get product by ID 10 with no auth token',                withRequest: {                    method: 'GET',                    path: '/product/10'                },                willRespondWith: {                    status: 401                },            });
            const api = new API(provider.mockService.baseUrl);
            // make request to Pact mock server            await expect(api.getProduct("10")).rejects.toThrow("Request failed with status code 401");        });    });});

Generate a new Pact file:

โฏ npm run test:pact --prefix consumer
PASS src/api.pact.spec.js  API Pact test    getting all products      โœ“ products exists (23ms)      โœ“ no products exists (13ms)      โœ“ no auth token (14ms)    getting one product      โœ“ ID 10 exists (12ms)      โœ“ product does not exist (12ms)      โœ“ no auth token (14ms)
Test Suites: 1 passed, 1 totalTests:       6 passed, 6 totalSnapshots:   0 totalTime:        2.469s, estimated 3sRan all test suites matching /pact.spec.js/i.

We should now have two new interactions in our pact file.

Let's test the provider. Copy the updated pact file into the provider's pact directory and run the command:

โฏ npm run test:pact --prefix provider
[2020-01-14T11:42:56.479Z]  INFO: pact@9.5.0/5247: Verifying provider[2020-01-14T11:42:56.483Z]  INFO: pact-node@10.2.2/5247: Verifying Pacts.[2020-01-14T11:42:56.484Z]  INFO: pact-node@10.2.2/5247: Verifying Pact Files FAIL  product/product.pact.test.js  Pact Verification    โœ• validates the expectations of ProductService (667ms)
  โ— Pact Verification โ€บ validates the expectations of ProductService
    WARN: Only the first item will be used to match the items in the array at $['body']
    INFO: Reading pact at pact-workshop-js/provider/pacts/frontendwebsite-productservice.json
    Verifying a pact between FrontendWebsite and ProductService
      Given products exist        get all products          with GET /products            returns a response which
              has status code 200
              has a matching body
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8"
      Given no products exist
        get all products
          with GET /products
            returns a response which
              has status code 200
              has a matching body
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8"
      Given products exist
        get all products with no auth token
          with GET /products
            returns a response which
              has status code 401 (FAILED - 1)      Given product with ID 10 exists        get product with ID 10          with GET /product/10            returns a response which
              has status code 200
              has a matching body
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8"
      Given product with ID 11 does not exist
        get product with ID 11
          with GET /product/11
            returns a response which
              has status code 404
      Given product with ID 10 exists
        get product by ID 10 with no auth token
          with GET /product/10
            returns a response which
              has status code 401 (FAILED - 2)

    Failures:
      1) Verifying a pact between FrontendWebsite and ProductService Given products exist get all products with no auth token with GET /products returns a response which has status code 401         Failure/Error: expect(response_status).to eql expected_response_status
           expected: 401                got: 200
           (compared using eql?)
      2) Verifying a pact between FrontendWebsite and ProductService Given product with ID 10 exists get product by ID 10 with no auth token with GET /product/10 returns a response which has status code 401         Failure/Error: expect(response_status).to eql expected_response_status
           expected: 401                got: 200
           (compared using eql?)

    6 interactions, 2 failures
    Failed interactions:
    * Get all products with no auth token given products exist
    * Get product by id 10 with no auth token given product with ID 10 exists
      at ChildProcess.<anonymous> (node_modules/@pact-foundation/pact-node/src/verifier.ts:194:58)
Test Suites: 1 failed, 1 totalTests:       1 failed, 1 totalSnapshots:   0 totalTime:        2.046sRan all test suites.[2020-01-14T11:42:57.139Z]  WARN: pact-node@10.2.2/5247: Pact exited with code 1.npm ERR! Test failed.  See above for more details.

Now with the most recently added interactions where we are expecting a response of 401 when no authorization header is sent, we are getting 200...

Move on to step 9*

Step 9 - Implement authorisation on the provider#

We will add a middleware to check the Authorization header and deny the request with 401 if the token is older than 1 hour.

In provider/middleware/auth.middleware.js

// 'Token' should be a valid ISO 8601 timestamp within the last hourconst isValidAuthTimestamp = (timestamp) => {    let diff = (new Date() - new Date(timestamp)) / 1000;    return diff >= 0 && diff <= 3600};
const authMiddleware = (req, res, next) => {    if (!req.headers.authorization) {        return res.status(401).json({ error: "Unauthorized" });    }    const timestamp = req.headers.authorization.replace("Bearer ", "")    if (!isValidAuthTimestamp(timestamp)) {        return res.status(401).json({ error: "Unauthorized" });    }    next();};
module.exports = authMiddleware;

In provider/server.js

const authMiddleware = require('./middleware/auth.middleware');
// add this into your init functionapp.use(authMiddleware);

We also need to add the middleware to the server our Pact tests use.

In provider/product/product.pact.test.js:

const authMiddleware = require('../middleware/auth.middleware');app.use(authMiddleware);

This means that a client must present an HTTP Authorization header that looks as follows:

Authorization: Bearer 2006-01-02T15:04

Let's test this out:

โฏ npm run test:pact --prefix provider
[2020-01-14T11:49:31.048Z]  INFO: pact@9.5.0/5759: Verifying provider[2020-01-14T11:49:31.051Z]  INFO: pact-node@10.2.2/5759: Verifying Pacts.[2020-01-14T11:49:31.052Z]  INFO: pact-node@10.2.2/5759: Verifying Pact Files FAIL  product/product.pact.test.js  Pact Verification    โœ• validates the expectations of ProductService (679ms)
  โ— Pact Verification โ€บ validates the expectations of ProductService
    WARN: Only the first item will be used to match the items in the array at $['body']
    INFO: Reading pact at pact-workshop-js/provider/pacts/frontendwebsite-productservice.json
    Verifying a pact between FrontendWebsite and ProductService
      Given products exist        get all products          with GET /products            returns a response which
              has status code 200 (FAILED - 1)
              has a matching body (FAILED - 2)
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8"
      Given no products exist
        get all products
          with GET /products
            returns a response which
              has status code 200 (FAILED - 3)
              has a matching body (FAILED - 4)
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8"
      Given products exist
        get all products with no auth token
          with GET /products
            returns a response which

              has status code 401
      Given product with ID 10 exists
        get product with ID 10
          with GET /product/10
            returns a response which
              has status code 200 (FAILED - 5)
              has a matching body (FAILED - 6)
              includes headers
                "Content-Type" which equals "application/json; charset=utf-8"
      Given product with ID 11 does not exist
        get product with ID 11
          with GET /product/11
            returns a response which
              has status code 404 (FAILED - 7)
      Given product with ID 10 exists
        get product by ID 10 with no auth token
          with GET /product/10
            returns a response which
              has status code 401

    Failures:
      1) Verifying a pact between FrontendWebsite and ProductService Given products exist get all products with GET /products returns a response which has status code 200         Failure/Error: expect(response_status).to eql expected_response_status
           expected: 200                got: 401
           (compared using eql?)
      2) Verifying a pact between FrontendWebsite and ProductService Given products exist get all products with GET /products returns a response which has a matching body         Failure/Error: expect(response_body).to match_term expected_response_body, diff_options, example
           Actual: {"error":"Unauthorized"}
           Diff           --------------------------------------           Key: - is expected                + is actual           Matching keys and values are not shown
           -[           -  {           -    "id": "09",           -    "type": "CREDIT_CARD",           -    "name": "Gem Visa"           -  },           -  {           -    "id": "09",           -    "type": "CREDIT_CARD",           -    "name": "Gem Visa"           -  },           -]           +{           +  "error": "Unauthorized"           +}

           Description of differences           --------------------------------------           * Expected an Array (like [{"id"=>"09", "type"=>"CREDIT_CARD", "name"=>"Gem Visa"}, {"id"=>"09", "type"=>"CREDIT_CARD", "name"=>"Gem Visa"}]) but got a Hash at $
      3) Verifying a pact between FrontendWebsite and ProductService Given no products exist get all products with GET /products returns a response which has status code 200         Failure/Error: expect(response_status).to eql expected_response_status
           expected: 200                got: 401
           (compared using eql?)
      4) Verifying a pact between FrontendWebsite and ProductService Given no products exist get all products with GET /products returns a response which has a matching body         Failure/Error: expect(response_body).to match_term expected_response_body, diff_options, example
           Actual: {"error":"Unauthorized"}
           Diff           --------------------------------------           Key: - is expected                + is actual           Matching keys and values are not shown
           -[,           -           -]           +{           +  "error": "Unauthorized"           +}

           Description of differences           --------------------------------------           * Expected an Array but got a Hash at $
      5) Verifying a pact between FrontendWebsite and ProductService Given product with ID 10 exists get product with ID 10 with GET /product/10 returns a response which has status code 200         Failure/Error: expect(response_status).to eql expected_response_status
           expected: 200                got: 401
           (compared using eql?)
      6) Verifying a pact between FrontendWebsite and ProductService Given product with ID 10 exists get product with ID 10 with GET /product/10 returns a response which has a matching body         Failure/Error: expect(response_body).to match_term expected_response_body, diff_options, example
           Actual: {"error":"Unauthorized"}
           Diff           --------------------------------------           Key: - is expected                + is actual           Matching keys and values are not shown
            {           -  "id": String,           -  "type": String,           -  "name": String            }
           Description of differences           --------------------------------------           * Could not find key "id" (keys present are: error) at $           * Could not find key "type" (keys present are: error) at $           * Could not find key "name" (keys present are: error) at $
      7) Verifying a pact between FrontendWebsite and ProductService Given product with ID 11 does not exist get product with ID 11 with GET /product/11 returns a response which has status code 404         Failure/Error: expect(response_status).to eql expected_response_status
           expected: 404                got: 401
           (compared using eql?)

    6 interactions, 4 failures
    Failed interactions:

    * Get all products given products exist
    * Get all products given no products exist    * Get product with id 10 given product with ID 10 exists    * Get product with id 11 given product with ID 11 does not exist
      at ChildProcess.<anonymous> (node_modules/@pact-foundation/pact-node/src/verifier.ts:194:58)
Test Suites: 1 failed, 1 totalTests:       1 failed, 1 totalSnapshots:   0 totalTime:        1.988s, estimated 2sRan all test suites.[2020-01-14T11:49:31.719Z]  WARN: pact-node@10.2.2/5759: Pact exited with code 1.npm ERR! Test failed.  See above for more details.

Oh, dear. More tests are failing. Can you understand why?

Move on to step 10

Step 10 - Request Filters on the Provider#

Because our pact file has static data in it, our bearer token is now out of date, so when Pact verification passes it to the Provider we get a 401. There are multiple ways to resolve this - mocking or stubbing out the authentication component is a common one. In our use case, we are going to use a process referred to as Request Filtering, using a RequestFilter.

NOTE: This is an advanced concept and should be used carefully, as it has the potential to invalidate a contract by bypassing its constraints. See https://github.com/DiUS/pact-jvm/blob/master/provider/junit/README.md#modifying-the-requests-before-they-are-sent for more details on this.

The approach we are going to take to inject the header is as follows:

  1. If we receive any Authorization header, we override the incoming request with a valid (in time) Authorization header, and continue with whatever call was being made
  2. If we don't receive an Authorization header, we do nothing

NOTE: We are not considering the 403 scenario in this example.

In provider/product/product.pact.test.js:

// add this to the Verifier optsrequestFilter: (req, res, next) => {  if (!req.headers["authorization"]) {    next();    return;  }  req.headers["authorization"] = `Bearer ${ new Date().toISOString() }`;  next();},

We can now run the Provider tests

โฏ npm run test:pact --prefix provider
[2020-01-14T11:58:57.933Z]  INFO: pact@9.5.0/6636: Verifying provider[2020-01-14T11:58:57.937Z]  INFO: pact-node@10.2.2/6636: Verifying Pacts.[2020-01-14T11:58:57.938Z]  INFO: pact-node@10.2.2/6636: Verifying Pact Files PASS  product/product.pact.test.js  Pact Verification    โœ“ validates the expectations of ProductService (626ms)
Test Suites: 1 passed, 1 totalTests:       1 passed, 1 totalSnapshots:   0 totalTime:        2.094sRan all test suites.[2020-01-14T11:58:58.557Z]  INFO: pact-node@10.2.2/6636: Pact Verification succeeded.

Move on to step 11

Step 11 - Using a Pact Broker#

Broker collaboration Workflow

We've been publishing our pacts from the consumer project by essentially sharing the file system with the provider. But this is not very manageable when you have multiple teams contributing to the code base, and pushing to CI. We can use a Pact Broker to do this instead.

Using a broker simplifies the management of pacts and adds a number of useful features, including some safety enhancements for continuous delivery which we'll see shortly.

In this workshop we will be using the open source Pact broker.

Running the Pact Broker with docker-compose#

In the root directory, run:

docker-compose up

Publish contracts from consumer#

First, in the consumer project we need to tell Pact about our broker.

In consumer/publish.pact.js:

const pact = require('@pact-foundation/pact-node');const path = require('path');
if (!process.env.CI && !process.env.PUBLISH_PACT) {    console.log("skipping Pact publish...");    return}
let pactBrokerUrl = process.env.PACT_BROKER_URL || 'http://localhost:8000';let pactBrokerUsername = process.env.PACT_BROKER_USERNAME || 'pact_workshop';let pactBrokerPassword = process.env.PACT_BROKER_PASSWORD || 'pact_workshop';
const gitHash = require('child_process')    .execSync('git rev-parse --short HEAD')    .toString().trim();
const opts = {    pactFilesOrDirs: [path.resolve(__dirname, './pacts/')],    pactBroker: pactBrokerUrl,    pactBrokerUsername: pactBrokerUsername,    pactBrokerPassword: pactBrokerPassword,    tags: ['prod', 'test'],    consumerVersion: gitHash};
pact    .publishPacts(opts)    .then(() => {        console.log('Pact contract publishing complete!');        console.log('');        console.log(`Head over to ${pactBrokerUrl} and login with`);        console.log(`=> Username: ${pactBrokerUsername}`);        console.log(`=> Password: ${pactBrokerPassword}`);        console.log('to see your published contracts.')    })    .catch(e => {        console.log('Pact contract publishing failed: ', e)    });

Now add this to consumer/package.json:

// add this under scripts"posttest:pact": "node publish.pact.js",

Now run

โฏ CI=true npm run test:pact --prefix consumer
PASS src/api.pact.spec.js  API Pact test    getting all products      โœ“ products exists (22ms)      โœ“ no products exists (12ms)      โœ“ no auth token (13ms)    getting one product      โœ“ ID 10 exists (11ms)      โœ“ product does not exist (12ms)      โœ“ no auth token (14ms)
Test Suites: 1 passed, 1 totalTests:       6 passed, 6 totalSnapshots:   0 totalTime:        2.653sRan all test suites matching /pact.spec.js/i.
[2020-01-14T12:27:49.592Z]  INFO: pact-node@10.2.4/10405: Publishing Pacts to Broker[2020-01-14T12:27:49.593Z]  INFO: pact-node@10.2.4/10405: Publishing pacts to broker at: http://localhost:8000[2020-01-14T12:27:50.164Z]  INFO: pact-node@10.2.4/10405:
    Tagging version fe0b6a3 of FrontendWebsite as "prod"    Tagging version fe0b6a3 of FrontendWebsite as "test"    Publishing FrontendWebsite/ProductService pact to pact broker at http://localhost:8000    The given version of pact is already published. Overwriting...    The latest version of this pact can be accessed at the following URL (use this to configure the provider verification):    http://localhost:8000/pacts/provider/ProductService/consumer/FrontendWebsite/latest

Pact contract publishing complete!
Head over to http://localhost:8000 and login with=> Username: pact_workshop=> Password: pact_workshopto see your published contracts.

Have a browse around the broker on http://localhost:8000 (with username/password: pact_workshop/pact_workshop) and see your newly published contract!

Verify contracts on Provider#

All we need to do for the provider is update where it finds its pacts, from local URLs, to one from a broker.

In provider/product/product.pact.test.js:

//replacepactUrls: [  path.resolve(__dirname, '../pacts/frontendwebsite-productservice.json')],
// withpactBrokerUrl: process.env.PACT_BROKER_URL || "http://localhost:8000",pactBrokerUsername: process.env.PACT_BROKER_USERNAME || "pact_workshop",pactBrokerPassword: process.env.PACT_BROKER_PASSWORD || "pact_workshop",
// addif (process.env.CI || process.env.PACT_PUBLISH_RESULTS) {  Object.assign(opts, {    publishVerificationResult: true,  });}
// beforereturn new Verifier(opts).verifyProvider().finally(() => {

Let's run the provider verification one last time after this change:

โฏ PACT_PUBLISH_RESULTS=true npm run test:pact --prefix provider
[2020-01-14T12:34:08.157Z]  INFO: pact@9.5.0/10742: Verifying provider[2020-01-14T12:34:08.161Z]  INFO: pact-node@10.2.2/10742: Verifying Pacts.[2020-01-14T12:34:08.161Z]  INFO: pact-node@10.2.2/10742: Verifying Pact Files PASS  product/product.pact.test.js  Pact Verification    โœ“ validates the expectations of ProductService (682ms)
Test Suites: 1 passed, 1 totalTests:       1 passed, 1 totalSnapshots:   0 totalTime:        1.99s, estimated 2sRan all test suites.[2020-01-14T12:34:08.837Z]  INFO: pact-node@10.2.2/10742: Pact Verification succeeded.

As part of this process, the results of the verification - the outcome (boolean) and the detailed information about the failures at the interaction level - are published to the Broker also.

This is one of the Broker's more powerful features. Referred to as Verifications, it allows providers to report back the status of a verification to the broker. You'll get a quick view of the status of each consumer and provider on a nice dashboard. But, it is much more important than this!

Can I deploy?#

With just a simple use of the pact-broker can-i-deploy tool - the Broker will determine if a consumer or provider is safe to release to the specified environment.

You can run the pact-broker can-i-deploy checks as follows:

โฏ npx pact-broker can-i-deploy \               --pacticipant FrontendWebsite \               --broker-base-url http://localhost:8000 \               --broker-username pact_workshop \               --broker-password pact_workshop \               --latest
Computer says yes \o/
CONSUMER        | C.VERSION | PROVIDER       | P.VERSION | SUCCESS?----------------|-----------|----------------|-----------|---------FrontendWebsite | fe0b6a3   | ProductService | 1.0.0     | true
All required verification results are published and successful
----------------------------
โฏ npx pact-broker can-i-deploy \                --pacticipant ProductService \                --broker-base-url http://localhost:8000 \                --broker-username pact_workshop \                --broker-password pact_workshop \                --latest
Computer says yes \o/
CONSUMER        | C.VERSION | PROVIDER       | P.VERSION | SUCCESS?----------------|-----------|----------------|-----------|---------FrontendWebsite | fe0b6a3   | ProductService | 1.0.0     | true
All required verification results are published and successful

That's it - you're now a Pact pro. Go build ๐Ÿ”จ