Use when configuring Vitest test isolation to prevent real process spawning in unit tests - provides file naming conventions, projects config, global safety guards, and defense-in-depth mocking strategies
Prevent unit tests from accidentally spawning real child processes (MCP servers, Go backends, etc.) that cause 30+ minute hangs.
Use this skill when:
Target audience: All testing agents writing Vitest tests.
Unit tests can accidentally spawn real child processes when:
*.test.ts instead of *.integration.test.tsResult: Tests hang for 30+ minutes waiting for real servers to start, blocking CI/CD pipelines.
| Layer | Protection Mechanism | Purpose |
|---|---|---|
| Layer 1 | Global setupFiles mock | Catches ALL unmocked spawns with explicit error |
| Layer 2 | Per-test vi.mock() | Explicit mocking for specific dependencies |
| Layer 3 | NODE_ENV checks | Production code guards against test environment |
Defense-in-Depth Strategy: All three layers work together - if one fails, the others catch the issue.
You MUST use these exact patterns:
✅ CORRECT:
*.unit.test.ts # Unit tests with ALL external dependencies mocked (fast, <5s)
*.integration.test.ts # Integration tests that MAY spawn real processes (slow, 60s+)
*.e2e.test.ts # End-to-end tests with real environment
❌ NEVER USE:
*.test.ts # Ambiguous - banned for tests that could spawn processes
Why this matters:
Required for Vitest v3.2+:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
projects: [
{
name: "unit",
include: ["**/*.unit.test.ts"],
testTimeout: 5000, // Fast timeout for unit tests
setupFiles: ["./vitest.setup.ts"], // Global safety guard
},
{
name: "integration",
include: ["**/*.integration.test.ts"],
testTimeout: 60000, // Longer timeout for integration tests
hookTimeout: 30000, // Hook timeout for async setup
},
],
bail: 1, // Fail fast - stop on first failure
},
});
Run specific project:
vitest run --project=unit # Unit tests only
vitest run --project=integration # Integration tests only
vitest run # All projects
Create this file to catch unmocked spawns:
import { vi } from "vitest";
// Block real child_process operations in unit tests
vi.mock("child_process", () => ({
spawn: vi.fn(() => {
throw new Error(
"Real child_process.spawn attempted in unit test!\n" +
"Either mock this dependency or rename to *.integration.test.ts"
);
}),
exec: vi.fn(() => {
throw new Error(
"Real child_process.exec attempted in unit test!\n" +
"Either mock this dependency or rename to *.integration.test.ts"
);
}),
execFile: vi.fn(() => {
throw new Error(
"Real child_process.execFile attempted in unit test!\n" +
"Either mock this dependency or rename to *.integration.test.ts"
);
}),
}));
What this does:
For MCP wrapper tests, you MUST also mock the MCP SDK:
// vitest.setup.ts - Add to existing file
vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: vi.fn(),
}));
vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
StdioClientTransport: vi.fn(),
}));
Use @claude/testing library for consistency:
import { createMCPMock } from "@claude/testing";
describe("MCP Wrapper Tests", () => {
it("should call MCP tool without spawning real process", async () => {
const mockClient = createMCPMock({
toolName: "get-issue",
response: { success: true, data: { id: "123" } },
});
// Test wrapper logic with mock
const result = await getIssueWrapper({ issueId: "123" });
expect(result).toEqual({ success: true, data: { id: "123" } });
});
});
Layer 2: Explicit per-test mocks for specific dependencies:
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { spawn } from "child_process";
// Mock at module level
vi.mock("child_process");
describe("Process Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers(); // Clean up timers BEFORE async cleanup
});
it("should handle process without spawning", () => {
const mockSpawn = vi.mocked(spawn);
mockSpawn.mockReturnValue({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
} as any);
// Test logic that uses spawn
const result = startProcess({ command: "test" });
expect(mockSpawn).toHaveBeenCalledWith("test", expect.any(Array));
});
});
Key points:
| Anti-Pattern | Why It Fails | Fix |
|---|---|---|
Using *.test.ts for tests that spawn processes | Ambiguous naming prevents proper test separation | Rename to *.integration.test.ts |
| No timeout configuration | Allows infinite hangs | Set testTimeout in projects config |
| Mocking wrapper functions instead of SDK | Misses SDK-level spawn calls | Mock @modelcontextprotocol/sdk not wrapper |
| Missing afterEach cleanup | Cross-test pollution, timer leaks | Add vi.useRealTimers() before async cleanup |
| No global setupFiles | Silent process spawns, hard-to-debug hangs | Add vitest.setup.ts with child_process mock |
For a complete working example showing all layers together, see:
Before completing test setup, verify:
*.unit.test.ts (for unit tests) or *.integration.test.ts (for integration tests)@modelcontextprotocol/sdk (if applicable)vi.useRealTimers() cleanupvitest run --project=unitProblem: Test still hangs despite configuration
Diagnosis steps:
--reporter=verbose to see which test hangsProblem: Mock not working, real process still spawns
Common causes:
Solution: Always mock at module level before imports, target SDK not wrappers.
testing-mcp-wrappers - MCP-specific testing patterns and best practicestesting-with-vitest-mocks - Advanced Vitest mocking patternsavoiding-low-value-tests - What to test and what to skiptesting-typescript-types - Type-level testing strategiesSee .history/CHANGELOG for version history.