Skip to content

Instantly share code, notes, and snippets.

@TimothyJones
Last active May 9, 2022 16:00
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save TimothyJones/d138bf765509d41e0ce8a4bfb777a31d to your computer and use it in GitHub Desktop.
Save TimothyJones/d138bf765509d41e0ce8a4bfb777a31d to your computer and use it in GitHub Desktop.
Proposal to improve the Pact-JS DSL

Improving the Pact-JS DSL

(Thanks to Andras Bubics and Matt Fellows for many discussions leading to this proposal)

Test frameworks for Javascript are diverse - some run in parallel by default, some have different testing styles or expectations (eg BDD), and they all have different ways to configure and instrument the test framework.

The Pact workflow also includes a number of (necessary) assumptions and expectations - such as the need to keep interactions separate in the mock provider (meaning tests can't run in parallel) and the need to spin up the mock server at an appropriate time (meaning that the Pact file write mode needs to be set correctly for each framework). These don't always play nicely with the JS test framework.

This means that using Pact effectively (or sometimes at all) requires extra understanding of how Pact and your chosen test framework work and interact, beyond the necessary understanding to simply write a consumer test. It would be an improvement to change Pact workflow so that this additional understanding is only necessary in unusally complex setups.

Another potential source of confusion is that Pact's current implementation gets information and configuration from custom scripts called in different parts of the test and development lifecycle. This is unusual for node projects - most of which are configurable by their own named settings files or a key in package.json.

It would be an improvement to change to a more idomatic node configuration method. This would also have the advantage that different test frameworks would still have similar pact setups.

Lastly, Javascript Pact tests are very verbose with a lot of repetition. It would be an improvement to be able to write concise tests with less repetition.

Pitch 1: Node-idiomatic configuration

Currently, Pact is configured with a file that contains something like this:

// setup.js
global.provider = new Pact({
  port: global.port,
  log: path.resolve(process.cwd(), 'logs', 'mockserver-integration.log'),
  dir: path.resolve(process.cwd(), 'pacts'),
  spec: 2,
  pactfileWriteMode: 'update',
  consumer: 'MyConsumer',
  provider: 'MyProvider'
});

Instead, this could be moved to the package.json (or .pactrc or similar):

  // package.json
  "pact": {
    "port": 8989,
    "log": "logs/mockserver-integration.log",
    "pactDir": "pacts",
    "specVersion": 2,
    "pactfileWriteMode": "update",
    "pacts": {
      "consumer": "MyConsumer",
      "provider": "MyProvider"
    }
  }

Which the library uses by default:

// setup.js
global.provider = new Pact()

If multiple providers and consumers were present:

// package.json
"pacts": [
  {
    "consumer": "MyConsumer",
    "provider": "MyProvider"
  },
  {
    "consumer": "Other Consumer",
    "provider": "Other Provider"
  }
]

Then the provider could be initialised like so:

// setup.js
global.provider = new Pact({
    "consumer": "MyConsumer",
    "provider": "MyProvider"
})

A common case is multiple providers, but one consumer, which could be represented like so:

// package.json
"consumer": "MyConsumer",
"pacts": [
  "MyProvider",
  "Other Provider"
]
// setup.js
global.provider = new Pact("MyProvider");

Allowing all three methods of configuring the consumer/provider combinations would provide easy flexibile configuration, and a standard, node-idiomatic way to configure Pact in JS.

Separating configuration and initialisation of the mock provider allows Pact more control over when the providers are started, which enables the next pitch in this document.

Pitch 2: Remove the need for a global provider

Currently, the pact provider is initalised in some setup file, and usually assigned to a global variable. This prevents tests from running in parallel, because you need to ensure that only one interaction on that provider is being tested at once. It also means that the pact file merging method needs to be selected based on how often the setup/teardown will run (which changes based on the test framework).

A test currently looks like this:

      describe("Dog's API", () => {
        let url = 'http://localhost'

        const EXPECTED_BODY = [{
          dog: 1
        }]

        describe("works", () => {
          beforeEach(() => {
            const interaction = {
              state: 'i have a list of projects',
              uponReceiving: 'a request for projects',
              withRequest: {
                method: 'GET',
                path: '/dogs',
                headers: {
                  'Accept': 'application/json'
                }
              },
              willRespondWith: {
                status: 200,
                headers: {
                  'Content-Type': 'application/json'
                },
                body: EXPECTED_BODY
              }
            }
            return provider.addInteraction(interaction)
          })

          // add expectations
          it('returns a sucessful body', done => {
            return getMeDogs({
                url,
                port
              })
              .then(response => {
                expect(response.headers['content-type']).toEqual('application/json')
                expect(response.data).toEqual(EXPECTED_BODY)
                expect(response.status).toEqual(200)
                done()
              })
              .then(() => provider.verify());
          })
        })
      })

A DSL without the need for a global provider could look like this:

    const pact = require('@pact-foundation/pact')

    describe("Dog's API", () => {
      const EXPECTED_BODY = [{
        dog: 1
      }]

      describe("works", () => {
        let interaction;

        beforeEach(() => {
          interaction = pact.interaction({
            state: 'i have a list of projects',
            uponReceiving: 'a request for projects',
            withRequest: {
              method: 'GET',
              path: '/dogs',
              headers: {
                'Accept': 'application/json'
              }
            },
            willRespondWith: {
              status: 200,
              headers: {
                'Content-Type': 'application/json'
              },
              body: EXPECTED_BODY
            }
          })
          return interaction.ready() 
        })

        // add expectations
        it('returns a sucessful body', done => {
          return getMeDogs({
              interaction.url,
              interaction.port
            })
            .then(response => {
              expect(response.headers['content-type']).toEqual('application/json')
              expect(response.data).toEqual(EXPECTED_BODY)
              expect(response.status).toEqual(200)
              done()
            })
            .then(() => interaction.verify());
        })
      })
    })

In the case of multiple providers, interactions could be specified like so:

    interaction = pact.interaction({
       consumer: "MyConsumer",
       provider: "MyProvider",
       state: 'i have a list of projects',
       ...

or even:

    const { pactFor } = require('@pact-foundation/pact')
    const pact = pactFor({ consumer: "MyConsumer", provider: "MyProvider" });
    
    ....
      interaction = pact.interaction({...})

There could be several different ways to handle the endpoints, allowing flexible configuration of client APIs:

     { port: interaction.port , url: interaction.url }
     interaction.baseUrl

Overall, this DSL would:

  • Obviate the need for setup (since you don't need to pass around a provider)
  • Allow Pact to serve mocks for each interaction, allowing parallel execution of tests
  • Remove the need to know how to update the pact file (unless there's an unusually complex project with pacts specified in more than one test framework).
  • Remove the need to understand how Pact sets up and tears down mock servers.

It would require a single teardown step to collate the interactions and write the pact file - but that could be handled by the library or test framework specific modules.

Pitch 3: DRY out the DSL

Some observations:

  • Pact tests tend to have a lot of repeat requests in different states - eg "given request X in state Y" and "given request X in state Z". This produces a lot of duplicate boilerplate.
  • Similarly, the way to trigger a specific request doesn't tend to change.
  • Pact tests always finish with provider.verify(). We could build that in to a more specific DSL as an assumed call.

Here is a proposal for a possible DSL:

    request(
      {
        name: 'a request for cats',
        method: 'GET',
        path: '/cats',
        headers: {
          Accept: 'application/json'
        },
        call: interaction =>
          getMeCats({
            url: interaction.url
          })
      },
      () => {
        const EXPECTED_BODY = [
          {
            cat: 2
          }
        ];
        response(
          {
            state: 'i have a list of cats',
            status: 200,
            headers: {
              'Content-Type': 'application/json'
            },
            body: EXPECTED_BODY
          },
          response => {
            expect(response.data).toEqual(EXPECTED_BODY);
          }
        );
        response(
          {
            state: 'there are no cats',
            status: 404,
            headers: {
              'Content-Type': 'application/json'
            }
          },
          response =>
            expect(response).resolves.toEqual({ error: 'There were no cats' })
        );
      }
    );

It's a big departure from the current model, but I think it produces a much cleaner test (It is 44 lines. For contrast, the same test written in the current DSL is 91 lines).

@mefellows
Copy link

I have been thinking on this again the past couple of weeks. My current feels (summarised):

  1. (1) Yes, something like this will be needed. We might still need a programmatic interface also to allow for hooks (e.g. for framework authors who might, say, be creating a Karma/Mocha/Jest/... adapter. )
  2. (2) Yes, if we can make this sort of interface for people I think it would go a great deal of the way to simplify Pacts' usage and make it easier for both newcomers and experienced users. There will be some edge cases around how people cleanup pacts and so on, and it would be a hard change to make backwards compatible (not just API-wise, but behaviour-wise also)
  3. (3) Personally, I'm not so sure it reads any better to me and deviates away from the BDD-style of the other languages. I think with the other simplifications the small amount of lines you might save from typing aren't that worth it. You should still be able to make your assertions a re-usable function anyway

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment