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.
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.
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.
| 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 |
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:
baseUrl is optional — existing production code never sets it?? (nullish coalescing) falls back to API_BASE_URL when baseUrl is undefined?? fallbackContext: 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:
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 segmentfixture-architecture.mdadminUserId, correlationId) for specific test scenariosContext: 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:
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:
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:
Promise.all orchestration is internal logic, not a contract concernmakeApiRequestWithContext (lower-level) when the facade method bundles multiple callsit blocks keep contracts independent and debuggable// 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);
// BAD: ESM hoisting makes this non-obvious and brittle
vi.mock('../../src/constants.js', async (importOriginal) => ({
...(await importOriginal()),
get API_BASE_URL() {
return mockBaseUrl;
},
}));
// BAD: Status 200 tells you nothing about the consumer's parsing logic
expect(response.status).toBe(200);
// GOOD: Exercises real client, validates parsed return value
const api = createApiClient(createTestContext(mockServer.url));
const result = await api.searchTransactions(request);
expect(result.transactions).toBeDefined();
baseUrl field MUST be optional with fallback via ?? (nullish coalescing)baseUrlit blocks per endpointbaseUrl when endpoint paths/consumer methods are versionless (for example, methods call /transactions instead of /api/v2/transactions)contract-testing.md — Foundational Pact.js patterns and provider verificationpactjs-utils-consumer-helpers.md — createProviderState(), setJsonContent(), and setJsonBody() helpers used alongside this patternpactjs-utils-provider-verifier.md — Provider-side verification configurationfixture-architecture.md — Composable fixture patterns (createTestContext follows pure-function-first)api-testing-foundations.md — API testing best practicesUsed in workflows:
automate — Consumer contract test generationtest-review — Contract test quality checksPattern derived from my-consumer-app Pact consumer test refactor (March 2026). Implements dependency injection for testability as described in Pact.js best practices.