311 lines
12 KiB
Markdown
311 lines
12 KiB
Markdown
# 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.
|