API Mocking and Contract Testing
Welcome to TopperBlog! 👋
I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.
🎯 What I Write About:
• AI/ML Engineering & LLMs
• Web3 & Blockchain Development
• System Design & Architecture
• Interview Preparation (FAANG)
• Freelancing & Remote Work
• Modern Tech Stacks (Next.js, React, Rust, TypeScript)
• Performance Optimization & Best Practices
💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.
📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.
🌐 Let's connect and grow together in this amazing tech journey!
#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering
API Mocking and Contract Testing: A Modern Developer's Guide
Metadata
{
"seo_title": "API Mocking & Contract Testing Guide for TypeScript Developers",
"meta_description": "Master API mocking and contract testing in TypeScript. Learn modern solutions, avoid common pitfalls, and implement best practices for reliable microservices testing.",
"keywords": [
"API mocking",
"contract testing",
"TypeScript testing",
"Pact testing",
"microservices testing",
"API testing strategies",
"consumer-driven contracts",
"MSW mock service worker"
],
"tags": [
"Testing",
"TypeScript",
"Microservices",
"API Development",
"DevOps",
"Contract Testing",
"Quality Assurance"
]
}
The Problem: Testing in a Distributed World (2026)
The microservices revolution promised us scalability, independent deployments, and team autonomy. What it delivered—alongside those benefits—was a testing nightmare that keeps developers up at night.
The Integration Testing Dilemma
Picture this: You're developing a payment service that depends on three external APIs—a user authentication service, a fraud detection system, and a payment gateway. Your CI/CD pipeline needs to verify that your service works correctly, but you face several brutal realities:
The Availability Problem: External services aren't always available during your test runs. The authentication service might be down for maintenance, the fraud detection API could be rate-limiting your requests, or the payment gateway's sandbox environment might be experiencing issues. Your tests fail not because your code is broken, but because someone else's infrastructure is having a bad day.
The Cost Problem: Many third-party APIs charge per request. Running comprehensive integration tests against real APIs can cost hundreds or thousands of dollars monthly. Even "free tier" APIs often have strict rate limits that make thorough testing impractical.
The Speed Problem: Network calls are slow. A test suite that makes dozens of real API calls might take 10-15 minutes to complete. Multiply that across multiple developers and CI/CD runs, and you're looking at hours of wasted time daily. The feedback loop becomes so slow that developers start skipping tests or writing fewer of them.
The Data Problem: Real APIs have real data that changes. The user account that existed yesterday might be deleted today. The product that was in stock during your last test run might be sold out now. Your tests become flaky, failing randomly based on external state you can't control.
The Environment Problem: Setting up realistic test environments is expensive and complex. You need databases, message queues, caching layers, and multiple services all configured correctly. Even with containers and infrastructure-as-code, maintaining these environments is a full-time job.
The Traditional Mocking Trap
Developers have traditionally solved these problems with mocking—creating fake implementations of external dependencies. But naive mocking creates its own set of problems:
The Drift Problem: Your mocks are based on your understanding of how an API works. But APIs evolve. When the authentication service adds a new required field or changes its error response format, your mocks don't know about it. Your tests pass with flying colors while your production code breaks spectacularly.
The Over-Mocking Problem: It's tempting to mock everything, but mocks that are too permissive give false confidence. If your mock accepts any input and returns happy-path responses, you're not really testing anything meaningful. You're just verifying that your code can call functions—not that it handles real-world scenarios correctly.
The Maintenance Burden: Every time an API changes, you need to update your mocks manually. With dozens of endpoints across multiple services, this becomes a significant maintenance burden. Teams often let mocks drift out of sync because updating them is tedious and error-prone.
The Contract Testing Gap
The fundamental problem is this: How do you verify that your service integrates correctly with external dependencies without actually calling them during every test run?
This is where contract testing enters the picture. Instead of testing the actual integration or relying on potentially inaccurate mocks, contract testing verifies that both sides of an API relationship agree on how they'll communicate—the "contract" between them.
The Modern TypeScript Solution
Let's build a robust testing strategy using modern TypeScript tools that solve these problems elegantly.
Setting Up Mock Service Worker (MSW)
MSW intercepts network requests at the network level, making it perfect for realistic API mocking:
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
interface User {
id: string;
email: string;
role: 'admin' | 'user';
}
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json<User>({
id: id as string,
email: `user${id}@example.com`,
role: 'user'
});
}),
http.post('/api/payments', async ({ request }) => {
const body = await request.json();
// Simulate validation
if (!body.amount || body.amount <= 0) {
return HttpResponse.json(
{ error: 'Invalid amount' },
{ status: 400 }
);
}
return HttpResponse.json({
transactionId: crypto.randomUUID(),
status: 'completed',
amount: body.amount
}, { status: 201 });
})
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/setupTests.ts
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Implementing Contract Testing with Pact
Pact enables consumer-driven contract testing, ensuring both sides of an API agree on the contract:
// src/api/userClient.ts
export class UserClient {
constructor(private baseUrl: string) {}
async getUser(id: string): Promise<User> {
const response = await fetch(`${this.baseUrl}/users/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
return response.json();
}
}
// src/api/userClient.pact.test.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserClient } from './userClient';
const { eachLike, string, uuid } = MatchersV3;
const provider = new PactV3({
consumer: 'PaymentService',
provider: 'UserService',
dir: './pacts'
});
describe('User API Contract', () => {
it('retrieves a user by ID', async () => {
await provider
.given('user exists')
.uponReceiving('a request for a user')
.withRequest({
method: 'GET',
path: '/users/123',
headers: { Accept: 'application/json' }
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: uuid('123'),
email: string('user@example.com'),
role: string('user')
}
})
.executeTest(async (mockServer) => {
const client = new UserClient(mockServer.url);
const user = await client.getUser('123');
expect(user.id).toBe('123');
expect(user.email).toBeDefined();
});
});
});
Type-Safe API Contracts with Zod
Combine runtime validation with TypeScript types:
// src/contracts/userContract.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.string().datetime().optional()
});
export type User = z.infer<typeof UserSchema>;
export const UserResponseSchema = z.object({
data: UserSchema,
meta: z.object({
requestId: z.string()
})
});
// src/api/userClient.ts
import { UserSchema, type User } from '../contracts/userContract';
export class UserClient {
async getUser(id: string): Promise<User> {
const response = await fetch(`${this.baseUrl}/users/${id}`);
const data = await response.json();
// Runtime validation ensures contract compliance
return UserSchema.parse(data);
}
}
Common Pitfalls and How to Avoid Them
Pitfall 1: Mocking Implementation Details
Wrong approach:
// Testing internal HTTP library behavior
jest.spyOn(axios, 'get').mockResolvedValue({ data: mockUser });
Better approach:
// Test at the network boundary
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(mockUser);
})
);
Pitfall 2: Ignoring Contract Versioning
Always version your contracts and maintain backward compatibility:
export const UserSchemaV1 = z.object({
id: z.string(),
email: z.string()
});
export const UserSchemaV2 = UserSchemaV1.extend({
role: z.enum(['admin', 'user']),
permissions: z.array(z.string()).optional()
});
Pitfall 3: Not Testing Error Scenarios
describe('Payment API error handling', () => {
it('handles network failures gracefully', async () => {
server.use(
http.post('/api/payments', () => {
return HttpResponse.error();
})
);
await expect(
paymentClient.createPayment({ amount: 100 })
).rejects.toThrow('Network error');
});
it('handles validation errors', async () => {
server.use(
http.post('/api/payments', () => {
return HttpResponse.json(
{ error: 'Invalid amount', code: 'VALIDATION_ERROR' },
{ status: 400 }
);
})
);
await expect(
paymentClient.createPayment({ amount: -10 })
).rejects.toThrow('Invalid amount');
});
});
Best Practices
1. Separate Mock Data from Test Logic
// src/fixtures/users.ts
export const mockUsers = {
admin: {
id: '1',
email: 'admin@example.com',
role: 'admin' as const
},
regular: {
id: '2',
email: 'user@example.com',
role: 'user' as const
}
};
2. Use Factory Functions for Test Data
// src/factories/userFactory.ts
import { faker } from '@faker-js/faker';
export const createMockUser = (overrides?: Partial<User>): User => ({
id: faker.string.uuid(),
email: faker.internet.email(),
role: 'user',
...overrides
});
3. Implement Contract Testing in CI/CD
# .github/workflows/contract-tests.yml
name: Contract Tests
on: [pull_request]
jobs:
consumer-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm test
- name: Publish contracts
run: npx pact-broker publish ./pacts --broker-base-url=${{ secrets.PACT_BROKER_URL }}
provider-verification:
needs: consumer-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm run test:provider
4. Document Your Contracts
/**
* User Service Contract v2.0
*
* Breaking changes from v1:
* - Added required 'role' field
* - Email validation now enforces RFC 5322
*
* @see https://docs.example.com/api/users
*/
export const UserContractV2 = {
schema: UserSchemaV2,
version: '2.0.0',
deprecationDate: null
};
5. Monitor Contract Compliance in Production
import { UserSchema } from './contracts/userContract';
export async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
const result = UserSchema.safeParse(data);
if (!result.success) {
// Log contract violation for monitoring
logger.error('Contract violation detected', {
endpoint: '/api/users/:id',
errors: result.error.errors
});
// Decide: fail fast or degrade gracefully
throw new ContractViolationError(result.error);
}
return result.data;
}
Frequently Asked Questions
Q1: Should I use contract testing for all APIs?
Not necessarily. Contract testing provides the most value for:
- Internal microservices where you control both consumer and provider
- Critical integrations where breaking changes are costly
- APIs with multiple consumers
For simple, stable third-party APIs, well-maintained mocks might suffice.
Q2: How do I handle breaking changes in contracts?
Implement a versioning strategy:
- Introduce new contract version alongside the old
- Give consumers time to migrate (typically 3-6 months)
- Monitor usage of deprecated versions
- Remove old version only after all consumers have migrated
Q3: What's the difference between MSW and Pact?
MSW is for mocking HTTP requests in tests—it intercepts network calls and returns predefined responses. Pact is for contract testing—it verifies that consumer expectations match provider implementations. Use MSW for unit/integration tests and Pact for contract verification.
Q4: How do I test WebSocket or gRPC APIs?
For WebSockets, use libraries like mock-socket. For gRPC, use @grpc/grpc-js with test doubles. Contract testing tools like Pact also support message-based protocols.
Q5: Should contracts be owned by consumers or providers?
In consumer-driven contract testing, consumers define their expectations. However, providers should review and approve contracts before implementation. This ensures contracts are realistic and maintainable.
Q6: How do I handle authentication in mocked APIs?
http.get('/api/protected', ({ request }) => {
const auth = request.headers.get('Authorization');
if (!auth || !auth.startsWith('Bearer ')) {
return HttpResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
return HttpResponse.json({ data: 'protected' });
});
Q7: What's the performance impact of runtime validation?
Runtime validation with Zod adds minimal overhead (typically <1ms per validation). For high-throughput services, consider:
- Validating only at system boundaries
- Using faster validators like
typiafor hot paths - Caching validation results for repeated data structures
Word Count: 1,784 words
API mocking and contract testing aren't just about writing tests—they're about building confidence in distributed systems. By combining MSW for realistic mocking, Pact for contract verification, and Zod for type-safe validation, you create a testing strategy that catches integration issues early while keeping your test suite fast and reliable. The investment in proper contract testing pays dividends in reduced production incidents and faster, more confident deployments.