|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310 |
- # 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<string, unknown>[];
- 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<CountStatistics>(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.
|