# Pact Consumer DI Pattern ## Principle 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. 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. ## Rationale ### The Problem 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. ### Why NOT vi.mock `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. ### Comparison | Approach | Production code change | Mock complexity | Exercises real client | Contract accuracy | | ------------ | ---------------------- | -------------------------- | --------------------- | --------------------------- | | Raw fetch | None | None | No | Low — hand-crafted requests | | vi.mock | None | High — ESM hoisting issues | Yes | Medium — fragile setup | | DI (baseUrl) | 2 lines | None | Yes | High — real requests | ## Pattern Examples ### Example 1: Production Code Change (2 Lines Total) **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. **Implementation**: ```typescript // src/types.ts export type ApiContext = { jwtToken: string; customerId: number; adminUserId?: number; correlationId?: string; baseUrl?: string; // Override for testing (Pact mock server) }; ``` ```typescript // src/http-client.ts import axios from 'axios'; import type { AxiosInstance } from 'axios'; import type { ApiContext } from './types.js'; import { API_BASE_URL, REQUEST_TIMEOUT } from './constants.js'; function createAxiosInstanceWithContext(context: ApiContext): AxiosInstance { return axios.create({ baseURL: context.baseUrl ?? API_BASE_URL, timeout: REQUEST_TIMEOUT, headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${context.jwtToken}`, ...(context.correlationId && { 'X-Request-Id': context.correlationId }), }, }); } ``` **Key Points**: - `baseUrl` is optional — existing production code never sets it - `??` (nullish coalescing) falls back to `API_BASE_URL` when `baseUrl` is undefined - Zero production behavior change — only test code provides the override - Two lines added total: one type field, one `??` fallback ### Example 2: Shared Test Context Helper **Context**: Create a reusable helper that builds an `ApiContext` with the mock server URL injected. One helper shared across all consumer test files. **Implementation**: ```typescript // pact/support/test-context.ts import type { ApiContext } from '../../src/types.js'; export function createTestContext(mockServerUrl: string): ApiContext { return { jwtToken: 'test-jwt-token', customerId: 1, baseUrl: `${mockServerUrl}/api/v2`, }; } ``` **Key Points**: - `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 - Single helper shared across all consumer test files — no repetition - Returns a plain object — follows pure-function-first pattern from `fixture-architecture.md` - Add fields as needed (e.g., `adminUserId`, `correlationId`) for specific test scenarios ### Example 3: Before/After for a Simple Test **Context**: Migrating an existing raw-fetch test to call real consumer code. **Before** (raw fetch — tests Pact mock, not consumer code): ```typescript .executeTest(async (mockServer: V3MockServer) => { const response = await fetch( `${mockServer.url}/api/v2/common/fields?ruleType=!&ignoreFeatureFlags=true`, { headers: { Authorization: "Bearer test-jwt-token", "Content-Type": "application/json", }, }, ); expect(response.status).toBe(200); const body = (await response.json()) as Record[]; expect(body).toEqual(expect.arrayContaining([...])); }); ``` **After** (real consumer code): ```typescript .executeTest(async (mockServer: V3MockServer) => { const api = createApiClient(createTestContext(mockServer.url)); const result = await api.getFilterFields(); expect(result).toEqual( expect.arrayContaining([ expect.objectContaining({ id: expect.any(String), readable: expect.any(String), filterType: expect.any(String), }), ]), ); }); ``` **Key Points**: - No HTTP status assertion — the consumer method throws on non-2xx, so reaching the expect proves success - Assertions validate the return value shape, not transport details - The real client's headers, timeout, and retry logic are exercised transparently - Less code, more coverage — the test is shorter and tests more ### Example 4: Contract Accuracy Fix **Context**: Using real consumer code revealed a contract mismatch that raw fetch silently hid. This is the strongest argument for the pattern. The real `getCustomerActivityCount(transactionId, dateRange)` sends: ```json { "transactionId": "txn-123", "filters": { "dateRange": "last_30_days" } } ``` The old test with raw fetch sent: ```json { "transactionId": "txn-123", "filters": {} } ``` 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. **Implementation** — fix the contract to match reality: ```typescript // WRONG — old contract with empty filters .withRequest({ method: "POST", path: "/api/v2/customers/activity/count", body: { transactionId: "txn-123", filters: {} }, }) // CORRECT — matches what real code actually sends .withRequest({ method: "POST", path: "/api/v2/customers/activity/count", body: { transactionId: "txn-123", filters: { dateRange: "last_30_days" }, }, }) ``` **Key Points**: - Contracts become discoverable truth, not hand-maintained guesses - Raw fetch silently hid the mismatch — the mock accepted whatever you sent - The 500 Request-Mismatch from Pact was immediate and clear - Fix the contract when real code reveals a mismatch — that mismatch is a bug the old tests were hiding ### Example 5: Parallel-Endpoint Methods **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. **Implementation**: ```typescript import { describe, it, expect } from 'vitest'; import type { V3MockServer } from '@pact-foundation/pact'; import { makeApiRequestWithContext } from '../../src/http-client.js'; import type { CountStatistics } from '../../src/types.js'; import { createTestContext } from '../support/test-context.js'; describe('Transaction Statistics - Count Endpoint', () => { // ... provider setup ... it('should return count statistics', async () => { const statsRequest = { transactionId: 'txn-123', period: 'daily' }; await provider .given('transaction statistics exist') .uponReceiving('a request for transaction count statistics') .withRequest({ method: 'POST', path: '/api/v2/transactions/statistics/count', body: statsRequest, }) .willRespondWith({ status: 200, body: { count: 42, period: 'daily' }, }) .executeTest(async (mockServer: V3MockServer) => { const context = createTestContext(mockServer.url); const result = await makeApiRequestWithContext(context, '/transactions/statistics/count', 'POST', statsRequest); expect(result.count).toBeDefined(); }); }); }); ``` **Key Points**: - Each Pact interaction verifies one endpoint contract - The `Promise.all` orchestration is internal logic, not a contract concern - Use `makeApiRequestWithContext` (lower-level) when the facade method bundles multiple calls - Separate `it` blocks keep contracts independent and debuggable ## Anti-Patterns ### Wrong: Raw fetch — tests Pact mock, not consumer code ```typescript // BAD: Raw fetch duplicates headers and URL assembly const response = await fetch(`${mockServer.url}/api/v2/transactions`, { method: 'GET', headers: { Authorization: 'Bearer test-jwt-token', 'Content-Type': 'application/json', }, }); expect(response.status).toBe(200); ``` ### Wrong: vi.mock with getter — fragile ESM hoisting ```typescript // BAD: ESM hoisting makes this non-obvious and brittle vi.mock('../../src/constants.js', async (importOriginal) => ({ ...(await importOriginal()), get API_BASE_URL() { return mockBaseUrl; }, })); ``` ### Wrong: Asserting HTTP status instead of return value ```typescript // BAD: Status 200 tells you nothing about the consumer's parsing logic expect(response.status).toBe(200); ``` ### Right: Call real consumer code, assert return values ```typescript // GOOD: Exercises real client, validates parsed return value const api = createApiClient(createTestContext(mockServer.url)); const result = await api.searchTransactions(request); expect(result.transactions).toBeDefined(); ``` ## Rules 1. `baseUrl` field MUST be optional with fallback via `??` (nullish coalescing) 2. Zero production behavior change — existing code never sets `baseUrl` 3. Assertions validate return values from consumer methods, not HTTP status codes 4. For parallel-endpoint facade methods, keep separate `it` blocks per endpoint 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`) 6. Create a single shared test context helper — no repetition across test files 7. If real code reveals a contract mismatch, fix the contract — that mismatch is a bug the old tests were hiding ## Integration Points - `contract-testing.md` — Foundational Pact.js patterns and provider verification - `pactjs-utils-consumer-helpers.md` — `createProviderState()`, `setJsonContent()`, and `setJsonBody()` helpers used alongside this pattern - `pactjs-utils-provider-verifier.md` — Provider-side verification configuration - `fixture-architecture.md` — Composable fixture patterns (`createTestContext` follows pure-function-first) - `api-testing-foundations.md` — API testing best practices Used in workflows: - `automate` — Consumer contract test generation - `test-review` — Contract test quality checks ## Source Pattern derived from my-consumer-app Pact consumer test refactor (March 2026). Implements dependency injection for testability as described in Pact.js best practices.