@debugmcp/mcp-debugger
Version:
Run-time step-through debugging for LLM agents.
407 lines (319 loc) • 10.4 kB
Markdown
# MCP Debug Server - Testing Architecture
This document describes the testing architecture, patterns, and strategies used in the MCP Debug Server project, which has achieved 90%+ test coverage.
## Test Framework and Configuration
### Technology Stack
- **Test Runner**: Vitest 1.3.1
- **Coverage Tool**: @vitest/coverage-v8
- **Assertion Library**: Vitest's built-in expect
- **Mocking**: Vitest's vi mocking utilities
- **Configuration**: `vitest.config.ts`
### Coverage Achievement
From `COVERAGE_SUMMARY.md`:
- **Overall Coverage**: 90.42%
- **Statements**: 6,517 of 7,208 (90.42%)
- **Branches**: 826 of 970 (85.15%)
- **Functions**: 704 of 782 (90.03%)
- **Lines**: 6,390 of 7,076 (90.31%)
## Test Organization
### Directory Structure
```
tests/
├── unit/ # Unit tests (mirrors src/ structure)
│ ├── proxy/ # ProxyManager tests
│ ├── session/ # SessionManager tests
│ ├── dap-core/ # Functional core tests
│ └── ...
├── integration/ # Integration tests
│ ├── proxy-startup.test.ts
│ └── python_debug_workflow.test.ts
├── e2e/ # End-to-end tests
│ └── debugpy-connection.test.ts
├── implementations/test/ # Fake implementations
├── mocks/ # Shared mocks
└── utils/ # Test utilities
```
## Key Testing Patterns
### 1. Fake Implementations Pattern
**Location**: `tests/implementations/test/fake-process-launcher.ts`
The project uses sophisticated fake implementations for testing process-spawning code:
```typescript
export class FakeProxyProcess extends EventEmitter implements IProxyProcess {
pid: number;
killed = false;
private messageQueue: any[] = [];
sentCommands: any[] = [];
simulateMessage(message: any): void {
this.emit('message', message);
}
simulateExit(code: number | null, signal?: string | null): void {
this.emit('exit', code, signal);
}
sendCommand(command: any): void {
this.sentCommands.push(command);
if (typeof command === 'object') {
this.emit('message', JSON.stringify(command));
}
}
}
```
This pattern enables testing complex process interactions without spawning real processes.
### 2. Test Lifecycle Management
**Example**: `tests/unit/proxy/proxy-manager-lifecycle.test.ts`
```typescript
describe('ProxyManager - Lifecycle', () => {
let proxyManager: ProxyManager;
let fakeLauncher: FakeProxyProcessLauncher;
let mockLogger: ILogger;
let mockFileSystem: IFileSystem;
beforeEach(() => {
fakeLauncher = new FakeProxyProcessLauncher();
mockLogger = createMockLogger();
mockFileSystem = createMockFileSystem();
proxyManager = new ProxyManager(
fakeLauncher,
mockFileSystem,
mockLogger
);
});
afterEach(() => {
vi.useRealTimers(); // Always restore real timers first
vi.clearAllMocks();
fakeLauncher.reset();
});
});
```
Key practices:
- Fresh instances for each test
- Proper cleanup in afterEach
- Timer restoration when using fake timers
### 3. Async Testing with Timeouts
**Pattern**: Testing timeout scenarios with fake timers
```typescript
it('should handle initialization timeout', async () => {
vi.useFakeTimers();
try {
// Start the async operation that will timeout
const startPromise = proxyManager.start(defaultConfig);
// Immediately attach rejection expectation
const expectPromise = expect(startPromise).rejects.toThrow(
ErrorMessages.proxyInitTimeout(30)
);
// Advance timers
await vi.advanceTimersByTimeAsync(30001);
// Await the expectation
await expectPromise;
} finally {
vi.useRealTimers();
}
});
```
This pattern ensures proper handling of async operations with timeouts.
### 4. Event Testing Pattern
**Example**: Testing event emissions
```typescript
it('should emit exit event when proxy process exits', async () => {
// Setup
fakeLauncher.prepareProxy((proxy) => {
setTimeout(() => {
proxy.simulateMessage({
type: 'status',
sessionId: defaultConfig.sessionId,
status: 'adapter_configured_and_launched'
});
}, 50);
});
await proxyManager.start(defaultConfig);
// Create promise to capture event
const exitPromise = new Promise<{ code: number; signal?: string }>((resolve) => {
proxyManager.once('exit', (code, signal) => resolve({ code, signal }));
});
// Trigger event
const fakeProxy = fakeLauncher.getLastLaunchedProxy()!;
fakeProxy.simulateExit(0, 'SIGTERM');
// Assert
const result = await exitPromise;
expect(result.code).toBe(0);
expect(result.signal).toBe('SIGTERM');
});
```
### 5. Error Scenario Testing
**Example**: Testing error propagation
```typescript
it('should handle process spawn failure', async () => {
// Mock file system to simulate proxy script not found
vi.mocked(mockFileSystem.pathExists).mockResolvedValue(false);
await expect(proxyManager.start(defaultConfig))
.rejects.toThrow('Bootstrap worker script not found');
});
```
## Test Utilities
### Mock Creation Helpers
**Location**: `tests/utils/test-utils.ts`
```typescript
export function createMockLogger(): ILogger {
return {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn()
};
}
export function createMockFileSystem(): IFileSystem {
return {
readFile: vi.fn().mockResolvedValue(''),
writeFile: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(true),
ensureDir: vi.fn().mockResolvedValue(undefined),
pathExists: vi.fn().mockResolvedValue(true),
// ... other methods
};
}
```
### Port Management
**Location**: `tests/utils/port-manager.ts`
Ensures tests don't conflict on network ports:
```typescript
export class TestPortManager {
private static basePort = 30000;
private static currentPort = TestPortManager.basePort;
private static allocatedPorts = new Set<number>();
static getNextPort(): number {
const port = this.currentPort++;
this.allocatedPorts.add(port);
return port;
}
static reset(): void {
this.currentPort = this.basePort;
this.allocatedPorts.clear();
}
}
```
## Integration Testing Strategy
### Python Debug Workflow Test
**Location**: `tests/integration/python_debug_workflow.test.ts`
Tests the complete debugging flow:
```typescript
it('should debug Python script with breakpoints', async () => {
// Create session
const sessionInfo = await sessionManager.createSession({
language: DebugLanguage.PYTHON,
name: 'Integration Test'
});
// Set breakpoint
const breakpoint = await sessionManager.setBreakpoint(
sessionInfo.id,
testScript,
5
);
// Start debugging
const result = await sessionManager.startDebugging(
sessionInfo.id,
testScript
);
// Wait for breakpoint hit
await waitForCondition(
() => session?.state === SessionState.PAUSED,
5000
);
// Get variables
const stackTrace = await sessionManager.getStackTrace(sessionInfo.id);
const scopes = await sessionManager.getScopes(
sessionInfo.id,
stackTrace[0].id
);
const variables = await sessionManager.getVariables(
sessionInfo.id,
scopes[0].variablesReference
);
// Verify
expect(variables).toContainEqual(
expect.objectContaining({ name: 'x', value: '10' })
);
});
```
## Test Categories
### 1. Unit Tests
- Test individual components in isolation
- Mock all dependencies
- Focus on logic and state management
- Examples: `proxy-manager-lifecycle.test.ts`, `session-manager-dap.test.ts`
### 2. Integration Tests
- Test component interactions
- Use real implementations where possible
- Focus on data flow and protocol compliance
- Examples: `python_debug_workflow.test.ts`, `proxy-startup.test.ts`
### 3. End-to-End Tests
- Test complete scenarios
- Minimal mocking
- Focus on user workflows
- Examples: `debugpy-connection.test.ts`
## Special Testing Considerations
### 1. Process Cleanup
Tests that spawn processes must ensure cleanup:
```typescript
afterEach(async () => {
// Clean up any remaining sessions
await sessionManager.closeAllSessions();
// Verify no orphaned processes
const sessions = sessionManager.getAllSessions();
expect(sessions).toHaveLength(0);
});
```
### 2. Timing-Sensitive Tests
Use helpers for timing-sensitive operations:
```typescript
async function waitForCondition(
condition: () => boolean,
timeout: number,
interval = 100
): Promise<void> {
const start = Date.now();
while (!condition() && Date.now() - start < timeout) {
await new Promise(resolve => setTimeout(resolve, interval));
}
if (!condition()) {
throw new Error(`Condition not met within ${timeout}ms`);
}
}
```
### 3. Error Message Testing
Verify exact error messages using the centralized system:
```typescript
it('should timeout with correct message', async () => {
await expect(operation())
.rejects.toThrow(ErrorMessages.proxyInitTimeout(30));
});
```
## Coverage Analysis
### High Coverage Areas (95%+)
- `src/utils/` - Utility functions
- `src/session/` - Session management
- `src/proxy/` - Proxy management
- `src/dap-core/` - Functional core
### Challenging Coverage Areas
- Error handling paths
- Process crash scenarios
- Network failure cases
- Race conditions
### Coverage Tools
Run coverage analysis:
```bash
npm run test:coverage
```
View detailed report:
```bash
npm run coverage:report
```
## Best Practices
1. **Always clean up resources** - Processes, timers, event listeners
2. **Use fake implementations** - Avoid real process spawning in unit tests
3. **Test error paths** - Ensure error handling is thoroughly tested
4. **Mock at boundaries** - Mock external dependencies, not internal components
5. **Prefer integration tests** - When testing component interactions
6. **Document complex tests** - Add comments explaining test scenarios
7. **Keep tests focused** - One concept per test
8. **Use descriptive names** - Test names should explain what and why
## Next Steps
- See [Development Setup Guide](../development/setup-guide.md) for running tests
- See [Testing Guide](../development/testing-guide.md) for writing new tests
- See [Debugging Guide](../development/debugging-guide.md) for debugging test failures