12 KiB
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:
// src/types.ts
export type ApiContext = {
jwtToken: string;
customerId: number;
adminUserId?: number;
correlationId?: string;
baseUrl?: string; // Override for testing (Pact mock server)
};
// 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:
baseUrlis optional — existing production code never sets it??(nullish coalescing) falls back toAPI_BASE_URLwhenbaseUrlis 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:
// 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:
baseUrlshould 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):
.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):
.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:
{ "transactionId": "txn-123", "filters": { "dateRange": "last_30_days" } }
The old test with raw fetch sent:
{ "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:
// 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:
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.allorchestration is internal logic, not a contract concern - Use
makeApiRequestWithContext(lower-level) when the facade method bundles multiple calls - Separate
itblocks keep contracts independent and debuggable
Anti-Patterns
Wrong: Raw fetch — tests Pact mock, not consumer code
// 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
// 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
// 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
// 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
baseUrlfield MUST be optional with fallback via??(nullish coalescing)- Zero production behavior change — existing code never sets
baseUrl - Assertions validate return values from consumer methods, not HTTP status codes
- For parallel-endpoint facade methods, keep separate
itblocks per endpoint - Include the API version prefix in
baseUrlwhen endpoint paths/consumer methods are versionless (for example, methods call/transactionsinstead of/api/v2/transactions) - Create a single shared test context helper — no repetition across test files
- 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 verificationpactjs-utils-consumer-helpers.md—createProviderState(),setJsonContent(), andsetJsonBody()helpers used alongside this patternpactjs-utils-provider-verifier.md— Provider-side verification configurationfixture-architecture.md— Composable fixture patterns (createTestContextfollows pure-function-first)api-testing-foundations.md— API testing best practices
Used in workflows:
automate— Consumer contract test generationtest-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.