nstructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async fetchItem(sku: string): Promise<CatalogItem> {
const response = await fetch(${this.baseUrl}/v1/catalog/${sku});
if (!response.ok) throw new Error(Catalog fetch failed: ${response.status});
return CatalogItemSchema.parse(await response.json());
}
}
**Consumer Contract Test (`test/contracts/catalog.consumer.test.ts`)**
```typescript
import { Pact } from '@pact-foundation/pact';
import { CatalogClient } from '../../src/clients/catalog';
describe('CatalogService Contract', () => {
const pact = new Pact({
consumer: 'BillingClient',
provider: 'CatalogService',
port: 18080,
log: 'test/logs/pact.log',
dir: 'test/pacts',
logLevel: 'warn',
});
beforeAll(async () => await pact.setup());
afterAll(async () => await pact.finalize());
beforeEach(() => pact.removeInteractions());
it('validates successful item retrieval', async () => {
pact.addInteraction({
state: 'catalog contains active inventory',
uponReceiving: 'GET request for a specific SKU',
withRequest: {
method: 'GET',
path: '/v1/catalog/PROD-8842',
headers: { 'Accept': 'application/json' },
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: {
sku: 'PROD-8842',
title: 'Industrial Sensor Array',
unitPrice: 149.50,
currency: 'USD',
metadata: { category: 'hardware' },
},
},
});
const client = new CatalogClient('http://localhost:18080');
const item = await client.fetchItem('PROD-8842');
expect(item.sku).toBe('PROD-8842');
expect(item.unitPrice).toBeGreaterThan(0);
});
it('handles missing SKU gracefully', async () => {
pact.addInteraction({
state: 'catalog has no record for requested SKU',
uponReceiving: 'GET request for non-existent item',
withRequest: {
method: 'GET',
path: Pact.Matchers.regex({
matcher: '/v1/catalog/[A-Z0-9-]+',
example: '/v1/catalog/MISSING-00',
}),
},
willRespondWith: {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: { error: 'not_found', message: 'SKU not indexed' },
},
});
const client = new CatalogClient('http://localhost:18080');
await expect(client.fetchItem('MISSING-00')).rejects.toThrow('Catalog fetch failed: 404');
});
});
Architecture Rationale:
- The consumer owns the contract. This prevents provider over-engineering and ensures tests reflect actual usage patterns.
Pact.Matchers.regex replaces hardcoded paths, allowing the contract to validate structure rather than exact values.
- The mock server runs locally, eliminating network latency and external dependencies.
Step 2: Pact Artifact Generation
When the consumer tests pass, Pact serializes the interactions into a JSON file (test/pacts/BillingClient-CatalogService.json). This file contains:
- Request/response pairs
- State definitions
- Header expectations
- Body matchers
The artifact is version-controlled and serves as the single source of truth for interface compatibility.
Step 3: Provider Verification
The provider service must prove it satisfies the consumer's expectations. Instead of spinning up a mock, we run the actual provider and point Pact's verifier at it.
Provider Application (src/server.ts)
import express from 'express';
import { z } from 'zod';
const app = express();
app.use(express.json());
const CatalogSchema = z.object({
sku: z.string(),
title: z.string(),
unitPrice: z.number(),
currency: z.enum(['USD', 'EUR', 'GBP']),
metadata: z.object({ category: z.string() }),
});
const inventory: Record<string, z.infer<typeof CatalogSchema>> = {
'PROD-8842': {
sku: 'PROD-8842',
title: 'Industrial Sensor Array',
unitPrice: 149.50,
currency: 'USD',
metadata: { category: 'hardware' },
},
};
app.get('/v1/catalog/:sku', (req, res) => {
const item = inventory[req.params.sku];
if (!item) return res.status(404).json({ error: 'not_found', message: 'SKU not indexed' });
res.json(item);
});
export { app };
Provider Verification Test (test/verification/catalog.provider.test.ts)
import { Verifier } from '@pact-foundation/pact';
import { app } from '../../src/server';
import http from 'http';
describe('CatalogService Provider Verification', () => {
let server: http.Server;
beforeAll(async () => {
server = app.listen(0); // Ephemeral port
});
afterAll(async () => {
server.close();
});
it('satisfies consumer contract expectations', async () => {
const opts = {
provider: 'CatalogService',
providerBaseUrl: `http://localhost:${(server.address() as any).port}`,
pactUrls: ['test/pacts/BillingClient-CatalogService.json'],
logLevel: 'warn',
validateCallback: (res: any) => {
// Optional: Run additional schema validation against real responses
return res.status >= 200 && res.status < 500;
},
};
const result = await new Verifier(opts).verifyProvider();
expect(result).toBeTruthy();
});
});
Architecture Rationale:
- The verifier reads the pact JSON and replays each recorded request against the live provider.
- Using an ephemeral port (
app.listen(0)) prevents port collision in CI environments.
validateCallback allows runtime assertions beyond Pact's built-in checks, such as response time thresholds or custom header validation.
Step 4: CI/CD Integration Strategy
Contract testing fails if treated as an isolated local exercise. The verification pipeline must enforce ordering:
- Consumer tests run β pact artifact generated β artifact published to shared storage (Pact Broker or CI artifact cache)
- Provider tests run β verifier fetches latest pact β verification passes/fails
- Merge blocked if verification fails
This sequence guarantees that no provider deployment can proceed without satisfying active consumer contracts.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
| Exact Value Matching | Hardcoding specific IDs, timestamps, or prices in contracts causes false failures when data changes naturally. | Use Pact.Matchers.type, regex, or eachLike to validate structure and constraints instead of literal values. |
| Ignoring Provider State | Contracts define state strings, but providers rarely implement state handlers. Tests pass locally but fail in CI due to missing seed data. | Implement state setup hooks in the verifier or use a deterministic test database that resets before verification runs. |
| Testing Non-Contract Concerns | Including authentication tokens, rate-limit headers, or tracing IDs in contract expectations bloats the pact and ties it to infrastructure. | Strip infrastructure headers from contracts. Validate security and observability separately in integration or security test suites. |
| Stale Pact Artifacts | Running verification against outdated pact files allows breaking changes to slip through when consumers update expectations but providers don't pull the latest contract. | Publish pacts to a Pact Broker or CI artifact store. Configure the verifier to fetch the latest version tagged for the target environment. |
| Collection Boundary Errors | Assuming arrays always contain exactly one item or ignoring pagination leads to contracts that break when providers return empty lists or paginated responses. | Use Pact.Matchers.eachLike with min: 0 and explicitly test empty collection scenarios. Define pagination headers in the contract if applicable. |
| Mock Server Misuse | Routing production traffic through Pact mock servers or treating them as API gateways. | Mock servers are strictly for test execution. Never expose them in production environments or use them for load testing. |
| Skipping Content-Type Validation | Omitting Content-Type headers in expectations allows providers to return XML, plain text, or malformed JSON without triggering failures. | Always declare Content-Type in willRespondWith.headers. Pact will enforce exact header matching during verification. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small team, rapid iteration | Local pact files + CI artifact cache | Minimal overhead, fast feedback, no external dependencies | Low (infrastructure-free) |
| Multi-team, high churn | Pact Broker with version tagging | Centralized contract management, automatic compatibility checks, audit trail | Medium (broker hosting + maintenance) |
| Strict compliance/audit requirements | Pact Broker + signed artifacts + verification logs | Immutable contract history, regulatory traceability, automated compliance reporting | High (tooling + process overhead) |
| Legacy monolith migration | Contract testing for new service boundaries only | Isolates integration risk during decomposition without rewriting existing test suites | Low (targeted scope) |
Configuration Template
Jest Configuration (jest.config.ts)
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/test'],
testMatch: ['**/*.test.ts'],
coverageDirectory: 'coverage',
collectCoverageFrom: ['src/**/*.ts'],
globals: {
'ts-jest': {
isolatedModules: true,
},
},
};
Pact Setup Utility (test/utils/pact.setup.ts)
import { Pact } from '@pact-foundation/pact';
import path from 'path';
export function createPactInstance(consumer: string, provider: string, port: number) {
return new Pact({
consumer,
provider,
port,
log: path.join(process.cwd(), 'test/logs/pact.log'),
dir: path.join(process.cwd(), 'test/pacts'),
logLevel: 'warn',
host: '127.0.0.1',
pactfileWriteMode: 'update',
});
}
TypeScript Configuration (tsconfig.json)
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}
Quick Start Guide
- Initialize dependencies: Run
npm install @pact-foundation/pact express jest ts-jest typescript zod @types/node @types/express in both consumer and provider directories.
- Configure Jest: Copy the
jest.config.ts and tsconfig.json templates into each service root. Ensure ts-jest is configured for isolated modules to prevent Pact mock conflicts.
- Write consumer expectations: Create a test file using
createPactInstance, define interactions with matchers, and run npx jest. Verify the pact JSON appears in test/pacts/.
- Run provider verification: Point the
Verifier at the generated pact file, start the real provider on an ephemeral port, and execute npx jest. Fix any mismatches until verification passes.
- Integrate into CI: Add a pipeline stage that publishes the pact artifact after consumer tests, then runs provider verification before allowing merges. Tag contracts with semantic versions for traceability.