Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

pactjs-utils-consumer-helpers.md 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. # Pact.js Utils Consumer Helpers
  2. ## Principle
  3. 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.
  4. ## Rationale
  5. ### Problems with raw consumer helper handling
  6. - **JsonMap requirement**: Pact's `.given(stateName, params)` requires `params` to be `JsonMap` — a flat object where every value must be `string | number | boolean | null`
  7. - **Type gymnastics**: Complex params (Date objects, nested objects, null values) require manual casting that TypeScript can't verify
  8. - **Inconsistent serialization**: Different developers serialize the same data differently (e.g., dates as ISO strings vs timestamps)
  9. - **Verbose `.given()` calls**: Repeating state name and params inline makes consumer tests harder to read
  10. - **Repeated interaction callbacks**: PactV4 interactions duplicate inline `(builder) => { ... }` blocks for body/query/header setup
  11. ### Solutions
  12. - **`createProviderState`**: Returns a `[string, JsonMap]` tuple that spreads directly into `.given()` — one function handles name and params
  13. - **`toJsonMap`**: Explicit coercion rules documented and tested — Date→ISO string, null→"null" string, nested objects→JSON string
  14. - **`setJsonContent`**: Curried callback helper for request/response builders — set `query`, `headers`, and/or `body` from one reusable function
  15. - **`setJsonBody`**: Body-only shorthand for `setJsonContent({ body })` — ideal for concise `.willRespondWith(...)` bodies
  16. ## Pattern Examples
  17. ### Example 1: Basic Provider State Creation
  18. ```typescript
  19. import { PactV3, MatchersV3 } from '@pact-foundation/pact';
  20. import { createProviderState } from '@seontechnologies/pactjs-utils';
  21. const provider = new PactV3({
  22. consumer: 'movie-web',
  23. provider: 'SampleMoviesAPI',
  24. dir: './pacts',
  25. });
  26. describe('Movie API Contract', () => {
  27. it('should return movie by id', async () => {
  28. // createProviderState returns [stateName, JsonMap] tuple
  29. const providerState = createProviderState({
  30. name: 'movie with id 1 exists',
  31. params: { id: 1, name: 'Inception', year: 2010 },
  32. });
  33. await provider
  34. .given(...providerState) // Spread tuple into .given(name, params)
  35. .uponReceiving('a request for movie 1')
  36. .withRequest({ method: 'GET', path: '/movies/1' })
  37. .willRespondWith({
  38. status: 200,
  39. body: MatchersV3.like({ id: 1, name: 'Inception', year: 2010 }),
  40. })
  41. .executeTest(async (mockServer) => {
  42. const res = await fetch(`${mockServer.url}/movies/1`);
  43. const movie = await res.json();
  44. expect(movie.name).toBe('Inception');
  45. });
  46. });
  47. });
  48. ```
  49. **Key Points**:
  50. - `createProviderState` accepts `{ name: string, params: Record<string, unknown> }`
  51. - Both `name` and `params` are required (pass `params: {}` for states without parameters)
  52. - Returns `[string, JsonMap]` — spread with `...` into `.given()`
  53. - `params` values are automatically converted to JsonMap-compatible types
  54. - Works identically with HTTP (`PactV3`) and message (`MessageConsumerPact`) pacts
  55. ### Example 2: Complex Parameters with toJsonMap
  56. ```typescript
  57. import { toJsonMap } from '@seontechnologies/pactjs-utils';
  58. // toJsonMap conversion rules:
  59. // - string, number, boolean → passed through
  60. // - null → "null" (string)
  61. // - undefined → "null" (string, same as null)
  62. // - Date → ISO string (e.g., "2025-01-15T10:00:00.000Z")
  63. // - nested object → JSON string
  64. // - array → comma-separated string via String() (e.g., [1,2,3] → "1,2,3")
  65. const params = toJsonMap({
  66. id: 42,
  67. name: 'John Doe',
  68. active: true,
  69. score: null,
  70. createdAt: new Date('2025-01-15T10:00:00Z'),
  71. metadata: { role: 'admin', permissions: ['read', 'write'] },
  72. });
  73. // Result:
  74. // {
  75. // id: 42,
  76. // name: "John Doe",
  77. // active: true,
  78. // score: "null",
  79. // createdAt: "2025-01-15T10:00:00.000Z",
  80. // metadata: '{"role":"admin","permissions":["read","write"]}'
  81. // }
  82. ```
  83. **Key Points**:
  84. - `toJsonMap` is called internally by `createProviderState` — you rarely need it directly
  85. - Use it when you need explicit control over parameter conversion outside of provider states
  86. - Conversion rules are deterministic: same input always produces same output
  87. ### Example 3: Provider State Without Parameters
  88. ```typescript
  89. import { createProviderState } from '@seontechnologies/pactjs-utils';
  90. // State without params — second tuple element is empty object
  91. const emptyState = createProviderState({ name: 'no movies exist', params: {} });
  92. // Returns: ['no movies exist', {}]
  93. await provider
  94. .given(...emptyState)
  95. .uponReceiving('a request when no movies exist')
  96. .withRequest({ method: 'GET', path: '/movies' })
  97. .willRespondWith({ status: 200, body: [] })
  98. .executeTest(async (mockServer) => {
  99. const res = await fetch(`${mockServer.url}/movies`);
  100. const movies = await res.json();
  101. expect(movies).toEqual([]);
  102. });
  103. ```
  104. ### Example 4: Multiple Provider States
  105. ```typescript
  106. import { createProviderState } from '@seontechnologies/pactjs-utils';
  107. // Some interactions require multiple provider states
  108. // Call .given() multiple times with different states
  109. await provider
  110. .given(...createProviderState({ name: 'user is authenticated', params: { userId: 1 } }))
  111. .given(...createProviderState({ name: 'movie with id 5 exists', params: { id: 5 } }))
  112. .uponReceiving('an authenticated request for movie 5')
  113. .withRequest({
  114. method: 'GET',
  115. path: '/movies/5',
  116. headers: { Authorization: MatchersV3.like('Bearer token') },
  117. })
  118. .willRespondWith({ status: 200, body: MatchersV3.like({ id: 5 }) })
  119. .executeTest(async (mockServer) => {
  120. // test implementation
  121. });
  122. ```
  123. ### Example 5: When to Use setJsonBody vs setJsonContent
  124. ```typescript
  125. import { MatchersV3 } from '@pact-foundation/pact';
  126. import { setJsonBody, setJsonContent } from '@seontechnologies/pactjs-utils';
  127. const { integer, string } = MatchersV3;
  128. await pact
  129. .addInteraction()
  130. .given('movie exists')
  131. .uponReceiving('a request to get movie by name')
  132. .withRequest(
  133. 'GET',
  134. '/movies',
  135. setJsonContent({
  136. query: { name: 'Inception' },
  137. headers: { Accept: 'application/json' },
  138. }),
  139. )
  140. .willRespondWith(
  141. 200,
  142. setJsonBody({
  143. status: 200,
  144. data: { id: integer(1), name: string('Inception') },
  145. }),
  146. );
  147. ```
  148. **Key Points**:
  149. - Use `setJsonContent` when the interaction needs `query`, `headers`, and/or `body` in one callback (most request builders)
  150. - Use `setJsonBody` when you only need `jsonBody` and want the shorter `.willRespondWith(status, setJsonBody(...))` form
  151. - `setJsonBody` is equivalent to `setJsonContent({ body: ... })`
  152. ### Example 6: One `addInteraction()` per `it()` Block (PactV4 Determinism Rule)
  153. **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.
  154. **Rule**: Exactly one `pact.addInteraction()` per `it()` block. For N interactions, write N `it()` blocks, or use `it.each(...)`.
  155. ```typescript
  156. // ❌ WRONG — two addInteraction() inside one it() — FFI non-deterministically drops one
  157. it('handles movie lookup scenarios', async () => {
  158. await pact
  159. .addInteraction()
  160. .given('movie exists')
  161. .uponReceiving('a request to get movie by id')
  162. .withRequest('GET', '/movies/1')
  163. .willRespondWith(200, setJsonBody({ id: integer(1), name: string('The Matrix') }))
  164. .executeTest(async (mockServer) => {
  165. /* ... */
  166. });
  167. // Sometimes this second interaction never makes it to the pact JSON:
  168. await pact
  169. .addInteraction()
  170. .given('no movies exist')
  171. .uponReceiving('a request for an empty list')
  172. .withRequest('GET', '/movies')
  173. .willRespondWith(200, setJsonBody([]))
  174. .executeTest(async (mockServer) => {
  175. /* ... */
  176. });
  177. });
  178. // ✅ RIGHT — one addInteraction() per it()
  179. it('gets a movie by id', async () => {
  180. await pact
  181. .addInteraction()
  182. .given('movie exists')
  183. .uponReceiving('a request to get movie by id')
  184. .withRequest('GET', '/movies/1')
  185. .willRespondWith(200, setJsonBody({ id: integer(1), name: string('The Matrix') }))
  186. .executeTest(async (mockServer) => {
  187. /* ... */
  188. });
  189. });
  190. it('returns empty list when no movies exist', async () => {
  191. await pact
  192. .addInteraction()
  193. .given('no movies exist')
  194. .uponReceiving('a request for an empty list')
  195. .withRequest('GET', '/movies')
  196. .willRespondWith(200, setJsonBody([]))
  197. .executeTest(async (mockServer) => {
  198. /* ... */
  199. });
  200. });
  201. // ✅ RIGHT — parameterized via it.each for data-driven coverage
  202. it.each([
  203. { id: 1, name: 'The Matrix' },
  204. { id: 2, name: 'Inception' },
  205. ])('gets movie $id', async ({ id, name }) => {
  206. await pact
  207. .addInteraction()
  208. .given('movie exists', { id, name })
  209. .uponReceiving(`a request to get movie ${id}`)
  210. .withRequest('GET', `/movies/${id}`)
  211. .willRespondWith(200, setJsonBody({ id: integer(id), name: string(name) }))
  212. .executeTest(async (mockServer) => {
  213. /* ... */
  214. });
  215. });
  216. ```
  217. **Key Points**:
  218. - **This rule stacks with two other MANDATORY vitest settings**: `fileParallelism: false` AND `pool: 'forks'` with `poolOptions.forks.singleFork: true`. All three are required and address different failure modes — `fileParallelism: false` prevents parallel workers from racing on the shared pact JSON; `pool: 'forks'` + `singleFork: true` prevents the Pact Rust FFI from leaking state across files (manifests as "request was expected but not received" flakes on Linux CI only); one-interaction-per-`it()` prevents the FFI from dropping interactions within a single test body.
  219. - Symptom of violating this rule: the pact file is byte-different between otherwise-identical runs; `scripts/check-pact-determinism.sh` flags drift; PactFlow rejects a republish with `Cannot change pact content`.
  220. - The rule applies to both HTTP consumer pacts (`PactV4`) and message consumer pacts (`MessageConsumerPact`).
  221. - See `pact-consumer-framework-setup.md` Example 10 for the determinism gate that automatically catches violations of this rule.
  222. ## Key Points
  223. - **Spread pattern**: Always use `...createProviderState()` — the tuple spreads into `.given(stateName, params)`
  224. - **Type safety**: TypeScript enforces `{ name: string, params: Record<string, unknown> }` input (both fields required)
  225. - **Null handling**: `null` becomes `"null"` string in JsonMap (Pact requirement)
  226. - **Date handling**: Date objects become ISO 8601 strings
  227. - **No nested objects in JsonMap**: Nested objects are JSON-stringified — provider state handlers must parse them
  228. - **Array serialization is lossy**: Arrays are converted via `String()` (e.g., `[1,2,3]` → `"1,2,3"`) — prefer passing arrays as JSON-stringified objects for round-trip safety
  229. - **Message pacts**: Works identically with `MessageConsumerPact` — same `.given()` API
  230. - **Builder reuse**: `setJsonContent` works for both `.withRequest(...)` and `.willRespondWith(...)` callbacks (query is ignored on response builders)
  231. - **Body shorthand**: `setJsonBody` keeps body-only responses concise and readable
  232. - **Matchers check type, not value**: `string('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.
  233. - **Reuse test values across files**: Interactions are uniquely identified by `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).
  234. - **One `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 and the determinism gate in `pact-consumer-framework-setup.md` Example 10.
  235. ## Related Fragments
  236. - `pactjs-utils-overview.md` — installation, decision tree, design philosophy
  237. - `pactjs-utils-provider-verifier.md` — provider-side state handler implementation; same `pool: 'forks'` + `singleFork: true` rule as consumer
  238. - `pact-consumer-framework-setup.md` — Vitest `fileParallelism: false` + `pool: 'forks'` + `singleFork: true` config, determinism gate (Example 10), and CI wiring
  239. - `contract-testing.md` — foundational patterns with raw Pact.js
  240. ## Anti-Patterns
  241. ### Wrong: Manual JsonMap assembly
  242. ```typescript
  243. // ❌ Manual casting — verbose, error-prone, no type safety
  244. provider.given('user exists', {
  245. id: 1 as unknown as string,
  246. createdAt: new Date().toISOString(),
  247. metadata: JSON.stringify({ role: 'admin' }),
  248. } as JsonMap);
  249. ```
  250. ### Right: Use createProviderState
  251. ```typescript
  252. // ✅ Automatic conversion with type safety
  253. provider.given(
  254. ...createProviderState({
  255. name: 'user exists',
  256. params: { id: 1, createdAt: new Date(), metadata: { role: 'admin' } },
  257. }),
  258. );
  259. ```
  260. ### Wrong: Inline state names without helper
  261. ```typescript
  262. // ❌ Duplicated state names between consumer and provider — easy to mismatch
  263. provider.given('a user with id 1 exists', { id: '1' });
  264. // Later in provider: 'user with id 1 exists' — different string!
  265. ```
  266. ### Right: Share state constants
  267. ```typescript
  268. // ✅ Define state names as constants shared between consumer and provider
  269. const STATES = {
  270. USER_EXISTS: 'user with id exists',
  271. NO_USERS: 'no users exist',
  272. } as const;
  273. provider.given(...createProviderState({ name: STATES.USER_EXISTS, params: { id: 1 } }));
  274. ```
  275. ### Wrong: Repeating inline builder lambdas everywhere
  276. ```typescript
  277. // ❌ Repetitive callback boilerplate in every interaction
  278. .willRespondWith(200, (builder) => {
  279. builder.jsonBody({ status: 200 });
  280. });
  281. ```
  282. ### Right: Use setJsonBody / setJsonContent
  283. ```typescript
  284. // ✅ Reusable callbacks with less boilerplate
  285. .withRequest('GET', '/movies', setJsonContent({ query: { name: 'Inception' } }))
  286. .willRespondWith(200, setJsonBody({ status: 200 }));
  287. ```
  288. ### Wrong: Multiple `addInteraction()` in a single `it()`
  289. ```typescript
  290. // ❌ PactV4 FFI non-deterministically drops one of these interactions ~1/N runs
  291. it('handles both success and empty list', async () => {
  292. await pact.addInteraction().uponReceiving('get movie').withRequest(/* ... */).executeTest(/* ... */);
  293. await pact.addInteraction().uponReceiving('empty list').withRequest(/* ... */).executeTest(/* ... */);
  294. });
  295. ```
  296. ### Right: One `addInteraction()` per `it()` (or use `it.each`)
  297. ```typescript
  298. // ✅ Deterministic pact JSON — FFI receives one interaction per test
  299. it('gets a movie', async () => {
  300. await pact
  301. .addInteraction() /* ... */
  302. .executeTest(/* ... */);
  303. });
  304. it('returns empty list', async () => {
  305. await pact
  306. .addInteraction() /* ... */
  307. .executeTest(/* ... */);
  308. });
  309. ```
  310. See Example 6 above for the full rationale and the determinism gate that enforces this rule.
  311. _Source: @seontechnologies/pactjs-utils consumer-helpers module, pactjs-utils sample-app consumer tests_