Use createProviderState, toJsonMap, setJsonContent, and setJsonBody from @seontechnologies/pactjs-utils to build type-safe provider state tuples and reusable PactV4 JSON callbacks for consumer contract tests. These helpers eliminate manual JsonMap casting and repetitive inline builder lambdas.
.given(stateName, params) requires params to be JsonMap — a flat object where every value must be string | number | boolean | null.given() calls: Repeating state name and params inline makes consumer tests harder to read(builder) => { ... } blocks for body/query/header setupcreateProviderState: Returns a [string, JsonMap] tuple that spreads directly into .given() — one function handles name and paramstoJsonMap: Explicit coercion rules documented and tested — Date→ISO string, null→”null” string, nested objects→JSON stringsetJsonContent: Curried callback helper for request/response builders — set query, headers, and/or body from one reusable functionsetJsonBody: Body-only shorthand for setJsonContent({ body }) — ideal for concise .willRespondWith(...) bodiesimport { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { createProviderState } from '@seontechnologies/pactjs-utils';
const provider = new PactV3({
consumer: 'movie-web',
provider: 'SampleMoviesAPI',
dir: './pacts',
});
describe('Movie API Contract', () => {
it('should return movie by id', async () => {
// createProviderState returns [stateName, JsonMap] tuple
const providerState = createProviderState({
name: 'movie with id 1 exists',
params: { id: 1, name: 'Inception', year: 2010 },
});
await provider
.given(...providerState) // Spread tuple into .given(name, params)
.uponReceiving('a request for movie 1')
.withRequest({ method: 'GET', path: '/movies/1' })
.willRespondWith({
status: 200,
body: MatchersV3.like({ id: 1, name: 'Inception', year: 2010 }),
})
.executeTest(async (mockServer) => {
const res = await fetch(`${mockServer.url}/movies/1`);
const movie = await res.json();
expect(movie.name).toBe('Inception');
});
});
});
Key Points:
createProviderState accepts { name: string, params: Record<string, unknown> }name and params are required (pass params: {} for states without parameters)[string, JsonMap] — spread with ... into .given()params values are automatically converted to JsonMap-compatible typesPactV3) and message (MessageConsumerPact) pactsimport { toJsonMap } from '@seontechnologies/pactjs-utils';
// toJsonMap conversion rules:
// - string, number, boolean → passed through
// - null → "null" (string)
// - undefined → "null" (string, same as null)
// - Date → ISO string (e.g., "2025-01-15T10:00:00.000Z")
// - nested object → JSON string
// - array → comma-separated string via String() (e.g., [1,2,3] → "1,2,3")
const params = toJsonMap({
id: 42,
name: 'John Doe',
active: true,
score: null,
createdAt: new Date('2025-01-15T10:00:00Z'),
metadata: { role: 'admin', permissions: ['read', 'write'] },
});
// Result:
// {
// id: 42,
// name: "John Doe",
// active: true,
// score: "null",
// createdAt: "2025-01-15T10:00:00.000Z",
// metadata: '{"role":"admin","permissions":["read","write"]}'
// }
Key Points:
toJsonMap is called internally by createProviderState — you rarely need it directlyimport { createProviderState } from '@seontechnologies/pactjs-utils';
// State without params — second tuple element is empty object
const emptyState = createProviderState({ name: 'no movies exist', params: {} });
// Returns: ['no movies exist', {}]
await provider
.given(...emptyState)
.uponReceiving('a request when no movies exist')
.withRequest({ method: 'GET', path: '/movies' })
.willRespondWith({ status: 200, body: [] })
.executeTest(async (mockServer) => {
const res = await fetch(`${mockServer.url}/movies`);
const movies = await res.json();
expect(movies).toEqual([]);
});
import { createProviderState } from '@seontechnologies/pactjs-utils';
// Some interactions require multiple provider states
// Call .given() multiple times with different states
await provider
.given(...createProviderState({ name: 'user is authenticated', params: { userId: 1 } }))
.given(...createProviderState({ name: 'movie with id 5 exists', params: { id: 5 } }))
.uponReceiving('an authenticated request for movie 5')
.withRequest({
method: 'GET',
path: '/movies/5',
headers: { Authorization: MatchersV3.like('Bearer token') },
})
.willRespondWith({ status: 200, body: MatchersV3.like({ id: 5 }) })
.executeTest(async (mockServer) => {
// test implementation
});
import { MatchersV3 } from '@pact-foundation/pact';
import { setJsonBody, setJsonContent } from '@seontechnologies/pactjs-utils';
const { integer, string } = MatchersV3;
await pact
.addInteraction()
.given('movie exists')
.uponReceiving('a request to get movie by name')
.withRequest(
'GET',
'/movies',
setJsonContent({
query: { name: 'Inception' },
headers: { Accept: 'application/json' },
}),
)
.willRespondWith(
200,
setJsonBody({
status: 200,
data: { id: integer(1), name: string('Inception') },
}),
);
Key Points:
setJsonContent when the interaction needs query, headers, and/or body in one callback (most request builders)setJsonBody when you only need jsonBody and want the shorter .willRespondWith(status, setJsonBody(...)) formsetJsonBody is equivalent to setJsonContent({ body: ... })addInteraction() per it() Block (PactV4 Determinism Rule)Context: PactV4’s pact.addInteraction() feeds the Rust FFI layer that writes interactions to the pact JSON. Chaining multiple .addInteraction()...executeTest() blocks inside a single it() — or otherwise registering multiple interactions before a single executeTest — causes the FFI to non-deterministically drop whole interactions (not individual fields) in roughly 1 out of N runs. The pattern passes locally, then fails intermittently in CI or at publish time with Cannot change pact content for already published pact once the dropped interaction reappears on a re-run.
Rule: Exactly one pact.addInteraction() per it() block. For N interactions, write N it() blocks, or use it.each(...).
// ❌ WRONG — two addInteraction() inside one it() — FFI non-deterministically drops one
it('handles movie lookup scenarios', async () => {
await pact
.addInteraction()
.given('movie exists')
.uponReceiving('a request to get movie by id')
.withRequest('GET', '/movies/1')
.willRespondWith(200, setJsonBody({ id: integer(1), name: string('The Matrix') }))
.executeTest(async (mockServer) => {
/* ... */
});
// Sometimes this second interaction never makes it to the pact JSON:
await pact
.addInteraction()
.given('no movies exist')
.uponReceiving('a request for an empty list')
.withRequest('GET', '/movies')
.willRespondWith(200, setJsonBody([]))
.executeTest(async (mockServer) => {
/* ... */
});
});
// ✅ RIGHT — one addInteraction() per it()
it('gets a movie by id', async () => {
await pact
.addInteraction()
.given('movie exists')
.uponReceiving('a request to get movie by id')
.withRequest('GET', '/movies/1')
.willRespondWith(200, setJsonBody({ id: integer(1), name: string('The Matrix') }))
.executeTest(async (mockServer) => {
/* ... */
});
});
it('returns empty list when no movies exist', async () => {
await pact
.addInteraction()
.given('no movies exist')
.uponReceiving('a request for an empty list')
.withRequest('GET', '/movies')
.willRespondWith(200, setJsonBody([]))
.executeTest(async (mockServer) => {
/* ... */
});
});
// ✅ RIGHT — parameterized via it.each for data-driven coverage
it.each([
{ id: 1, name: 'The Matrix' },
{ id: 2, name: 'Inception' },
])('gets movie $id', async ({ id, name }) => {
await pact
.addInteraction()
.given('movie exists', { id, name })
.uponReceiving(`a request to get movie ${id}`)
.withRequest('GET', `/movies/${id}`)
.willRespondWith(200, setJsonBody({ id: integer(id), name: string(name) }))
.executeTest(async (mockServer) => {
/* ... */
});
});
Key Points:
fileParallelism: false — prevents parallel workers racing on the shared pact JSON file; (2) pool: 'forks' with singleFork: true — required for pact JSON write safety across multiple files; (3) one .pacttest.ts per consumer+provider pair — singleFork: true keeps all files in one process, so two files for the same pair produce an FFI handle collision (“request was expected but not received” on Linux CI, sporadic); (4) one-interaction-per-it() (this rule) — prevents the FFI from dropping interactions within a single test body. See pact-consumer-framework-setup.md Example 10 for the file-organization ✅/❌ pattern.Cannot change pact content.PactV4) and message consumer pacts (MessageConsumerPact)....createProviderState() — the tuple spreads into .given(stateName, params){ name: string, params: Record<string, unknown> } input (both fields required)null becomes "null" string in JsonMap (Pact requirement)String() (e.g., [1,2,3] → "1,2,3") — prefer passing arrays as JSON-stringified objects for round-trip safetyMessageConsumerPact — same .given() APIsetJsonContent works for both .withRequest(...) and .willRespondWith(...) callbacks (query is ignored on response builders)setJsonBody keeps body-only responses concise and readablestring('My movie') means “any string”, integer(1) means “any integer”. The example values are arbitrary — the provider can return different values and verification still passes as long as the type matches. Use matchers only in .willRespondWith() (responses), never in .withRequest() (requests) — Postel’s Law applies.uponReceiving + .given(), not by placeholder values. Two test files can both use testId: 100 without conflicting. On the provider side, shared values simplify state handlers — idempotent handlers (check if exists, create if not) only need to ensure one record exists. Use different values only when testing different states of the same entity type (e.g., movieExists(100) for happy paths vs. movieNotFound(999) for error paths).addInteraction() per it() block (MANDATORY for PactV4): Multiple interactions inside one it() cause the Rust FFI to non-deterministically drop interactions. Use one it() per interaction or it.each(...) for parameterized cases. See Example 6.pactjs-utils-overview.md — installation, decision tree, design philosophypactjs-utils-provider-verifier.md — provider-side state handler implementation; same pool: 'forks' + singleFork: true rule as consumerpact-consumer-framework-setup.md — Vitest fileParallelism: false + pool: 'forks' + singleFork: true config and CI wiringcontract-testing.md — foundational patterns with raw Pact.js// ❌ Manual casting — verbose, error-prone, no type safety
provider.given('user exists', {
id: 1 as unknown as string,
createdAt: new Date().toISOString(),
metadata: JSON.stringify({ role: 'admin' }),
} as JsonMap);
// ✅ Automatic conversion with type safety
provider.given(
...createProviderState({
name: 'user exists',
params: { id: 1, createdAt: new Date(), metadata: { role: 'admin' } },
}),
);
// ❌ Duplicated state names between consumer and provider — easy to mismatch
provider.given('a user with id 1 exists', { id: '1' });
// Later in provider: 'user with id 1 exists' — different string!
// ✅ Define state names as constants shared between consumer and provider
const STATES = {
USER_EXISTS: 'user with id exists',
NO_USERS: 'no users exist',
} as const;
provider.given(...createProviderState({ name: STATES.USER_EXISTS, params: { id: 1 } }));
// ❌ Repetitive callback boilerplate in every interaction
.willRespondWith(200, (builder) => {
builder.jsonBody({ status: 200 });
});
// ✅ Reusable callbacks with less boilerplate
.withRequest('GET', '/movies', setJsonContent({ query: { name: 'Inception' } }))
.willRespondWith(200, setJsonBody({ status: 200 }));
addInteraction() in a single it()// ❌ PactV4 FFI non-deterministically drops one of these interactions ~1/N runs
it('handles both success and empty list', async () => {
await pact.addInteraction().uponReceiving('get movie').withRequest(/* ... */).executeTest(/* ... */);
await pact.addInteraction().uponReceiving('empty list').withRequest(/* ... */).executeTest(/* ... */);
});
addInteraction() per it() (or use it.each)// ✅ Deterministic pact JSON — FFI receives one interaction per test
it('gets a movie', async () => {
await pact
.addInteraction() /* ... */
.executeTest(/* ... */);
});
it('returns empty list', async () => {
await pact
.addInteraction() /* ... */
.executeTest(/* ... */);
});
See Example 6 above for the full rationale.
Source: @seontechnologies/pactjs-utils consumer-helpers module, pactjs-utils sample-app consumer tests