JavaScript Testing¶
Testing Strategy for JavaScript MCP Applications¶
Comprehensive testing ensures your MCP servers and clients work reliably across different environments and scenarios.
Testing Stack¶
Core Testing Tools¶
# Install testing dependencies
npm install --save-dev \
jest \
@jest/globals \
jest-environment-node \
supertest \
nock \
msw
Jest Configuration¶
// jest.config.js
export default {
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.js'],
globals: {
'ts-jest': {
useESM: true
}
},
moduleNameMapping: {
'^(\\.{1,2}/.*)\\.js$': '$1'
},
transform: {
'^.+\\.js$': ['babel-jest', { presets: [['@babel/preset-env', { targets: { node: 'current' } }]] }]
},
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/test-helpers/**'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
testMatch: [
'**/src/**/__tests__/**/*.js',
'**/src/**/*.test.js'
],
setupFilesAfterEnv: ['<rootDir>/src/test-setup.js']
};
Unit Testing MCP Servers¶
Testing Tool Handlers¶
// src/tools/__tests__/database-tools.test.js
import { jest } from '@jest/globals';
import { DatabaseTools } from '../database-tools.js';
describe('DatabaseTools', () => {
let dbTools;
let mockDatabase;
beforeEach(() => {
mockDatabase = {
query: jest.fn(),
connect: jest.fn(),
close: jest.fn()
};
dbTools = new DatabaseTools(mockDatabase);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('executeQuery', () => {
it('should execute valid SELECT query', async () => {
const mockResults = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];
mockDatabase.query.mockResolvedValue({ rows: mockResults });
const result = await dbTools.executeQuery({
query: 'SELECT * FROM users',
limit: 10
});
expect(mockDatabase.query).toHaveBeenCalledWith(
'SELECT * FROM users LIMIT $1',
[10]
);
expect(result).toEqual({
content: [{
type: 'text',
text: JSON.stringify(mockResults, null, 2)
}]
});
});
it('should reject dangerous queries', async () => {
await expect(dbTools.executeQuery({
query: 'DROP TABLE users'
})).rejects.toThrow('Dangerous query detected');
expect(mockDatabase.query).not.toHaveBeenCalled();
});
it('should handle database connection errors', async () => {
mockDatabase.query.mockRejectedValue(new Error('Connection failed'));
await expect(dbTools.executeQuery({
query: 'SELECT 1'
})).rejects.toThrow('Database error: Connection failed');
});
it('should validate input parameters', async () => {
await expect(dbTools.executeQuery({})).rejects.toThrow('Query is required');
await expect(dbTools.executeQuery({
query: ''
})).rejects.toThrow('Query cannot be empty');
await expect(dbTools.executeQuery({
query: 'SELECT * FROM users',
limit: -1
})).rejects.toThrow('Limit must be positive');
});
});
describe('listTables', () => {
it('should return available tables', async () => {
const mockTables = ['users', 'products', 'orders'];
mockDatabase.query.mockResolvedValue({
rows: mockTables.map(name => ({ table_name: name }))
});
const result = await dbTools.listTables();
expect(result.content[0].text).toContain('users');
expect(result.content[0].text).toContain('products');
expect(result.content[0].text).toContain('orders');
});
});
});
Testing HTTP Tools¶
// src/tools/__tests__/http-tools.test.js
import nock from 'nock';
import { HTTPTools } from '../http-tools.js';
describe('HTTPTools', () => {
let httpTools;
beforeEach(() => {
httpTools = new HTTPTools();
nock.cleanAll();
});
afterEach(() => {
nock.cleanAll();
});
describe('fetchUrl', () => {
it('should fetch URL successfully', async () => {
const mockData = { message: 'Hello, World!' };
nock('https://api.example.com')
.get('/test')
.reply(200, mockData);
const result = await httpTools.fetchUrl({
url: 'https://api.example.com/test'
});
expect(result.content[0].text).toContain(JSON.stringify(mockData));
});
it('should handle HTTP errors', async () => {
nock('https://api.example.com')
.get('/error')
.reply(404, { error: 'Not found' });
await expect(httpTools.fetchUrl({
url: 'https://api.example.com/error'
})).rejects.toThrow('HTTP 404');
});
it('should handle network timeouts', async () => {
nock('https://api.example.com')
.get('/slow')
.delay(6000)
.reply(200, 'OK');
await expect(httpTools.fetchUrl({
url: 'https://api.example.com/slow',
timeout: 1000
})).rejects.toThrow('timeout');
});
it('should validate URLs', async () => {
await expect(httpTools.fetchUrl({
url: 'not-a-url'
})).rejects.toThrow('Invalid URL');
await expect(httpTools.fetchUrl({
url: 'javascript:alert("xss")'
})).rejects.toThrow('Invalid protocol');
});
});
describe('postData', () => {
it('should send POST requests with JSON data', async () => {
const requestData = { name: 'John', email: 'john@example.com' };
const responseData = { id: 123, status: 'created' };
nock('https://api.example.com')
.post('/users', requestData)
.reply(201, responseData);
const result = await httpTools.postData({
url: 'https://api.example.com/users',
data: requestData
});
expect(result.content[0].text).toContain(JSON.stringify(responseData));
});
});
});
Integration Testing¶
Testing Complete MCP Server¶
// src/__tests__/server.integration.test.js
import { jest } from '@jest/globals';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { MCPServer } from '../server.js';
describe('MCP Server Integration', () => {
let server;
let mockTransport;
beforeEach(async () => {
mockTransport = {
start: jest.fn(),
close: jest.fn(),
send: jest.fn(),
onmessage: null,
onerror: null,
onclose: null
};
server = new MCPServer('test-server', '1.0.0');
});
afterEach(async () => {
if (server) {
await server.close();
}
});
describe('Tool Discovery', () => {
it('should list all registered tools', async () => {
const response = await simulateRequest({
jsonrpc: '2.0',
method: 'tools/list',
id: 1
});
expect(response.result.tools).toBeInstanceOf(Array);
expect(response.result.tools.length).toBeGreaterThan(0);
const toolNames = response.result.tools.map(t => t.name);
expect(toolNames).toContain('database_query');
expect(toolNames).toContain('http_fetch');
});
it('should provide complete tool schemas', async () => {
const response = await simulateRequest({
jsonrpc: '2.0',
method: 'tools/list',
id: 1
});
const dbTool = response.result.tools.find(t => t.name === 'database_query');
expect(dbTool).toBeDefined();
expect(dbTool.description).toBeTruthy();
expect(dbTool.inputSchema).toBeDefined();
expect(dbTool.inputSchema.properties).toBeDefined();
});
});
describe('Tool Execution', () => {
it('should execute tools with valid parameters', async () => {
const response = await simulateRequest({
jsonrpc: '2.0',
method: 'tools/call',
params: {
name: 'echo',
arguments: { text: 'Hello, World!' }
},
id: 1
});
expect(response.result.content).toBeInstanceOf(Array);
expect(response.result.content[0].text).toContain('Hello, World!');
});
it('should handle tool errors gracefully', async () => {
const response = await simulateRequest({
jsonrpc: '2.0',
method: 'tools/call',
params: {
name: 'database_query',
arguments: { query: 'INVALID SQL' }
},
id: 1
});
expect(response.error).toBeDefined();
expect(response.error.message).toContain('syntax error');
});
it('should validate tool arguments', async () => {
const response = await simulateRequest({
jsonrpc: '2.0',
method: 'tools/call',
params: {
name: 'echo',
arguments: {} // Missing required 'text' parameter
},
id: 1
});
expect(response.error).toBeDefined();
expect(response.error.message).toContain('required');
});
});
// Helper function to simulate JSON-RPC requests
async function simulateRequest(request) {
return new Promise((resolve) => {
const handler = server.server.getRequestHandler(request.method);
handler(request).then(result => {
resolve({
jsonrpc: '2.0',
id: request.id,
result
});
}).catch(error => {
resolve({
jsonrpc: '2.0',
id: request.id,
error: {
code: -32603,
message: error.message
}
});
});
});
}
});
Testing Browser Clients¶
Mock Service Worker Setup¶
// src/test-helpers/msw-setup.js
import { setupServer } from 'msw/node';
import { rest } from 'msw';
// Mock MCP server responses
export const server = setupServer(
rest.post('http://localhost:8000/mcp', (req, res, ctx) => {
const { method, params } = req.body;
if (method === 'tools/list') {
return res(
ctx.json({
jsonrpc: '2.0',
id: req.body.id,
result: {
tools: [
{
name: 'echo',
description: 'Echo back text',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' }
},
required: ['text']
}
}
]
}
})
);
}
if (method === 'tools/call' && params.name === 'echo') {
return res(
ctx.json({
jsonrpc: '2.0',
id: req.body.id,
result: {
content: [{
type: 'text',
text: `Echo: ${params.arguments.text}`
}]
}
})
);
}
return res(
ctx.status(404),
ctx.json({
jsonrpc: '2.0',
id: req.body.id,
error: {
code: -32601,
message: 'Method not found'
}
})
);
})
);
// Start server before all tests
beforeAll(() => server.listen());
// Reset handlers after each test
afterEach(() => server.resetHandlers());
// Clean up after all tests
afterAll(() => server.close());
Browser Client Testing¶
// src/__tests__/browser-client.test.js
import { screen, fireEvent, waitFor } from '@testing-library/dom';
import '@testing-library/jest-dom';
import { MCPWebClient } from '../browser-client.js';
// Mock fetch globally
global.fetch = jest.fn();
describe('Browser MCP Client', () => {
let client;
let mockContainer;
beforeEach(() => {
// Create DOM container for testing
mockContainer = document.createElement('div');
mockContainer.innerHTML = `
<input id="serverUrl" value="http://localhost:8000/mcp">
<button id="connectBtn">Connect</button>
<div id="connectionStatus"></div>
<div id="toolsList"></div>
<div id="results"></div>
`;
document.body.appendChild(mockContainer);
client = new MCPWebClient();
// Reset fetch mock
fetch.mockClear();
});
afterEach(() => {
document.body.removeChild(mockContainer);
});
describe('Connection', () => {
it('should connect to MCP server successfully', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
jsonrpc: '2.0',
id: 1,
result: {}
})
});
const connectBtn = screen.getByText('Connect');
fireEvent.click(connectBtn);
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'http://localhost:8000/mcp',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
);
});
});
it('should handle connection errors', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
const connectBtn = screen.getByText('Connect');
fireEvent.click(connectBtn);
await waitFor(() => {
const status = screen.getByText(/Connection failed/);
expect(status).toBeInTheDocument();
});
});
});
describe('Tool Management', () => {
beforeEach(async () => {
// Mock successful connection
fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
jsonrpc: '2.0',
id: 1,
result: {
tools: [{
name: 'echo',
description: 'Echo text',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' }
},
required: ['text']
}
}]
}
})
});
await client.connect();
});
it('should load and display tools', async () => {
await waitFor(() => {
expect(screen.getByText('echo')).toBeInTheDocument();
expect(screen.getByText('Echo text')).toBeInTheDocument();
});
});
it('should generate input fields from schema', async () => {
await waitFor(() => {
const textInput = screen.getByPlaceholderText(/text/i);
expect(textInput).toBeInTheDocument();
expect(textInput.type).toBe('text');
});
});
it('should call tools with user input', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
jsonrpc: '2.0',
id: 2,
result: {
content: [{ type: 'text', text: 'Echo: Hello' }]
}
})
});
await waitFor(() => {
const textInput = screen.getByPlaceholderText(/text/i);
fireEvent.change(textInput, { target: { value: 'Hello' } });
const callBtn = screen.getByText('Call Tool');
fireEvent.click(callBtn);
});
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'http://localhost:8000/mcp',
expect.objectContaining({
body: expect.stringContaining('"arguments":{"text":"Hello"}')
})
);
});
});
});
});
Performance Testing¶
Load Testing Tools¶
// src/__tests__/performance.test.js
import { performance } from 'perf_hooks';
import { MCPServer } from '../server.js';
describe('Performance Tests', () => {
let server;
beforeEach(() => {
server = new MCPServer('perf-test-server', '1.0.0');
});
describe('Response Times', () => {
it('should respond to tool calls within acceptable time', async () => {
const startTime = performance.now();
await server.handleToolCall('echo', { text: 'performance test' });
const endTime = performance.now();
const responseTime = endTime - startTime;
expect(responseTime).toBeLessThan(100); // 100ms threshold
});
it('should handle concurrent requests efficiently', async () => {
const numberOfRequests = 100;
const requests = Array.from({ length: numberOfRequests }, (_, i) =>
server.handleToolCall('echo', { text: `request ${i}` })
);
const startTime = performance.now();
await Promise.all(requests);
const endTime = performance.now();
const totalTime = endTime - startTime;
const avgResponseTime = totalTime / numberOfRequests;
expect(avgResponseTime).toBeLessThan(50); // Average under 50ms
});
});
describe('Memory Usage', () => {
it('should not leak memory during repeated operations', async () => {
const initialMemory = process.memoryUsage().heapUsed;
// Perform many operations
for (let i = 0; i < 1000; i++) {
await server.handleToolCall('echo', { text: `iteration ${i}` });
}
// Force garbage collection if available
if (global.gc) {
global.gc();
}
const finalMemory = process.memoryUsage().heapUsed;
const memoryIncrease = finalMemory - initialMemory;
// Memory increase should be reasonable (less than 10MB)
expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024);
});
});
});
Test Coverage and Reporting¶
Coverage Configuration¶
// jest.config.js (coverage section)
export default {
// ... other config
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/**/index.js',
'!src/test-helpers/**'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
'./src/tools/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90
}
}
};
Custom Test Reporters¶
// src/test-helpers/custom-reporter.js
export default class CustomReporter {
constructor(globalConfig, options) {
this._globalConfig = globalConfig;
this._options = options;
}
onRunComplete(contexts, results) {
const { numTotalTests, numPassedTests, numFailedTests, testResults } = results;
console.log('\n=== Test Summary ===');
console.log(`Total Tests: ${numTotalTests}`);
console.log(`Passed: ${numPassedTests}`);
console.log(`Failed: ${numFailedTests}`);
if (numFailedTests > 0) {
console.log('\n=== Failed Tests ===');
testResults.forEach(suite => {
suite.testResults.forEach(test => {
if (test.status === 'failed') {
console.log(`โ ${suite.testFilePath}: ${test.fullName}`);
test.failureMessages.forEach(msg => {
console.log(` ${msg}`);
});
}
});
});
}
console.log(`\nTest run ${numFailedTests === 0 ? 'โ
PASSED' : 'โ FAILED'}`);
}
}
CI/CD Integration¶
GitHub Actions Testing¶
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 21]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run type check
run: npm run type-check
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
Best Practices¶
Test Organization¶
- Describe Blocks: Group related tests logically
- Clear Names: Use descriptive test names that explain the behavior
- Arrange-Act-Assert: Structure tests with clear setup, execution, and verification
- One Assertion: Focus each test on a single behavior
Mocking Strategy¶
- External Dependencies: Mock all external services and APIs
- Database: Use in-memory databases or mocks for testing
- File System: Mock file operations to avoid test pollution
- Time: Mock time-dependent functions for predictable tests
Performance¶
- Parallel Execution: Run tests in parallel when possible
- Test Isolation: Ensure tests don't depend on each other
- Fast Feedback: Prioritize unit tests for quick feedback
- Resource Cleanup: Clean up resources after tests
Testing is essential for building reliable MCP applications. Focus on comprehensive coverage, realistic scenarios, and maintainable test code.