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.
### 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 {
-`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):
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';
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
Pattern derived from my-consumer-app Pact consumer test refactor (March 2026). Implements dependency injection for testability as described in Pact.js best practices.