This guide covers how to write and run tests for the MCP Debug Server project.
Two companion documents cover what this one deliberately does not:
tests/README.md— the directory layout, the full command list, and where a new test file belongs.docs/architecture/testing-architecture.md— the why behind the mock architecture, the project split, and the coverage/type gates.
The project uses Vitest, for its native ESM support, Jest-compatible API, built-in TypeScript handling, and watch mode.
Vitest is configured with three projects in vitest.config.ts, and you select one with --project — not --exclude, which Vitest silently ignores once projects is set:
| Project | Contents | Parallelism | Timeout |
|---|---|---|---|
unit |
hermetic, millisecond-scale tests | files run in parallel (forks, no worker cap) |
15 s |
integration |
tests/**/integration/** and tests/stress/** |
serial (maxWorkers: 1) |
30 s |
e2e |
tests/e2e/** — smoke, Docker, npx |
serial (maxWorkers: 1) |
30 s |
The practical consequence when writing a test: a unit test must be hermetic. It shares no port, no temp directory, and no process.env with the file running beside it, because there probably is a file running beside it. Anything that spawns a real process or binds a real port belongs in integration or e2e, where the serial pool protects it.
# Everything (builds first, then runs all three projects)
npm test
# One project
npm run test:unit
npm run test:integration
npm run test:e2e
# Watch mode
npm run test:watch
# Coverage
npm run test:coverage
# A single file
npx vitest run tests/core/unit/session/session-manager-workflow.test.ts
# Tests whose NAME matches a pattern (there is no --grep flag in Vitest)
npx vitest run -t "should complete full debug workflow"-t is short for --testNamePattern; it filters on the describe/it text, not the file path. To filter by path, pass the path — a bare positional argument is a substring filter on the filename, not a glob.
npm run test:coverage # full run; HTML report at coverage/index.html
npm run test:coverage:summary # summary table
npm run test:coverage:analyze # detailed analysisCoverage uses the istanbul provider and emits text, json, html, and json-summary reports. Thresholds are enforced in vitest.config.ts — 90% statements and 80% branches — so a run below either number fails rather than merely reporting. The margins exist to absorb platform-specific branches (win32-only arms are uncovered on Linux and vice versa); raise them when the measured numbers rise, never lower them to admit a regression.
Coverage is configured at the root of the config, not inside a project. A per-project coverage block is silently ignored.
Type checking is a separate gate from the test run, and it is the one most likely to block your push unexpectedly.
pnpm run typecheck # src + packages/*/src — must be clean
pnpm run typecheck:tests # the test trees, ratcheted
pnpm run typecheck:tests:raw # raw tsc output for tsconfig.spec.json
pnpm run typecheck:tests:update # re-record the baseline
pnpm run typecheck:all # both — exactly what CI and pre-push runtsconfig.spec.json type-checks the test trees alongside the sources. It does not pass today — the suite carries a backlog of mock/type divergences — so scripts/typecheck-tests-ratchet.mjs gates on the per-file error count recorded in tests/typecheck-baseline.json instead of demanding zero.
The ratchet fails in both directions:
- A count went up. You introduced type errors in that file. Fix them. Re-recording the baseline is the exception, not the remedy.
- A count went down, or a test file was removed. That is progress, but the baseline is now stale: run
pnpm run typecheck:tests:updateand committests/typecheck-baseline.jsonin the same change.
Two things that will bite you otherwise:
- Pre-push refuses to run while
tests/typecheck-baseline.jsonis modified but uncommitted. The ratchet validates your working tree; CI validates the pushed commit. An uncommitted refresh would pass locally and fail in CI, so.husky/pre-pushblocks that state outright. - If the ratchet cannot read the baseline at all (invalid JSON, wrong shape, a path outside the test trees) it exits 2 — "could not be run", as distinct from 1, "ratchet failure" — and tells you to delete the file first.
--updatereads the baseline before writing one, so a corrupt file blocks its own repair.
Worth knowing before you wonder why a push takes four minutes:
.husky/pre-commitruns no tests and no build — only the personal-paths check, the build-artifact/tarball guards, and an optional docstar check. Commits stay cheap..husky/pre-pushruns lint → the uncommitted-baseline guard →typecheck:all→ a clean build →test:unit+test:integration. The e2e suite is left to CI.- The
Lint CodeCI job runs ESLint →typecheck:all→ the personal-path and changelog gates. No build needed.
Tests follow the source structure loosely, across several test roots rather than one mirrored tree:
src/session/session-manager*.ts
→ tests/core/unit/session/session-manager-workflow.test.ts
→ tests/core/unit/session/session-manager-state.test.ts
→ tests/core/unit/session/session-manager-dap.test.ts (…and ~30 more slices)
src/session/launch/proxy-launcher.ts
→ tests/core/unit/session/launch/proxy-launcher.test.ts
src/proxy/proxy-manager.ts
→ tests/unit/proxy/proxy-manager.start.test.ts
→ tests/unit/proxy/proxy-manager.handshake.test.ts
→ tests/unit/proxy/proxy-manager-message-handling.test.ts
→ tests/unit/proxy/proxy-manager.branch-coverage.test.ts
Note the roots: tests/core/unit/ holds the session/server/factory core, tests/unit/ holds everything else (adapters, CLI, DI container, DAP core, proxy, utils, and repo tooling), tests/adapters/{lang}/ holds per-language tests, and most adapter packages additionally carry packages/adapter-{lang}/tests/ — adapter-java is the exception, its tests live only under tests/adapters/java/unit/. There is no tests/unit/session/. See tests/README.md for the full map.
File naming: *.test.ts or *.spec.ts. Both are picked up.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ComponentToTest } from '../../../src/component-to-test.js';
describe('ComponentToTest', () => {
let component: ComponentToTest;
let mockDependency: MockType;
beforeEach(() => {
mockDependency = createMockDependency();
component = new ComponentToTest(mockDependency);
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers(); // if the test used fake timers
});
describe('methodName', () => {
it('should handle normal case', async () => {
// Arrange
const input = 'test-input';
// Act
const result = await component.methodName(input);
// Assert
expect(result).toBe('expected-output');
expect(mockDependency.someMethod).toHaveBeenCalledWith(input);
});
it('should handle error case', async () => {
mockDependency.someMethod.mockRejectedValue(new Error('Test error'));
await expect(component.methodName('input')).rejects.toThrow('Test error');
});
});
});tests/vitest.setup.ts already calls vi.resetAllMocks(), vi.restoreAllMocks(), and vi.unstubAllEnvs() after every test, so a local afterEach is for your resources — sessions, proxy managers, fake launchers — not for the global mock slate.
import {
createMockDependencies,
createMockLogger,
createMockFileSystem
} from '../../test-utils/helpers/test-dependencies.js';
const deps = createMockDependencies(); // the whole DI container, every method a vi.fn()
const mockLogger = createMockLogger(); // or a single seam
expect(mockLogger.info).toHaveBeenCalledWith(
'[Component] Operation completed',
{ sessionId: 'test-123' }
);Watch which createMockFileSystem you import. Two exist and they behave differently:
| Import from | Behavior |
|---|---|
test-utils/helpers/test-dependencies.js |
every member is a bare vi.fn() — configure return values per test |
test-utils/helpers/test-utils.js |
pre-configured defaults (pathExists/exists resolve true, readFile resolves '', stat resolves a mock stat object) and also stubs createWriteStream/createReadStream |
Pick the one that matches what your test asserts. A test that fails with undefined is not a function on a stream, or that mysteriously finds every path existing, has usually imported the other one.
Before hand-rolling as unknown as SomeInterface, check whether a compile-checked fake already exists — that cast silences exactly the divergence the double is there to catch.
tests/test-utils/fakes/fake-debug-adapter.ts—FakeDebugAdapter, a class thatimplements IDebugAdapter, so the compiler rejects it the moment the interface moves. Required members arevi.fns with production-shaped defaults; the optional ones are absent unless you opt in withwithAttachSupport()/withLaunchBarrier(), because production guards them (adapter.supportsAttach?.()) and a double that always defined them would only ever exercise one branch.tests/test-utils/mocks/mock-proxy-manager.ts—MockProxyManagerdoes the same job forIProxyManager(it predates thefakes/directory).tests/test-utils/mocks/*—vi.fnbags for narrow seams, where an untyped literal costs little and reads more directly.
See tests/README.md — Fakes vs mocks — for the rule of thumb.
Every proxy retry/timeout path in the unit suite runs on fake timers. That is not a style preference — it is what let the unit project go parallel with a 15 s ceiling, so a test that burns real backoff is a regression.
it('should timeout after the configured duration', async () => {
vi.useFakeTimers();
try {
const operationPromise = component.operationWithTimeout();
// Create the expectation BEFORE advancing time, or the rejection
// fires with nothing attached to it.
const expectPromise = expect(operationPromise).rejects.toThrow('Operation timed out');
await vi.advanceTimersByTimeAsync(5001);
await expectPromise;
} finally {
vi.useRealTimers();
}
});vi.runAllTimersAsync() flushes everything pending, which is the usual choice when you just need the async machinery to settle.
For real-time async state, use waitUntil rather than a fixed delay() — it returns as soon as the condition holds, and fails with a useful message if it never does, instead of surfacing as a flaky assertion downstream:
import { waitUntil } from '../../test-utils/helpers/test-utils.js';
await waitUntil(() => session.state === SessionState.PAUSED, {
timeout: 5000,
interval: 50,
message: 'session to pause'
});it('should emit events correctly', async () => {
const eventPromise = new Promise<{ data: string }>((resolve) => {
component.once('data-ready', (data) => resolve({ data }));
});
component.processData();
const result = await eventPromise;
expect(result.data).toBe('processed');
});waitForEvent(emitter, event, timeout) in tests/test-utils/helpers/test-utils.ts wraps that pattern.
Never attach a listener to process and leave it there. The setup file compares process.rawListeners against a baseline after every test, removes anything leaked, and logs a [process-listener-leak] error — leaked process listeners can hard-kill the Vitest fork worker (issue #159). Capture handlers with mockImplementation instead of attaching them, or remove them in afterEach. Run npm run test:strict (LEAK_GUARD_STRICT=1) to turn that warning into a failure.
// tests/implementations/test/fake-process-launcher.ts
import { FakeProxyProcessLauncher } from '../../implementations/test/fake-process-launcher.js';
it('should handle process messages', async () => {
const fakeLauncher = new FakeProxyProcessLauncher();
fakeLauncher.prepareProxy((proxy) => {
// The setup callback runs at PREPARE time — before the proxy is launched
// and before ProxyManager subscribes. `simulateMessage` emits synchronously,
// so calling it inline here would emit to nobody. Defer it.
setTimeout(() => {
proxy.simulateMessage({ type: 'status', status: 'initialized' });
}, 100);
});
// ProxyManager(adapter, proxyProcessLauncher, fileSystem, logger)
const manager = new ProxyManager(null, fakeLauncher, mockFileSystem, mockLogger);
await manager.start(config);
expect(fakeLauncher.launchedProxies).toHaveLength(1);
});This exercises the real proxy lifecycle — start, init handshake, DAP routing, exit — without spawning a Node subprocess, which is what keeps it in the parallel unit project.
The same sequencing rule applies to every simulate* helper on the mocks and fakes: they are a bare this.emit(...), so the listener must already be attached. MockProxyManager.simulateStopped(...) works inline in the worked example below precisely because startDebugging has already returned by then.
SessionManagerOperations is a facade of thin delegates over collaborators (launcher, proxyLauncher, execution, …) held as protected fields. Driving or spying on one needs a cast, and that cast lives in exactly one place:
import { internals } from '../../test-utils/helpers/operations-internals.js';
const startSpy = vi.spyOn(internals(sessionManager).proxyLauncher, 'start');Add to OperationsInternals rather than re-casting in your file — two suites had already grown byte-identical copies of the cast, and the copies had started to disagree about which collaborators they named.
describe('error handling', () => {
it('should handle file not found', async () => {
vi.mocked(mockFileSystem.pathExists).mockResolvedValue(false);
await expect(component.loadFile('missing.txt'))
.rejects.toThrow('File not found: missing.txt');
});
it('should clean up on error', async () => {
vi.mocked(mockService.connect).mockRejectedValue(new Error('Connection failed'));
await expect(component.initialize()).rejects.toThrow();
expect(component.isInitialized()).toBe(false);
expect(mockService.disconnect).toHaveBeenCalled();
});
it('should use centralized error messages', async () => {
// src/utils/error-messages.ts — assert against the constructor, not a
// copied string, so a reworded message updates both sides at once.
await expect(component.operationWithTimeout())
.rejects.toThrow(ErrorMessages.proxyInitTimeout(30));
});
});The API shapes below are the real ones. startDebugging is positional; setBreakpoint takes a breakpoint object.
// tests/core/unit/session/session-manager-workflow.test.ts
// Session workflow tests use createMockDependencies from the LOCAL helper because
// it returns a different shape: SessionManagerDependencies plus the individual
// mocks (mockProxyManager, mockFileSystem, ...), and it supplies environment,
// pathUtils and adapterRegistry. The test-utils/helpers version returns the DI
// Dependencies (fileSystem, processManager, networkManager, logger,
// proxyProcessLauncher, proxyManagerFactory, sessionStoreFactory) and none of
// those three. Neither helper calls vi.mock; both build vi.fn()-based objects.
import { createMockDependencies } from './session-manager-test-utils.js';
import { SessionManager, SessionManagerConfig } from '../../../../src/session/session-manager.js';
import { DebugLanguage, SessionState } from '@debugmcp/shared';
describe('SessionManager - Debug Session Workflow', () => {
let sessionManager: SessionManager;
let dependencies: ReturnType<typeof createMockDependencies>;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
dependencies = createMockDependencies();
const config: SessionManagerConfig = {
logDirBase: '/tmp/test-sessions',
defaultDapLaunchArgs: { stopOnEntry: true, justMyCode: true }
};
sessionManager = new SessionManager(config, dependencies);
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
dependencies.mockProxyManager.reset();
});
it('should complete create → start → breakpoint → step', async () => {
const session = await sessionManager.createSession({
language: DebugLanguage.MOCK,
name: 'Workflow Test'
});
// startDebugging(sessionId, scriptPath, scriptArgs?, dapLaunchArgs?,
// dryRunSpawn?, adapterLaunchConfig?, breakOnExceptions?)
const startPromise = sessionManager.startDebugging(
session.id,
'test.py',
[],
{ stopOnEntry: true }
);
// Let the mock proxy emit its events.
await vi.runAllTimersAsync();
const startResult = await startPromise;
expect(startResult.success).toBe(true);
expect(startResult.state).toBe(SessionState.PAUSED);
// setBreakpoint(sessionId, { file, line, condition?, suspendPolicy?,
// logMessage?, requestedLine?, anchor? })
const { breakpoint } = await sessionManager.setBreakpoint(session.id, {
file: 'test.py',
line: 15
});
expect(breakpoint.verified).toBe(true);
// Step methods take only (sessionId).
dependencies.mockProxyManager.simulateStopped(1, 'entry');
const stepResult = await sessionManager.stepOver(session.id);
expect(stepResult.success).toBe(true);
});
});A dry run is the fifth positional argument, and is the cheapest way to assert on spawn configuration without a debuggee:
const result = await sessionManager.startDebugging(session.id, 'test.py', [], {}, true);
expect((result.data as { dryRun?: boolean })?.dryRun).toBe(true);tests/test-utils/helpers/test-dependencies.ts exports createMockDependencies() — a complete DI container matching the Dependencies interface (fileSystem, processManager, networkManager, logger, proxyProcessLauncher, proxyManagerFactory, sessionStoreFactory) — plus the narrower createMockLogger(), createMockFileSystem(), createMockProcessManager(), createMockNetworkManager(), createMockProxyProcessLauncher(), and createMockEnvironment().
tests/test-utils/helpers/test-utils.ts exports delay(), waitUntil(), waitForEvent(), and its own (pre-configured) createMockLogger() / createMockFileSystem().
tests/unit/test-utils/auto-mock.ts generates a mock from a class or instance (createMockFromInterface), validates a mock's shape against the real implementation (validateMockInterface), and combines the two (createValidatedMock).
Python script content lives as TypeScript string constants in tests/test-utils/fixtures/python-scripts.ts (simpleLoopScript, functionCallScript, fibonacciScript, exceptionHandlingScript, multiModuleMainScript, multiModuleHelperScript, buggyScript). Actual fixture files live in tests/fixtures/, in four directories:
tests/fixtures/
├── adversarial-adapter/ # server.mjs — a scripted TCP DAP adapter that misbehaves on demand
├── debug-scripts/ # small debuggees (simple.py, with-variables.py, with-errors.py,
│ # simple-mock.js, js-throws.js, ruby-clean-exit.rb, …)
├── javascript-e2e/ # TypeScript fixture + its tsconfig
└── python/ # Python debuggees for attach/E2E (debugpy_server.py,
# debug_test_simple.py, attach_then_raise.py)
The adversarial adapter is worth knowing about: it is a deterministic DAP scenario player driven by a JSON scenario (dropResponse, eventsBeforeResponse, delayMs, close: "mid-response"). Tests inject it through the internal ProxyConfig.adapterCommand seam, so it drives the production proxy worker, socket framing, policy handshake, and parent/worker status IPC — without adding a test-only field to the MCP tool schema. Reach for it when you need to prove the proxy survives an adapter behaving badly. tests/fixtures/ is excluded from tsconfig.spec.json, so fixtures are not type-checked by the ratchet.
import { portManager, PortRange } from '../../test-utils/helpers/port-manager.js';
const port = portManager.getPort(PortRange.UNIT_TESTS); // 5679–5778
portManager.releasePort(port); // optionalThree non-overlapping 100-port ranges anchored at 5679: UNIT_TESTS (5679–5778), INTEGRATION (5779–5878), E2E (5879–5978). The manager tracks allocations in an in-process Set, so it prevents collisions within a worker; it does not guarantee the OS has the port free.
npx vitest run tests/unit/proxy/proxy-manager.start.test.ts # one file
npx vitest run -t "launches the proxy process" # one test, by name
npm run test:watch # watch mode
npm run test:verbose # verbose reporter
npm run test:failures # summarize failures from the last run
npm run test:summary # summarize the last runvitest.config.ts installs an onConsoleLog filter, so a bare console.log('Input:', data) from a test may never reach your terminal. The filter suppresses a list of noise patterns (server log prefixes, timestamps, spawn, node_modules, …) and, by default, suppresses stdout while keeping stderr.
The reliable ways to see your own output:
console.error(...)— stderr passes by default.- Prefix the message with one of the whitelisted markers:
[Discovery Test],[Workflow Test],[Test Server],[env-utils],[process-listener-leak]. Lines containingFAIL,Error:,Expected,Received,AssertionError,TypeError, orReferenceErroralso always pass.
Chasing a "my log statement disappeared" ghost here is a common first-hour tax; it is the filter, not your test.
Every run shuffles file order under a seed, so a failure you cannot reproduce may be an order dependency rather than a flake. The seed is printed by the run. Reproduce it with:
vitest run --project unit --sequence.seed=<seed>To hunt for the class of bug deliberately — a test leaning on a sibling's leftover mock or env state — npm run test:flake loops the unit project under fresh explicit seeds with within-file shuffle (--sequence.shuffle.tests), prints each seed, and exits non-zero listing the ones that failed. Within-file shuffle is not in the committed config on purpose: the integration and e2e suites share one live debug session across it blocks and would break if reordered.
Set a breakpoint in the test file and launch it with the debugger; step through as usual.
Names should say what and why:
// ❌
it('should work', () => {});
it('test error', () => {});
// ✅
it('should return empty array when no sessions exist', () => {});
it('should throw timeout error when adapter does not respond within 30s', () => {});describe('SessionManager', () => {
describe('session lifecycle', () => {
describe('createSession', () => {
it('should generate unique session ID', () => {});
it('should set initial state to CREATED', () => {});
});
describe('closeSession', () => {
it('should clean up resources', () => {});
it('should handle an already-closed session', () => {});
});
});
});With fileParallelism: true on the unit project and file-order shuffling on every run, an order-dependent test is not a latent risk — it is a test that will fail on somebody's machine this week.
// ❌ depends on execution order
let sharedSession: Session;
it('should create session', () => { sharedSession = createSession(); });
it('should use session', () => { expect(sharedSession.id).toBeDefined(); });
// ✅ each test stands alone
it('should create session', () => {
expect(createSession().id).toBeDefined();
});
it('should process session', () => {
expect(processSession(createSession())).toBeDefined();
});The one sanctioned exception is the integration/e2e suites, which drive a single live debug session through create → … → close across it blocks. That is why within-file shuffle stays out of the committed config.
function createTestSession(overrides?: Partial<Session>): Session {
return {
id: 'test-session-123',
state: SessionState.CREATED,
language: DebugLanguage.PYTHON,
breakpoints: new Map(),
...overrides
};
}
it('should handle paused session', () => {
const session = createTestSession({ state: SessionState.PAUSED });
// …
});describe('file operations', () => {
it('should read file successfully', async () => {
mockFs.readFile.mockResolvedValue('content');
expect(await component.readConfig()).toBe('content');
});
it('should handle file read error', async () => {
mockFs.readFile.mockRejectedValue(new Error('ENOENT'));
await expect(component.readConfig()).rejects.toThrow('Configuration file not found');
});
});Symptom: tests/typecheck-baseline.json is modified but not committed, and the push stops before any test runs.
Fix: commit the baseline in the same change. CI reads the pushed commit, not your working tree, so an uncommitted refresh is a guaranteed CI failure the hook is saving you from.
Symptom: not "you added type errors" but "the check could not be run" — a corrupt or wrong-shaped baseline, or a diagnostic anchored outside the test trees.
Fix: delete tests/typecheck-baseline.json, then pnpm run typecheck:tests:update. Deleting first is required, not superstition: --update reads the baseline before writing one, so a corrupt file blocks its own repair.
Fix: fake timers, not longer timeouts. A unit test that needs more than the 15 s ceiling is a unit test doing something real, which means it belongs in integration.
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000);Symptom: the setup file reports (and removes) process listeners your test left behind.
Fix: capture handlers with mockImplementation instead of attaching them, or remove them in afterEach. Left in place they can hard-kill the Vitest fork worker (issue #159) — which surfaces as an unrelated file "failing" with no assertion. npm run test:strict promotes the warning to a failure.
Fix: clean up what you created. The global afterEach already resets mocks and env stubs.
afterEach(async () => {
await sessionManager.closeAllSessions();
fakeLauncher.reset();
vi.useRealTimers();
});Fix: vi.mock with a factory.
vi.mock('../../../src/utils/logger.js', () => ({
createLogger: vi.fn(() => ({ info: vi.fn(), error: vi.fn() }))
}));- Descriptive test names.
- Independent tests — the unit project runs in parallel and shuffles file order.
- Mock external dependencies; prefer a compile-checked fake over
as unknown as. - Cover both success and error paths.
- Fake timers for anything that would otherwise wait.
- Clean up what you created; the setup file handles the rest.
- Keep coverage above the enforced 90/80 floor, and keep the type-error baseline shrinking.