(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.
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.
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.
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).
I have been thinking on this again the past couple of weeks. My current feels (summarised):