You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

pact-consumer-framework-setup.md 33KB

пре 2 месеци
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. # Pact Consumer CDC — Framework Setup
  2. ## Principle
  3. When scaffolding a Pact.js consumer contract testing framework, align every artifact — directory layout, vitest config, package.json scripts, shell scripts, CI workflow, and test files — with the canonical `@seontechnologies/pactjs-utils` conventions. Consistency across repositories eliminates onboarding friction and ensures CI pipelines are copy-paste portable.
  4. ## Rationale
  5. The TEA framework workflow generates scaffolding for consumer-driven contract (CDC) testing. Without opinionated, battle-tested conventions, each project invents its own structure — different script names, different env var patterns, different CI step ordering — making cross-repo maintenance expensive. This fragment codifies the production-proven patterns from the pactjs-utils reference implementation so that every new project starts correctly.
  6. ## Pattern Examples
  7. ### Example 1: Directory Structure & File Naming
  8. **Context**: Consumer contract test project layout using pactjs-utils conventions.
  9. **Implementation**:
  10. ```
  11. tests/contract/
  12. ├── consumer/
  13. │ ├── get-filter-fields.pacttest.ts # Consumer test (one per endpoint group)
  14. │ ├── filter-transactions.pacttest.ts
  15. │ └── get-transaction-stats.pacttest.ts
  16. └── support/
  17. ├── pact-config.ts # PactV4 factory (consumer/provider names, output dir)
  18. ├── provider-states.ts # Provider state factory functions
  19. └── consumer-helpers.ts # Local shim (until pactjs-utils is published)
  20. scripts/
  21. ├── env-setup.sh # Shared env loader (sourced by all broker scripts)
  22. ├── publish-pact.sh # Publish pact files to broker
  23. ├── can-i-deploy.sh # Deployment safety check
  24. └── record-deployment.sh # Record deployment after merge
  25. .github/
  26. ├── actions/
  27. │ └── detect-breaking-change/
  28. │ └── action.yml # PR checkbox-driven breaking change detection
  29. └── workflows/
  30. └── contract-test-consumer.yml # Consumer CDC CI workflow
  31. ```
  32. **Key Points**:
  33. - Consumer tests use `.pacttest.ts` extension (not `.pact.spec.ts` or `.contract.ts`)
  34. - Support files live in `tests/contract/support/`, not mixed with consumer tests
  35. - Shell scripts live in `scripts/` at project root, not nested inside test directories
  36. - CI workflow named `contract-test-consumer.yml` (not `pact-consumer.yml` or other variants)
  37. ---
  38. ### Example 2: Vitest Configuration for Pact
  39. **Context**: Minimal vitest config dedicated to contract tests — do NOT copy settings from the project's main `vitest.config.ts`.
  40. **Implementation**:
  41. ```typescript
  42. // vitest.config.pact.ts
  43. // See pact-consumer-framework-setup.md Example 2 "Key Points" for rationale on
  44. // fileParallelism + pool:forks + singleFork. Do not remove those three settings.
  45. import { defineConfig } from 'vitest/config';
  46. export default defineConfig({
  47. test: {
  48. environment: 'node',
  49. include: ['tests/contract/**/*.pacttest.ts'],
  50. testTimeout: 30000,
  51. fileParallelism: false,
  52. pool: 'forks',
  53. poolOptions: { forks: { singleFork: true } },
  54. },
  55. });
  56. ```
  57. **Key Points**:
  58. - **`fileParallelism: false` is required** — primary defense against non-deterministic pact generation. Without it, parallel workers race on the shared pact JSON file and corrupt interactions. Symptom: local runs pass, CI randomly fails with `Cannot change pact content for already published pact`. The `publish-pact.sh` `jq` sort (Example 4) provides byte-stability at publish time.
  59. - **`pool: 'forks'` + `singleFork: true` is required for multi-file consumer suites** — same config the provider side uses (`pactjs-utils-provider-verifier.md` Example 7). Best current understanding: the `@pact-foundation/pact` napi-rs binding is not robust across Vitest worker threads sharing a process; with the default threads pool (Vitest v1) and multiple `.pacttest.ts` files on the same consumer+provider pair, we observed reproducible "request was expected but not received" flakes on Linux CI only. `singleFork: true` serializes every pact file into one forked subprocess and eliminated the flake on two repos (`pactjs-utils`, `seon-mcp-server`). Vitest v2+ defaults to `forks`, but set the pool explicitly so the contract does not drift with Vitest version bumps.
  60. - **One `.pacttest.ts` per consumer+provider pair is the canonical pattern** — not just an observation. Two files for the same pair in one process (which `singleFork: true` guarantees) cause an FFI handle collision: the second file's `new PactV4(...)` call re-enters the FFI handle still holding stale state from the first file → "request was expected but not received" sporadically on Linux CI. The fix is structural — merge the files, not the config. `pool: 'forks'` is still required for pact JSON write safety but does NOT prevent same-pair file splits from colliding. Multiple files for **different** pairs (different consumer or provider name) are correct and safe. See Example 10 for the ✅/❌ pattern.
  61. - **Interacting settings**: leave `isolate` at its default (`true`). Do NOT set `sequence.concurrent: true`, `maxConcurrency > 1`, or `maxWorkers > 1` in this config — they defeat the serialization this rule relies on. `hookTimeout` may be raised if mock-server startup is slow, but keep `testTimeout` ≥ `hookTimeout`.
  62. - Do NOT add `setupFiles`, `coverage`, or other settings from the unit test config
  63. - Keep it minimal — Pact tests run in Node environment with extended timeout
  64. - 30 second timeout accommodates Pact mock server startup and interaction verification
  65. - Use a dedicated config file (`vitest.config.pact.ts`), not the main vitest config
  66. ---
  67. ### Example 3: Package.json Script Naming
  68. **Context**: Colon-separated naming matching pactjs-utils exactly. Scripts source `env-setup.sh` inline.
  69. **Implementation**:
  70. ```json
  71. {
  72. "scripts": {
  73. "test:pact:consumer": "vitest run --config vitest.config.pact.ts",
  74. "publish:pact": ". ./scripts/env-setup.sh && ./scripts/publish-pact.sh",
  75. "can:i:deploy:consumer": ". ./scripts/env-setup.sh && PACTICIPANT=<service-name> ./scripts/can-i-deploy.sh",
  76. "record:consumer:deployment": ". ./scripts/env-setup.sh && PACTICIPANT=<service-name> ./scripts/record-deployment.sh"
  77. }
  78. }
  79. ```
  80. Replace `<service-name>` with the consumer's pacticipant name (e.g., `my-frontend-app`).
  81. **Key Points**:
  82. - Use colon-separated naming: `test:pact:consumer`, NOT `test:contract` or `test:contract:consumer`
  83. - Broker scripts source `env-setup.sh` inline in package.json (`. ./scripts/env-setup.sh && ...`)
  84. - `PACTICIPANT` is set per-script invocation, not globally
  85. - Do NOT use `npx pact-broker` — use `pact-broker` directly (installed as a dependency)
  86. ---
  87. ### Example 4: Shell Scripts
  88. **Context**: Reusable bash scripts aligned with pactjs-utils conventions.
  89. #### `scripts/env-setup.sh` — Shared Environment Loader
  90. ```bash
  91. #!/bin/bash
  92. # -e: exit on error -u: error on undefined vars (catches typos/missing env vars in CI)
  93. set -eu
  94. if [ -f .env ]; then
  95. set -a
  96. source .env
  97. set +a
  98. fi
  99. export GITHUB_SHA="${GITHUB_SHA:-$(git rev-parse --short HEAD)}"
  100. export GITHUB_BRANCH="${GITHUB_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}"
  101. ```
  102. #### `scripts/publish-pact.sh` — Publish Pacts to Broker
  103. ```bash
  104. #!/bin/bash
  105. # Publish generated pact files to PactFlow/Pact Broker.
  106. #
  107. # Before publish, normalize each pact JSON: sort interactions by (description, provider state name,
  108. # method, path) and sort object keys via `jq -S`. This gives byte-stable output to the broker even
  109. # if the PactV4 generator produces ordering drift between runs. Ensures "Cannot change pact content"
  110. # from PactFlow never fires on ordering-only changes.
  111. #
  112. # Requires: PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA, GITHUB_BRANCH, jq
  113. # -e: exit on error -u: error on undefined vars -o pipefail: fail if any pipe segment fails
  114. set -euo pipefail
  115. . ./scripts/env-setup.sh
  116. PACT_DIR="./pacts"
  117. # Defense-in-depth: normalize interaction order for byte-stable publishes.
  118. for f in "$PACT_DIR"/*.json; do
  119. tmp="$(mktemp)"
  120. jq -S '.interactions |= sort_by(.description, (.providerStates[0].name // ""), .request.method, .request.path)' \
  121. "$f" > "$tmp"
  122. mv "$tmp" "$f"
  123. done
  124. pact-broker publish "$PACT_DIR" \
  125. --consumer-app-version="$GITHUB_SHA" \
  126. --branch="$GITHUB_BRANCH" \
  127. --broker-base-url="$PACT_BROKER_BASE_URL" \
  128. --broker-token="$PACT_BROKER_TOKEN"
  129. ```
  130. #### `scripts/can-i-deploy.sh` — Deployment Safety Check
  131. ```bash
  132. #!/bin/bash
  133. # Check if a pacticipant version can be safely deployed
  134. #
  135. # Requires: PACTICIPANT (set by caller), PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA
  136. # -e: exit on error -u: error on undefined vars -o pipefail: fail if any pipe segment fails
  137. set -euo pipefail
  138. . ./scripts/env-setup.sh
  139. PACTICIPANT="${PACTICIPANT:?PACTICIPANT env var is required}"
  140. ENVIRONMENT="${ENVIRONMENT:-dev}"
  141. pact-broker can-i-deploy \
  142. --pacticipant "$PACTICIPANT" \
  143. --version="$GITHUB_SHA" \
  144. --to-environment "$ENVIRONMENT" \
  145. --retry-while-unknown=10 \
  146. --retry-interval=30
  147. ```
  148. #### `scripts/record-deployment.sh` — Record Deployment
  149. ```bash
  150. #!/bin/bash
  151. # Record a deployment to an environment in Pact Broker
  152. # Only records on main/master branch (skips feature branches)
  153. #
  154. # Requires: PACTICIPANT, PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA, GITHUB_BRANCH
  155. # -e: exit on error -u: error on undefined vars -o pipefail: fail if any pipe segment fails
  156. set -euo pipefail
  157. . ./scripts/env-setup.sh
  158. PACTICIPANT="${PACTICIPANT:?PACTICIPANT env var is required}"
  159. if [ "$GITHUB_BRANCH" = "main" ] || [ "$GITHUB_BRANCH" = "master" ]; then
  160. pact-broker record-deployment \
  161. --pacticipant "$PACTICIPANT" \
  162. --version "$GITHUB_SHA" \
  163. --environment "${npm_config_env:-dev}"
  164. else
  165. echo "Skipping record-deployment: not on main branch (current: $GITHUB_BRANCH)"
  166. fi
  167. ```
  168. **Key Points**:
  169. - `env-setup.sh` uses `set -eu` (no pipefail — it only sources `.env`, no pipes); broker scripts use `set -euo pipefail`
  170. - Use `pact-broker` directly, NOT `npx pact-broker`
  171. - Use `PACTICIPANT` env var (required via `${PACTICIPANT:?...}`), not hardcoded service names
  172. - `can-i-deploy` includes `--retry-while-unknown=10 --retry-interval=30` (waits for provider verification)
  173. - `record-deployment` has branch guard (only records on main/master)
  174. - **`publish-pact.sh` normalizes interactions with `jq -S` + `sort_by(...)` before publishing** — ensures byte-stable payload to the broker regardless of generator ordering quirks.
  175. - Do NOT invent custom env vars like `PACT_CONSUMER_VERSION` or `PACT_BREAKING_CHANGE` in scripts — those are handled by `env-setup.sh` and the CI detect-breaking-change action respectively
  176. ---
  177. ### Example 5: CI Workflow (`contract-test-consumer.yml`)
  178. **Context**: GitHub Actions workflow for consumer CDC, matching pactjs-utils structure exactly.
  179. **Implementation**:
  180. ```yaml
  181. name: Contract Test - Consumer
  182. on:
  183. pull_request:
  184. types: [opened, synchronize, reopened, edited]
  185. push:
  186. branches: [main]
  187. env:
  188. PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
  189. PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
  190. GITHUB_SHA: ${{ github.sha }}
  191. GITHUB_BRANCH: ${{ github.head_ref || github.ref_name }}
  192. concurrency:
  193. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
  194. cancel-in-progress: true
  195. jobs:
  196. consumer-contract-test:
  197. if: github.actor != 'dependabot[bot]'
  198. runs-on: ubuntu-latest
  199. steps:
  200. - uses: actions/checkout@v6
  201. - uses: actions/setup-node@v6
  202. with:
  203. node-version-file: '.nvmrc'
  204. cache: 'npm'
  205. - name: Detect Pact breaking change
  206. uses: ./.github/actions/detect-breaking-change
  207. - name: Install dependencies
  208. run: npm ci
  209. # (1) Generate pact files
  210. - name: Run consumer contract tests
  211. run: npm run test:pact:consumer
  212. # (2) Publish pacts to broker (publish-pact.sh also normalizes interaction order as defense-in-depth)
  213. - name: Publish pacts to PactFlow
  214. run: npm run publish:pact
  215. # After publish, PactFlow fires a webhook that triggers
  216. # the provider's contract-test-provider.yml workflow.
  217. # can-i-deploy retries while waiting for provider verification.
  218. # (4) Check deployment safety (main only — on PRs, local verification is the gate)
  219. - name: Can I deploy consumer? (main only)
  220. if: github.ref == 'refs/heads/main' && env.PACT_BREAKING_CHANGE != 'true'
  221. run: npm run can:i:deploy:consumer
  222. # (5) Record deployment (main only)
  223. - name: Record consumer deployment (main only)
  224. if: github.ref == 'refs/heads/main'
  225. run: npm run record:consumer:deployment --env=dev
  226. ```
  227. **Key Points**:
  228. - **1:1 local/CI parity is a hard rule**: every CI step is `npm run <same-name-a-dev-uses>`. Never let CI invoke `vitest` or `pact-broker` directly — that divergence is how "works on my machine" slips in. Consumer tests, publish, can-i-deploy, and record-deployment are all the same commands a developer runs locally.
  229. - **Workflow-level `env` block** for broker secrets and git vars — not per-step
  230. - **`detect-breaking-change` step** runs before install to set `PACT_BREAKING_CHANGE` env var
  231. - **Step numbering skips (3)** — step 3 is the webhook-triggered provider verification (happens externally)
  232. - **can-i-deploy condition**: `github.ref == 'refs/heads/main' && env.PACT_BREAKING_CHANGE != 'true'`
  233. - **Comment on (4)**: "on PRs, local verification is the gate"
  234. - **No upload-artifact step** — the broker is the source of truth for pact files
  235. - **`dependabot[bot]` skip** on the job (contract tests don't run for dependency updates)
  236. - **PR types include `edited`** — needed for breaking change checkbox detection in PR body
  237. - **`GITHUB_BRANCH`** uses `${{ github.head_ref || github.ref_name }}` — `head_ref` for PRs, `ref_name` for pushes
  238. ---
  239. ### Example 6: Detect Breaking Change Composite Action
  240. **Context**: GitHub composite action that reads a `[x] Pact breaking change` checkbox from the PR body.
  241. **Implementation**:
  242. Create `.github/actions/detect-breaking-change/action.yml`:
  243. ```yaml
  244. name: 'Detect Pact Breaking Change'
  245. description: 'Reads the PR template checkbox to determine if this change is a Pact breaking change. Sets PACT_BREAKING_CHANGE env var.'
  246. outputs:
  247. is_breaking_change:
  248. description: 'Whether the change is a breaking change (true/false)'
  249. value: ${{ steps.result.outputs.is_breaking_change }}
  250. runs:
  251. using: 'composite'
  252. steps:
  253. # PR event path: read checkbox directly from current PR body.
  254. - name: Set PACT_BREAKING_CHANGE from PR description (PR only)
  255. if: github.event_name == 'pull_request'
  256. uses: actions/github-script@v7
  257. with:
  258. script: |
  259. const prBody = context.payload.pull_request.body || '';
  260. const breakingChangePattern = /\[\s*[xX]\s*\]\s*Pact breaking change/i;
  261. const isBreakingChange = breakingChangePattern.test(prBody);
  262. core.exportVariable('PACT_BREAKING_CHANGE', isBreakingChange ? 'true' : 'false');
  263. console.log(`PACT_BREAKING_CHANGE=${isBreakingChange ? 'true' : 'false'} (from PR description checkbox).`);
  264. # Push-to-main path: resolve the merged PR and read the same checkbox.
  265. - name: Set PACT_BREAKING_CHANGE from merged PR (push to main)
  266. if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  267. uses: actions/github-script@v7
  268. with:
  269. script: |
  270. const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
  271. owner: context.repo.owner,
  272. repo: context.repo.repo,
  273. commit_sha: context.sha,
  274. });
  275. const merged = prs.find(pr => pr.merged_at);
  276. const mergedBody = merged?.body || '';
  277. const breakingChangePattern = /\[\s*[xX]\s*\]\s*Pact breaking change/i;
  278. const isBreakingChange = breakingChangePattern.test(mergedBody);
  279. core.exportVariable('PACT_BREAKING_CHANGE', isBreakingChange ? 'true' : 'false');
  280. console.log(`PACT_BREAKING_CHANGE=${isBreakingChange ? 'true' : 'false'} (from merged PR lookup).`);
  281. - name: Export result
  282. id: result
  283. shell: bash
  284. run: echo "is_breaking_change=${PACT_BREAKING_CHANGE:-false}" >> "$GITHUB_OUTPUT"
  285. ```
  286. **Key Points**:
  287. - Two separate conditional steps (better CI log readability than single if/else)
  288. - PR path: reads checkbox directly from PR body
  289. - Push-to-main path: resolves merged PR via GitHub API, reads same checkbox
  290. - Exports `PACT_BREAKING_CHANGE` env var for downstream steps
  291. - `outputs.is_breaking_change` available for consuming workflows
  292. - Uses a case-insensitive checkbox regex (`/\[\s*[xX]\s*\]\s*Pact breaking change/i`) to detect checked states robustly
  293. ---
  294. ### Example 7: Consumer Test Using PactV4 Builder
  295. **Context**: Consumer pact test using PactV4 `addInteraction()` builder pattern. The test MUST call **real consumer code** (your actual API client/service functions) against the mock server — not raw `fetch()`. Using `fetch()` directly defeats the purpose of CDC testing because it doesn't verify your actual consumer code works with the contract.
  296. **Implementation**:
  297. The consumer code must expose a way to inject the base URL (e.g., `setApiUrl()`, constructor parameter, or environment variable). This is a prerequisite for contract testing.
  298. ```typescript
  299. // src/api/movie-client.ts — The REAL consumer code (already exists in your project)
  300. import axios from 'axios';
  301. const axiosInstance = axios.create({
  302. baseURL: process.env.API_URL || 'http://localhost:3001',
  303. });
  304. // Expose a way to override the base URL for Pact testing
  305. export const setApiUrl = (url: string) => {
  306. axiosInstance.defaults.baseURL = url;
  307. };
  308. export const getMovies = async () => {
  309. const res = await axiosInstance.get('/movies');
  310. return res.data;
  311. };
  312. export const getMovieById = async (id: number) => {
  313. const res = await axiosInstance.get(`/movies/${id}`);
  314. return res.data;
  315. };
  316. ```
  317. ```typescript
  318. // tests/contract/consumer/get-movies.pacttest.ts
  319. import { MatchersV3 } from '@pact-foundation/pact';
  320. import type { V3MockServer } from '@pact-foundation/pact';
  321. import { createProviderState, setJsonBody, setJsonContent } from '../support/consumer-helpers';
  322. import { movieExists } from '../support/provider-states';
  323. import { createPact } from '../support/pact-config';
  324. // Import REAL consumer code — this is what we're actually testing
  325. import { getMovies, getMovieById, setApiUrl } from '../../../src/api/movie-client';
  326. const { like, integer, string } = MatchersV3;
  327. const pact = createPact();
  328. describe('Movies API Consumer Contract', () => {
  329. const movieWithId = { id: 1, name: 'The Matrix', year: 1999, rating: 8.7, director: 'Wachowskis' };
  330. it('should get a movie by ID', async () => {
  331. const [stateName, stateParams] = createProviderState(movieExists(movieWithId));
  332. await pact
  333. .addInteraction()
  334. .given(stateName, stateParams)
  335. .uponReceiving('a request to get movie by ID')
  336. .withRequest(
  337. 'GET',
  338. '/movies/1',
  339. setJsonContent({
  340. headers: { Accept: 'application/json' },
  341. }),
  342. )
  343. .willRespondWith(
  344. 200,
  345. setJsonBody(
  346. like({
  347. id: integer(1),
  348. name: string('The Matrix'),
  349. year: integer(1999),
  350. rating: like(8.7),
  351. director: string('Wachowskis'),
  352. }),
  353. ),
  354. )
  355. .executeTest(async (mockServer: V3MockServer) => {
  356. // Inject mock server URL into the REAL consumer code
  357. setApiUrl(mockServer.url);
  358. // Call the REAL consumer function — this is what CDC testing validates
  359. const movie = await getMovieById(1);
  360. expect(movie.id).toBe(1);
  361. expect(movie.name).toBe('The Matrix');
  362. });
  363. });
  364. it('should handle movie not found', async () => {
  365. await pact
  366. .addInteraction()
  367. .given('No movies exist')
  368. .uponReceiving('a request for a non-existent movie')
  369. .withRequest('GET', '/movies/999')
  370. .willRespondWith(404, setJsonBody({ error: 'Movie not found' }))
  371. .executeTest(async (mockServer: V3MockServer) => {
  372. setApiUrl(mockServer.url);
  373. await expect(getMovieById(999)).rejects.toThrow();
  374. });
  375. });
  376. });
  377. ```
  378. **Key Points**:
  379. - **CRITICAL**: Always test your REAL consumer code — import and call actual API client functions, never raw `fetch()`
  380. - Using `fetch()` directly only tests that Pact's mock server works, which is meaningless
  381. - Consumer code MUST expose a URL injection mechanism: `setApiUrl()`, env var override, or constructor parameter
  382. - If the consumer code doesn't support URL injection, add it — this is a design prerequisite for CDC testing
  383. - Use PactV4 `addInteraction()` builder (not PactV3 fluent API with `withRequest({...})` object)
  384. - **Interaction naming convention**: Use the pattern `"a request to <action> <resource> [<condition>]"` for `uponReceiving()`. Examples: `"a request to get a movie by ID"`, `"a request to delete a non-existing movie"`, `"a request to create a movie that already exists"`. These names appear in Pact Broker UI and verification logs — keep them descriptive and unique within the consumer-provider pair.
  385. - Use `setJsonContent` for request/response builder callbacks with query/header/body concerns; use `setJsonBody` for body-only response callbacks
  386. - Provider state factory functions (`movieExists`) return `ProviderStateInput` objects
  387. - `createProviderState` converts to `[stateName, stateParams]` tuple for `.given()`
  388. **Common URL injection patterns** (pick whichever fits your consumer architecture):
  389. | Pattern | Example | Best For |
  390. | -------------------- | -------------------------------------------- | --------------------- |
  391. | `setApiUrl(url)` | Mutates axios instance `baseURL` | Singleton HTTP client |
  392. | Constructor param | `new ApiClient({ baseUrl: mockServer.url })` | Class-based clients |
  393. | Environment variable | `process.env.API_URL = mockServer.url` | Config-driven apps |
  394. | Factory function | `createApi({ baseUrl: mockServer.url })` | Functional patterns |
  395. ---
  396. ### Example 8: Support Files
  397. #### Pact Config Factory
  398. ```typescript
  399. // tests/contract/support/pact-config.ts
  400. import path from 'node:path';
  401. import { PactV4 } from '@pact-foundation/pact';
  402. export const createPact = (overrides?: { consumer?: string; provider?: string }) =>
  403. new PactV4({
  404. dir: path.resolve(process.cwd(), 'pacts'),
  405. consumer: overrides?.consumer ?? 'MyConsumerApp',
  406. provider: overrides?.provider ?? 'MyProviderAPI',
  407. logLevel: 'warn',
  408. });
  409. ```
  410. #### Provider State Factories
  411. ```typescript
  412. // tests/contract/support/provider-states.ts
  413. import type { ProviderStateInput } from './consumer-helpers';
  414. export const movieExists = (movie: { id: number; name: string; year: number; rating: number; director: string }): ProviderStateInput => ({
  415. name: 'An existing movie exists',
  416. params: movie,
  417. });
  418. export const hasMovieWithId = (id: number): ProviderStateInput => ({
  419. name: 'Has a movie with a specific ID',
  420. params: { id },
  421. });
  422. ```
  423. #### Local Consumer Helpers Shim
  424. ```typescript
  425. // tests/contract/support/consumer-helpers.ts
  426. // TODO(temporary scaffolding): Replace local TemplateHeaders/TemplateQuery types
  427. // with '@seontechnologies/pactjs-utils' exports when available.
  428. type TemplateHeaders = Record<string, string | number | boolean>;
  429. type TemplateQueryValue = string | number | boolean | Array<string | number | boolean>;
  430. type TemplateQuery = Record<string, TemplateQueryValue>;
  431. export type ProviderStateInput = {
  432. name: string;
  433. params: Record<string, unknown>;
  434. };
  435. type JsonMap = { [key: string]: boolean | number | string | null | JsonMap | Array<unknown> };
  436. type JsonContentBuilder = {
  437. headers: (headers: TemplateHeaders) => unknown;
  438. jsonBody: (body: unknown) => unknown;
  439. query?: (query: TemplateQuery) => unknown;
  440. };
  441. export type JsonContentInput = {
  442. body?: unknown;
  443. headers?: TemplateHeaders;
  444. query?: TemplateQuery;
  445. };
  446. export const toJsonMap = (obj: Record<string, unknown>): JsonMap =>
  447. Object.fromEntries(
  448. Object.entries(obj).map(([key, value]) => {
  449. if (value === null || value === undefined) return [key, 'null'];
  450. if (typeof value === 'object' && !(value instanceof Date) && !Array.isArray(value)) return [key, JSON.stringify(value)];
  451. if (typeof value === 'number' || typeof value === 'boolean') return [key, value];
  452. if (value instanceof Date) return [key, value.toISOString()];
  453. return [key, String(value)];
  454. }),
  455. );
  456. export const createProviderState = ({ name, params }: ProviderStateInput): [string, JsonMap] => [name, toJsonMap(params)];
  457. export const setJsonContent =
  458. ({ body, headers, query }: JsonContentInput) =>
  459. (builder: JsonContentBuilder): void => {
  460. if (query && builder.query) {
  461. builder.query(query);
  462. }
  463. if (headers) {
  464. builder.headers(headers);
  465. }
  466. if (body !== undefined) {
  467. builder.jsonBody(body);
  468. }
  469. };
  470. export const setJsonBody = (body: unknown) => setJsonContent({ body });
  471. ```
  472. **Key Points**:
  473. - If `@seontechnologies/pactjs-utils` is not yet installed, create a local shim that mirrors the API
  474. - Add a TODO comment noting to swap for the published package when available
  475. - The shim exports `createProviderState`, `toJsonMap`, `setJsonContent`, `setJsonBody`, and helper input types
  476. - Keep shim types local (or sourced from public exports only); do not import from internal Pact paths like `@pact-foundation/pact/src/*`
  477. ---
  478. ### Example 9: .gitignore Entries
  479. **Context**: Pact-specific entries to add to `.gitignore`.
  480. ```
  481. # Pact contract testing artifacts
  482. /pacts/
  483. pact-logs/
  484. ```
  485. ---
  486. ### Example 10: Test File Organization — One File Per Consumer+Provider Pair
  487. **Context**: Avoiding Pact Rust FFI handle collisions when structuring consumer test files.
  488. **Rule**: Every consumer+provider pair maps to exactly one `.pacttest.ts` file. Never split interactions for the same pair across multiple files.
  489. **Root cause**: The Pact Rust FFI maintains one handle per consumer+provider pair per process. With `singleFork: true` (all files run sequentially in one forked process), two files for the same pair access the same FFI handle back-to-back. The second file's `new PactV4({ consumer, provider })` call re-enters the handle still holding stale interaction state from the first file. The first test in the second file starts the mock server in this corrupted state — "request was expected but not received" results, sporadic and Linux-CI-only (execution order differs between environments).
  490. **Evidence**: In `pactjs-utils`, `movies-read.pacttest.ts` and `movies-write.pacttest.ts` both used `consumer: 'SampleAppConsumer', provider: 'SampleMoviesAPI'`. The vitest config and CI workflow were correct throughout. The fix was merging the two files into `movies.pacttest.ts`. The config was not changed.
  491. ```typescript
  492. // ❌ WRONG — same consumer+provider pair split across two files
  493. // movies-read.pacttest.ts
  494. const pact = new PactV4({ consumer: 'SampleAppConsumer', provider: 'SampleMoviesAPI', ... })
  495. describe('Read Operations', () => { /* 4 tests: GET /movies, GET /movies/:id */ })
  496. // movies-write.pacttest.ts ← second PactV4 for the SAME pair = FFI handle collision
  497. const pact = new PactV4({ consumer: 'SampleAppConsumer', provider: 'SampleMoviesAPI', ... })
  498. describe('Write Operations', () => { /* 5 tests: POST, PUT, DELETE */ })
  499. // ✅ RIGHT — one file per consumer+provider pair, describe blocks for organization
  500. // movies.pacttest.ts
  501. const pact = new PactV4({ consumer: 'SampleAppConsumer', provider: 'SampleMoviesAPI', ... })
  502. describe('Movies API', () => {
  503. describe('Read Operations', () => { /* 4 tests */ })
  504. describe('Write Operations', () => { /* 5 tests */ })
  505. })
  506. ```
  507. **Key Points**:
  508. - **File = contract**: A `.pacttest.ts` file represents one consumer+provider contract. One contract = one file.
  509. - **Describe blocks, not files**: Organize by operation type (`Read Operations`, `Write Operations`), resource, or feature — always within one file per pair.
  510. - **Different pairs = different files**: `ServiceA / BackendAPI` and `ServiceA / AuthAPI` are two contracts and correctly use two separate files. This rule only forbids splitting ONE pair.
  511. - **`singleFork: true` is not a fix for this**: It ensures correct pact JSON write semantics across files, but when two files share a pair it actually guarantees the FFI collision (both land in the same process). Without it you'd get file-write races instead. Neither is safe. The fix is one file per pair.
  512. - **Naming convention**: `{domain}.pacttest.ts` when one domain maps to one pair. `{consumer-kebab}-{provider-kebab}.pacttest.ts` when the filename must be self-describing about which pair it covers.
  513. ---
  514. ## Validation Checklist
  515. Before presenting the consumer CDC framework to the user, verify:
  516. - [ ] `vitest.config.pact.ts` is minimal **and sets `fileParallelism: false` AND `pool: 'forks'` with `poolOptions.forks.singleFork: true`** (`fileParallelism: false` prevents shared pact JSON corruption from parallel workers; forks + `singleFork: true` is required for pact JSON write safety across files — see Example 2 Key Points for mechanism and evidence)
  517. - [ ] Each consumer+provider pair is covered by exactly ONE `.pacttest.ts` file — never split interactions for the same pair across multiple files (two `PactV4` instances for the same pair in one process cause FFI handle collision → "request was expected but not received" on Linux CI; `singleFork: true` does NOT prevent this — it ensures both files share one process, which guarantees the collision; see Example 10)
  518. - [ ] `vitest.config.pact.ts` does NOT set `sequence.concurrent: true`, `maxConcurrency > 1`, `maxWorkers > 1`, or `isolate: false` — all four defeat the serialization the rule relies on
  519. - [ ] `scripts/publish-pact.sh` normalizes interactions with `jq -S '.interactions |= sort_by(.description, (.providerStates[0].name // ""), .request.method, .request.path)'` before the `pact-broker publish` call (ensures byte-stable payload to PactFlow regardless of generator ordering)
  520. - [ ] Script names match pactjs-utils (`test:pact:consumer`, `publish:pact`, `can:i:deploy:consumer`, `record:consumer:deployment`)
  521. - [ ] Scripts source `env-setup.sh` inline in package.json
  522. - [ ] Shell scripts use `pact-broker` not `npx pact-broker`
  523. - [ ] Shell scripts use `PACTICIPANT` env var pattern
  524. - [ ] `can-i-deploy.sh` has `--retry-while-unknown=10 --retry-interval=30`
  525. - [ ] `record-deployment.sh` has branch guard
  526. - [ ] `env-setup.sh` uses `set -eu`; broker scripts use `set -euo pipefail` — each with explanatory comment
  527. - [ ] CI workflow named `contract-test-consumer.yml`
  528. - [ ] CI has workflow-level env block (not per-step)
  529. - [ ] CI has `detect-breaking-change` step before install
  530. - [ ] CI step (1) generates pact files (calls `npm run test:pact:consumer`) — its own visible step, not folded into publish
  531. - [ ] CI steps are 1:1 with developer commands — every CI step calls `npm run <same-name>` a dev would run locally (no direct `vitest` or `pact-broker` invocation)
  532. - [ ] CI step numbering skips (3) — webhook-triggered provider verification
  533. - [ ] CI can-i-deploy has `PACT_BREAKING_CHANGE != 'true'` condition
  534. - [ ] CI has NO upload-artifact step
  535. - [ ] `.github/actions/detect-breaking-change/action.yml` exists
  536. - [ ] Consumer tests use `.pacttest.ts` extension
  537. - [ ] Consumer tests use PactV4 `addInteraction()` builder
  538. - [ ] `uponReceiving()` names follow `"a request to <action> <resource> [<condition>]"` pattern and are unique within the consumer-provider pair
  539. - [ ] Interaction callbacks use `setJsonContent` for query/header/body and `setJsonBody` for body-only responses
  540. - [ ] Request bodies use exact values (no `like()` wrapper) — Postel's Law: be strict in what you send
  541. - [ ] `like()`, `eachLike()`, `string()`, `integer()` matchers are only used in `willRespondWith` (responses), not in `withRequest` (requests) — matchers check type/shape, not exact values
  542. - [ ] Consumer tests call REAL consumer code (actual API client functions), NOT raw `fetch()`
  543. - [ ] Consumer code exposes URL injection mechanism (`setApiUrl()`, env var, or constructor param)
  544. - [ ] Local consumer-helpers shim present if pactjs-utils not installed
  545. - [ ] `.gitignore` includes `/pacts/` and `pact-logs/`
  546. ## Related Fragments
  547. - `pactjs-utils-overview.md` — Library decision tree and installation
  548. - `pactjs-utils-consumer-helpers.md` — `createProviderState`, `toJsonMap`, `setJsonContent`, `setJsonBody`, **one-interaction-per-`it()` rule**
  549. - `pactjs-utils-provider-verifier.md` — Provider-side verification patterns; consumer and provider BOTH require `pool: 'forks'` + `singleFork: true` — same FFI-safety rule applies on both sides
  550. - `pactjs-utils-request-filter.md` — Auth injection for provider verification
  551. - `pact-broker-webhooks.md` — PactFlow → GitHub webhook auth pattern (dedicated user, classic PAT, PactFlow secret) and staleness monitoring
  552. - `contract-testing.md` — Foundational CDC patterns and resilience coverage