選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

pact-consumer-di.md 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. # Pact Consumer DI Pattern
  2. ## Principle
  3. Inject the Pact mock server URL into consumer code via an optional `baseUrl` field on the API context type instead of using raw `fetch()` inside `executeTest()`. This ensures contract tests exercise the real consumer HTTP client — including retry logic, header assembly, timeout configuration, error handling, and metrics — rather than testing Pact itself.
  4. The base URL is typically a module-level constant evaluated at import time (`export const API_BASE_URL = env.API_BASE_URL`), but `mockServer.url` is only available at runtime inside `executeTest()`. Dependency injection solves this timing mismatch cleanly: add one optional field to the context type, use nullish coalescing in the HTTP client factory, and inject the mock server URL in tests.
  5. ## Rationale
  6. ### The Problem
  7. Raw `fetch()` in `executeTest()` only proves that Pact returns what you told it to return. The real consumer HTTP client has retry logic, header assembly, timeout configuration, error handling, and metrics collection — none of which are exercised when you hand-craft fetch calls. Contracts written with raw fetch are hand-maintained guesses about what the consumer actually sends.
  8. ### Why NOT vi.mock
  9. `vi.mock` with ESM (`module: Node16`) has hoisting quirks that make it unreliable for overriding module-level constants. A getter-based mock is non-obvious and fragile — it works until the next bundler or TypeScript config change breaks it. DI is a standard pattern that requires zero mock magic and works across all module systems.
  10. ### Comparison
  11. | Approach | Production code change | Mock complexity | Exercises real client | Contract accuracy |
  12. | ------------ | ---------------------- | -------------------------- | --------------------- | --------------------------- |
  13. | Raw fetch | None | None | No | Low — hand-crafted requests |
  14. | vi.mock | None | High — ESM hoisting issues | Yes | Medium — fragile setup |
  15. | DI (baseUrl) | 2 lines | None | Yes | High — real requests |
  16. ## Pattern Examples
  17. ### Example 1: Production Code Change (2 Lines Total)
  18. **Context**: Add an optional `baseUrl` field to the API context type and use nullish coalescing in the HTTP client factory. This is the entire production code change required.
  19. **Implementation**:
  20. ```typescript
  21. // src/types.ts
  22. export type ApiContext = {
  23. jwtToken: string;
  24. customerId: number;
  25. adminUserId?: number;
  26. correlationId?: string;
  27. baseUrl?: string; // Override for testing (Pact mock server)
  28. };
  29. ```
  30. ```typescript
  31. // src/http-client.ts
  32. import axios from 'axios';
  33. import type { AxiosInstance } from 'axios';
  34. import type { ApiContext } from './types.js';
  35. import { API_BASE_URL, REQUEST_TIMEOUT } from './constants.js';
  36. function createAxiosInstanceWithContext(context: ApiContext): AxiosInstance {
  37. return axios.create({
  38. baseURL: context.baseUrl ?? API_BASE_URL,
  39. timeout: REQUEST_TIMEOUT,
  40. headers: {
  41. 'Content-Type': 'application/json',
  42. Accept: 'application/json',
  43. Authorization: `Bearer ${context.jwtToken}`,
  44. ...(context.correlationId && { 'X-Request-Id': context.correlationId }),
  45. },
  46. });
  47. }
  48. ```
  49. **Key Points**:
  50. - `baseUrl` is optional — existing production code never sets it
  51. - `??` (nullish coalescing) falls back to `API_BASE_URL` when `baseUrl` is undefined
  52. - Zero production behavior change — only test code provides the override
  53. - Two lines added total: one type field, one `??` fallback
  54. ### Example 2: Shared Test Context Helper
  55. **Context**: Create a reusable helper that builds an `ApiContext` with the mock server URL injected. One helper shared across all consumer test files.
  56. **Implementation**:
  57. ```typescript
  58. // pact/support/test-context.ts
  59. import type { ApiContext } from '../../src/types.js';
  60. export function createTestContext(mockServerUrl: string): ApiContext {
  61. return {
  62. jwtToken: 'test-jwt-token',
  63. customerId: 1,
  64. baseUrl: `${mockServerUrl}/api/v2`,
  65. };
  66. }
  67. ```
  68. **Key Points**:
  69. - `baseUrl` should include the API version prefix when consumer methods use versionless relative paths (e.g., `/transactions`) or endpoint paths are defined without the version segment
  70. - Single helper shared across all consumer test files — no repetition
  71. - Returns a plain object — follows pure-function-first pattern from `fixture-architecture.md`
  72. - Add fields as needed (e.g., `adminUserId`, `correlationId`) for specific test scenarios
  73. ### Example 3: Before/After for a Simple Test
  74. **Context**: Migrating an existing raw-fetch test to call real consumer code.
  75. **Before** (raw fetch — tests Pact mock, not consumer code):
  76. ```typescript
  77. .executeTest(async (mockServer: V3MockServer) => {
  78. const response = await fetch(
  79. `${mockServer.url}/api/v2/common/fields?ruleType=!&ignoreFeatureFlags=true`,
  80. {
  81. headers: {
  82. Authorization: "Bearer test-jwt-token",
  83. "Content-Type": "application/json",
  84. },
  85. },
  86. );
  87. expect(response.status).toBe(200);
  88. const body = (await response.json()) as Record<string, unknown>[];
  89. expect(body).toEqual(expect.arrayContaining([...]));
  90. });
  91. ```
  92. **After** (real consumer code):
  93. ```typescript
  94. .executeTest(async (mockServer: V3MockServer) => {
  95. const api = createApiClient(createTestContext(mockServer.url));
  96. const result = await api.getFilterFields();
  97. expect(result).toEqual(
  98. expect.arrayContaining([
  99. expect.objectContaining({
  100. id: expect.any(String),
  101. readable: expect.any(String),
  102. filterType: expect.any(String),
  103. }),
  104. ]),
  105. );
  106. });
  107. ```
  108. **Key Points**:
  109. - No HTTP status assertion — the consumer method throws on non-2xx, so reaching the expect proves success
  110. - Assertions validate the return value shape, not transport details
  111. - The real client's headers, timeout, and retry logic are exercised transparently
  112. - Less code, more coverage — the test is shorter and tests more
  113. ### Example 4: Contract Accuracy Fix
  114. **Context**: Using real consumer code revealed a contract mismatch that raw fetch silently hid. This is the strongest argument for the pattern.
  115. The real `getCustomerActivityCount(transactionId, dateRange)` sends:
  116. ```json
  117. { "transactionId": "txn-123", "filters": { "dateRange": "last_30_days" } }
  118. ```
  119. The old test with raw fetch sent:
  120. ```json
  121. { "transactionId": "txn-123", "filters": {} }
  122. ```
  123. This was wrong but passed because raw fetch let you hand-craft any body. When switched to real code, Pact immediately returned a 500 Request-Mismatch because the body shape did not match the interaction.
  124. **Implementation** — fix the contract to match reality:
  125. ```typescript
  126. // WRONG — old contract with empty filters
  127. .withRequest({
  128. method: "POST",
  129. path: "/api/v2/customers/activity/count",
  130. body: { transactionId: "txn-123", filters: {} },
  131. })
  132. // CORRECT — matches what real code actually sends
  133. .withRequest({
  134. method: "POST",
  135. path: "/api/v2/customers/activity/count",
  136. body: {
  137. transactionId: "txn-123",
  138. filters: { dateRange: "last_30_days" },
  139. },
  140. })
  141. ```
  142. **Key Points**:
  143. - Contracts become discoverable truth, not hand-maintained guesses
  144. - Raw fetch silently hid the mismatch — the mock accepted whatever you sent
  145. - The 500 Request-Mismatch from Pact was immediate and clear
  146. - Fix the contract when real code reveals a mismatch — that mismatch is a bug the old tests were hiding
  147. ### Example 5: Parallel-Endpoint Methods
  148. **Context**: Facade methods that call multiple endpoints via `Promise.all` (e.g., `getTransactionStats` calls count + score + amount in parallel). Keep separate `it` blocks per endpoint and use the lower-level request function directly.
  149. **Implementation**:
  150. ```typescript
  151. import { describe, it, expect } from 'vitest';
  152. import type { V3MockServer } from '@pact-foundation/pact';
  153. import { makeApiRequestWithContext } from '../../src/http-client.js';
  154. import type { CountStatistics } from '../../src/types.js';
  155. import { createTestContext } from '../support/test-context.js';
  156. describe('Transaction Statistics - Count Endpoint', () => {
  157. // ... provider setup ...
  158. it('should return count statistics', async () => {
  159. const statsRequest = { transactionId: 'txn-123', period: 'daily' };
  160. await provider
  161. .given('transaction statistics exist')
  162. .uponReceiving('a request for transaction count statistics')
  163. .withRequest({
  164. method: 'POST',
  165. path: '/api/v2/transactions/statistics/count',
  166. body: statsRequest,
  167. })
  168. .willRespondWith({
  169. status: 200,
  170. body: { count: 42, period: 'daily' },
  171. })
  172. .executeTest(async (mockServer: V3MockServer) => {
  173. const context = createTestContext(mockServer.url);
  174. const result = await makeApiRequestWithContext<CountStatistics>(context, '/transactions/statistics/count', 'POST', statsRequest);
  175. expect(result.count).toBeDefined();
  176. });
  177. });
  178. });
  179. ```
  180. **Key Points**:
  181. - Each Pact interaction verifies one endpoint contract
  182. - The `Promise.all` orchestration is internal logic, not a contract concern
  183. - Use `makeApiRequestWithContext` (lower-level) when the facade method bundles multiple calls
  184. - Separate `it` blocks keep contracts independent and debuggable
  185. ## Anti-Patterns
  186. ### Wrong: Raw fetch — tests Pact mock, not consumer code
  187. ```typescript
  188. // BAD: Raw fetch duplicates headers and URL assembly
  189. const response = await fetch(`${mockServer.url}/api/v2/transactions`, {
  190. method: 'GET',
  191. headers: {
  192. Authorization: 'Bearer test-jwt-token',
  193. 'Content-Type': 'application/json',
  194. },
  195. });
  196. expect(response.status).toBe(200);
  197. ```
  198. ### Wrong: vi.mock with getter — fragile ESM hoisting
  199. ```typescript
  200. // BAD: ESM hoisting makes this non-obvious and brittle
  201. vi.mock('../../src/constants.js', async (importOriginal) => ({
  202. ...(await importOriginal()),
  203. get API_BASE_URL() {
  204. return mockBaseUrl;
  205. },
  206. }));
  207. ```
  208. ### Wrong: Asserting HTTP status instead of return value
  209. ```typescript
  210. // BAD: Status 200 tells you nothing about the consumer's parsing logic
  211. expect(response.status).toBe(200);
  212. ```
  213. ### Right: Call real consumer code, assert return values
  214. ```typescript
  215. // GOOD: Exercises real client, validates parsed return value
  216. const api = createApiClient(createTestContext(mockServer.url));
  217. const result = await api.searchTransactions(request);
  218. expect(result.transactions).toBeDefined();
  219. ```
  220. ## Rules
  221. 1. `baseUrl` field MUST be optional with fallback via `??` (nullish coalescing)
  222. 2. Zero production behavior change — existing code never sets `baseUrl`
  223. 3. Assertions validate return values from consumer methods, not HTTP status codes
  224. 4. For parallel-endpoint facade methods, keep separate `it` blocks per endpoint
  225. 5. Include the API version prefix in `baseUrl` when endpoint paths/consumer methods are versionless (for example, methods call `/transactions` instead of `/api/v2/transactions`)
  226. 6. Create a single shared test context helper — no repetition across test files
  227. 7. If real code reveals a contract mismatch, fix the contract — that mismatch is a bug the old tests were hiding
  228. ## Integration Points
  229. - `contract-testing.md` — Foundational Pact.js patterns and provider verification
  230. - `pactjs-utils-consumer-helpers.md` — `createProviderState()`, `setJsonContent()`, and `setJsonBody()` helpers used alongside this pattern
  231. - `pactjs-utils-provider-verifier.md` — Provider-side verification configuration
  232. - `fixture-architecture.md` — Composable fixture patterns (`createTestContext` follows pure-function-first)
  233. - `api-testing-foundations.md` — API testing best practices
  234. Used in workflows:
  235. - `automate` — Consumer contract test generation
  236. - `test-review` — Contract test quality checks
  237. ## Source
  238. Pattern derived from my-consumer-app Pact consumer test refactor (March 2026). Implements dependency injection for testability as described in Pact.js best practices.