Prerequisites:
- Understanding of OpenAPI Adapter
- Familiarity with @frontmcp/testing
- Basic knowledge of HTTP Mocking
What You’ll Learn
- Mock OpenAPI spec loading
- Test generated CRUD operations
- Handle authentication in tests
- Test error scenarios
- Verify request/response transformations
The Challenge
OpenAPI-generated tools make HTTP requests to external APIs. Without mocking:- Spec loading fails - The adapter fetches the OpenAPI spec at startup
- API calls fail - Generated tools call the real API
- Tests aren’t isolated - External state affects test results
- Tests are slow - Network latency adds up
Step 1: Set Up Test Configuration
First, configure your test file:openapi-app.e2e.ts
import { test, expect, httpMock, httpResponse } from '@frontmcp/testing';
test.use({
server: './src/main.ts',
port: 3003,
});
// Reusable mock interceptor
let interceptor: ReturnType<typeof httpMock.interceptor>;
beforeEach(() => {
interceptor = httpMock.interceptor();
});
afterEach(() => {
interceptor.restore();
});
Step 2: Mock the OpenAPI Specification
The adapter fetches the OpenAPI spec when the server starts. Mock this first:// Sample OpenAPI 3.0 specification
const openApiSpec = {
openapi: '3.0.0',
info: {
title: 'Expense API',
version: '1.0.0',
},
servers: [
{ url: 'https://api.example.com' },
],
paths: {
'/expenses': {
get: {
operationId: 'listExpenses',
summary: 'List all expenses',
responses: {
'200': {
description: 'List of expenses',
content: {
'application/json': {
schema: {
type: 'array',
items: { $ref: '#/components/schemas/Expense' },
},
},
},
},
},
},
post: {
operationId: 'createExpense',
summary: 'Create a new expense',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreateExpenseInput' },
},
},
},
responses: {
'201': {
description: 'Created expense',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Expense' },
},
},
},
},
},
},
'/expenses/{id}': {
get: {
operationId: 'getExpense',
summary: 'Get expense by ID',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
},
],
responses: {
'200': {
description: 'Expense details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Expense' },
},
},
},
'404': {
description: 'Expense not found',
},
},
},
delete: {
operationId: 'deleteExpense',
summary: 'Delete an expense',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
},
],
responses: {
'204': { description: 'Deleted' },
},
},
},
},
components: {
schemas: {
Expense: {
type: 'object',
properties: {
id: { type: 'string' },
amount: { type: 'number' },
category: { type: 'string' },
description: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' },
},
},
CreateExpenseInput: {
type: 'object',
required: ['amount', 'category'],
properties: {
amount: { type: 'number' },
category: { type: 'string' },
description: { type: 'string' },
},
},
},
},
};
// Mock the spec endpoint before each test
beforeEach(() => {
interceptor = httpMock.interceptor();
// Mock OpenAPI spec fetch
interceptor.get('https://api.example.com/openapi.json', {
body: openApiSpec,
});
});
Step 3: Test Tool Discovery
Verify that tools are generated from the spec:test.describe('OpenAPI Tool Generation', () => {
test('generates tools from OpenAPI spec', async ({ mcp }) => {
const tools = await mcp.tools.list();
// Tools are named: {adapter-name}:{operationId}
expect(tools).toContainTool('expense-api:listExpenses');
expect(tools).toContainTool('expense-api:createExpense');
expect(tools).toContainTool('expense-api:getExpense');
expect(tools).toContainTool('expense-api:deleteExpense');
});
test('tools have correct descriptions', async ({ mcp }) => {
const tools = await mcp.tools.list();
const listTool = tools.find(t => t.name === 'expense-api:listExpenses');
expect(listTool?.description).toContain('List all expenses');
const createTool = tools.find(t => t.name === 'expense-api:createExpense');
expect(createTool?.description).toContain('Create a new expense');
});
});
Step 4: Test GET Operations
Test list and get-by-ID operations:test.describe('GET Operations', () => {
test('listExpenses returns array of expenses', async ({ mcp }) => {
const mockExpenses = [
{ id: '1', amount: 50, category: 'Travel', description: 'Taxi' },
{ id: '2', amount: 120, category: 'Meals', description: 'Client dinner' },
];
interceptor.get('https://api.example.com/expenses', {
body: mockExpenses,
});
const result = await mcp.tools.call('expense-api:listExpenses', {});
expect(result).toBeSuccessful();
expect(result.json()).toHaveLength(2);
expect(result.json()[0].category).toBe('Travel');
});
test('getExpense returns single expense', async ({ mcp }) => {
const mockExpense = {
id: 'exp-123',
amount: 75.50,
category: 'Office Supplies',
description: 'Printer paper',
createdAt: '2024-01-15T10:30:00Z',
};
interceptor.get('https://api.example.com/expenses/exp-123', {
body: mockExpense,
});
const result = await mcp.tools.call('expense-api:getExpense', {
id: 'exp-123',
});
expect(result).toBeSuccessful();
expect(result.json().id).toBe('exp-123');
expect(result.json().amount).toBe(75.50);
});
test('getExpense handles 404', async ({ mcp }) => {
interceptor.get('https://api.example.com/expenses/not-found', {
status: 404,
body: { error: 'Expense not found' },
});
const result = await mcp.tools.call('expense-api:getExpense', {
id: 'not-found',
});
expect(result).toBeError();
});
});
Step 5: Test POST Operations
Test create operations with request body validation:test.describe('POST Operations', () => {
test('createExpense sends correct request body', async ({ mcp }) => {
const createdExpense = {
id: 'new-123',
amount: 99.99,
category: 'Travel',
description: 'Airport parking',
createdAt: '2024-01-20T14:00:00Z',
};
const postHandle = interceptor.post('https://api.example.com/expenses', {
status: 201,
body: createdExpense,
});
const result = await mcp.tools.call('expense-api:createExpense', {
amount: 99.99,
category: 'Travel',
description: 'Airport parking',
});
expect(result).toBeSuccessful();
expect(result.json().id).toBe('new-123');
// Verify the request body was correct
const calls = postHandle.calls();
expect(calls).toHaveLength(1);
expect(calls[0].body).toEqual({
amount: 99.99,
category: 'Travel',
description: 'Airport parking',
});
});
test('createExpense handles validation errors', async ({ mcp }) => {
interceptor.post('https://api.example.com/expenses', {
status: 400,
body: {
error: 'Validation failed',
details: [{ field: 'amount', message: 'must be positive' }],
},
});
const result = await mcp.tools.call('expense-api:createExpense', {
amount: -50,
category: 'Travel',
});
expect(result).toBeError();
});
});
Step 6: Test DELETE Operations
Test delete operations:test.describe('DELETE Operations', () => {
test('deleteExpense sends DELETE request', async ({ mcp }) => {
const deleteHandle = interceptor.delete(
'https://api.example.com/expenses/exp-456',
{ status: 204 }
);
const result = await mcp.tools.call('expense-api:deleteExpense', {
id: 'exp-456',
});
expect(result).toBeSuccessful();
// Verify DELETE was called
expect(deleteHandle.callCount()).toBe(1);
});
test('deleteExpense handles not found', async ({ mcp }) => {
interceptor.delete('https://api.example.com/expenses/missing', {
status: 404,
body: { error: 'Expense not found' },
});
const result = await mcp.tools.call('expense-api:deleteExpense', {
id: 'missing',
});
expect(result).toBeError();
});
});
Step 7: Test with Authentication
If your OpenAPI adapter uses authentication:test.describe('Authenticated Requests', () => {
test('includes auth header from token', async ({ mcp, auth }) => {
const token = await auth.createToken({
sub: 'user-123',
scopes: ['expenses:read'],
});
await mcp.authenticate(token);
const getHandle = interceptor.get('https://api.example.com/expenses', {
body: [],
});
await mcp.tools.call('expense-api:listExpenses', {});
// Verify Authorization header was sent
const calls = getHandle.calls();
expect(calls[0].headers['authorization']).toMatch(/^Bearer /);
});
test('includes tenant header from user claims', async ({ mcp, auth }) => {
const token = await auth.createToken({
sub: 'user-123',
claims: { tenantId: 'tenant-abc' },
});
await mcp.authenticate(token);
const getHandle = interceptor.get('https://api.example.com/expenses', {
body: [],
});
await mcp.tools.call('expense-api:listExpenses', {});
// Verify custom header (if using headersMapper)
const calls = getHandle.calls();
expect(calls[0].headers['x-tenant-id']).toBe('tenant-abc');
});
});
Step 8: Test Error Scenarios
Cover various API error responses:test.describe('Error Handling', () => {
test('handles 401 Unauthorized', async ({ mcp }) => {
interceptor.get('https://api.example.com/expenses', {
status: 401,
body: { error: 'Invalid or expired token' },
});
const result = await mcp.tools.call('expense-api:listExpenses', {});
expect(result).toBeError();
});
test('handles 403 Forbidden', async ({ mcp }) => {
interceptor.get('https://api.example.com/expenses', {
status: 403,
body: { error: 'Insufficient permissions' },
});
const result = await mcp.tools.call('expense-api:listExpenses', {});
expect(result).toBeError();
});
test('handles 500 Server Error', async ({ mcp }) => {
interceptor.get('https://api.example.com/expenses', {
status: 500,
body: { error: 'Internal server error' },
});
const result = await mcp.tools.call('expense-api:listExpenses', {});
expect(result).toBeError();
});
test('handles network timeout', async ({ mcp }) => {
interceptor.get('https://api.example.com/expenses', {
delay: 60000, // 60 second delay (will timeout)
body: {},
});
const result = await mcp.tools.call('expense-api:listExpenses', {});
expect(result).toBeError();
});
test('handles malformed JSON response', async ({ mcp }) => {
interceptor.mock({
match: { url: 'https://api.example.com/expenses' },
response: {
status: 200,
headers: { 'content-type': 'application/json' },
body: 'not valid json{{{',
},
});
const result = await mcp.tools.call('expense-api:listExpenses', {});
expect(result).toBeError();
});
});
Step 9: Test Query Parameters
Test operations with query parameters:test.describe('Query Parameters', () => {
test('passes query parameters correctly', async ({ mcp }) => {
// Assuming the spec has query params for filtering
const getHandle = interceptor.get(/expenses\?/, {
body: [{ id: '1', category: 'Travel' }],
});
await mcp.tools.call('expense-api:listExpenses', {
category: 'Travel',
limit: 10,
offset: 0,
});
const calls = getHandle.calls();
const url = new URL(calls[0].url);
expect(url.searchParams.get('category')).toBe('Travel');
expect(url.searchParams.get('limit')).toBe('10');
});
});
Complete Test Suite Example
Here’s a complete, runnable test file:expense-api.e2e.ts
import { test, expect, httpMock, httpResponse } from '@frontmcp/testing';
test.use({
server: './src/main.ts',
port: 3003,
});
let interceptor: ReturnType<typeof httpMock.interceptor>;
// OpenAPI spec - define once, reuse
const openApiSpec = {
openapi: '3.0.0',
info: { title: 'Expense API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/expenses': {
get: { operationId: 'listExpenses', summary: 'List expenses' },
post: { operationId: 'createExpense', summary: 'Create expense' },
},
'/expenses/{id}': {
get: { operationId: 'getExpense', summary: 'Get expense' },
delete: { operationId: 'deleteExpense', summary: 'Delete expense' },
},
},
};
beforeEach(() => {
interceptor = httpMock.interceptor();
// Always mock the spec endpoint
interceptor.get('https://api.example.com/openapi.json', {
body: openApiSpec,
});
});
afterEach(() => {
interceptor.restore();
});
test.describe('Expense API OpenAPI Adapter', () => {
test('lists all expenses', async ({ mcp }) => {
interceptor.get('https://api.example.com/expenses', {
body: [
{ id: '1', amount: 50, category: 'Travel' },
{ id: '2', amount: 100, category: 'Meals' },
],
});
const result = await mcp.tools.call('expense-api:listExpenses', {});
expect(result).toBeSuccessful();
expect(result.json()).toHaveLength(2);
});
test('creates an expense', async ({ mcp }) => {
interceptor.post('https://api.example.com/expenses', {
status: 201,
body: { id: 'new-1', amount: 75, category: 'Office' },
});
const result = await mcp.tools.call('expense-api:createExpense', {
amount: 75,
category: 'Office',
});
expect(result).toBeSuccessful();
expect(result.json().id).toBe('new-1');
});
test('gets expense by ID', async ({ mcp }) => {
interceptor.get('https://api.example.com/expenses/123', {
body: { id: '123', amount: 200, category: 'Equipment' },
});
const result = await mcp.tools.call('expense-api:getExpense', { id: '123' });
expect(result).toBeSuccessful();
expect(result.json().amount).toBe(200);
});
test('deletes an expense', async ({ mcp }) => {
interceptor.delete('https://api.example.com/expenses/456', {
status: 204,
});
const result = await mcp.tools.call('expense-api:deleteExpense', { id: '456' });
expect(result).toBeSuccessful();
});
test('handles API errors gracefully', async ({ mcp }) => {
interceptor.get('https://api.example.com/expenses', {
status: 500,
body: { error: 'Database connection failed' },
});
const result = await mcp.tools.call('expense-api:listExpenses', {});
expect(result).toBeError();
});
});
Best Practices
Always mock the spec endpoint
Always mock the spec endpoint
The OpenAPI spec is fetched when the server starts. Mock it in
beforeEach:beforeEach(() => {
interceptor.get('/openapi.json', { body: openApiSpec });
});
Use realistic mock data
Use realistic mock data
Mock responses should match the schema defined in your OpenAPI spec:
// Match the schema
{ id: 'string', amount: 123.45, category: 'Travel' }
// Don't use invalid types
{ id: 123, amount: 'fifty dollars' } // Wrong!
Verify request details
Verify request details
Use call tracking to verify headers, body, and URL:
const handle = interceptor.post('/api/data', { body: {} });
await mcp.tools.call('tool', input);
const calls = handle.calls();
expect(calls[0].headers['content-type']).toBe('application/json');
expect(calls[0].body).toEqual(expectedBody);
Test all error codes
Test all error codes
Cover 4xx and 5xx responses:
- 400 Bad Request (validation errors)
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 429 Rate Limited
- 500 Server Error
Next Steps
HTTP Mocking Reference
Complete HTTP mocking API
OpenAPI Adapter
Full adapter configuration
Testing Overview
Testing framework basics
Test Authentication
Testing with auth tokens