Les enseignants ont besoin de moyennes à jour immédiatement après la publication ou modification des notes, sans attendre un batch nocturne. Le système recalcule via Domain Events synchrones : statistiques d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées (normalisation /20), et moyenne générale par élève. Les résultats sont stockés dans des tables dénormalisées avec cache Redis (TTL 5 min). Trois endpoints API exposent les données avec contrôle d'accès par rôle. Une commande console permet le backfill des données historiques au déploiement.
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.
|