Skip to main content
ClaudeWave
Skill354 repo starsupdated today

mock-to-internals-migration

This skill provides a migration protocol for replacing unreliable `mock.module()` and `vi.spyOn()` patterns with dependency injection through `_internals` exports in production code. Use this when test files mock Node.js modules like `child_process` or `fs`, the production code has an `_internals` export available, and tests need reliable mocking across Bun's shared test runner while avoiding Windows EBUSY conflicts from `mock.restore()` calls.

Install in Claude Code
Copy
git clone --depth 1 https://github.com/ZaxbyHub/opencode-swarm /tmp/mock-to-internals-migration && cp -r /tmp/mock-to-internals-migration/.opencode/skills/generated/mock-to-internals-migration ~/.claude/skills/mock-to-internals-migration
Then start a new Claude Code session; the skill loads automatically.

SKILL.md

# mock.module / vi.spyOn → _internals DI Seam Migration Protocol

Follow every step in order. Do not skip steps.

## When to use this skill

- A test file uses `mock.module('node:child_process')` or `mock.module('node:fs')` or similar
- A test file uses `vi.spyOn(module, 'functionName')` on a module's exported functions (same anti-pattern as `mock.module` — unreliable across Bun's shared test runner when the module is split or functions move)
- The production code already has an `_internals` export (or needs one added)
- The goal is to eliminate `mock.module` usage per AGENTS.md invariant 7 and the writing-tests skill

**Benefit:** This migration also sidesteps the Windows EBUSY risk documented in the writing-tests skill — tests using `_internals` seams do not need `mock.restore()`, which on Windows can conflict with async child process handles.

## Step 0 — Identify the target module and its _internals seam

1. Find where the test imports from:
   ```typescript
   import { _internals as engineInternals } from '../../mutation/engine.js';
   ```
2. Read the source module (e.g., `src/mutation/engine.ts`)
3. Check if `_internals` already exists:
   ```typescript
    export const _internals = {
      existingHelper,
      // other entries...
    };
   ```
4. Identify which function/method the mock.module was intercepting (usually `spawnSync`, `execFileSync`, `readFileSync`, etc.)

## Step 0b — Check ALL test files consuming the target module

**CRITICAL:** Before converting any test file, identify EVERY test file that imports or spies on the target module. Cross-file mock leakage in Bun's shared test runner means a migration in one file can silently break tests in another file.

```bash
# Find all test files that import the module
grep -rln "from.*<source-module>" src/ tests/ --include="*.test.ts"

# Find all test files that spy on the module
grep -rln "vi.spyOn.*<source-module>" src/ tests/ --include="*.test.ts"
grep -rln "spyOn.*<source-module>" src/ tests/ --include="*.test.ts"

# Find all test files that mock the module
grep -rln "mock.module.*<source-module>" src/ tests/ --include="*.test.ts"
```

Prefer the `imports` tool for comprehensive discovery — grep misses dynamic imports and re-exports.

Record every file found. ALL of them must pass after migration — not just the one being explicitly converted.

## Step 1 — Add the function to _internals in the source file

**CRITICAL:** Do NOT import type aliases from Node.js built-ins (e.g., `type SpawnSyncFn` from `node:child_process`). Use `typeof` instead. This applies to ALL Node built-in functions, not just spawnSync.

```typescript
// BAD — fails build
import { spawnSync, type SpawnSyncFn } from 'node:child_process';

// GOOD — works at build time
import { spawnSync } from 'node:child_process';
type SpawnSyncFn = typeof spawnSync;
```

Add the function to `_internals`:
```typescript
export const _internals: {
  executeMutation: typeof executeMutation;
  computeReport: typeof computeReport;
  executeMutationSuite: typeof executeMutationSuite;
  spawnSync: SpawnSyncFn;  // ← ADD THIS
} = {
  executeMutation,
  computeReport,
  executeMutationSuite,
  spawnSync,  // ← ADD THIS
} as const;
```

> **Note:** The explicit type annotation on `_internals` (the `{ ... }` shape on the left side of `=`) overrides `as const` readonly inference. If you omit the explicit type annotation, `as const` will make properties `readonly` and test injection (`engineInternals.spawnSync = mockSpawnSync`) will fail at TypeScript compile time. Always include the explicit type annotation.

Replace all direct calls in the module with `_internals.spawnSync(...)`:
```typescript
// BEFORE
const result = spawnSync('git', ['apply', patchFile], { cwd: workingDir });

// AFTER
const result = _internals.spawnSync('git', ['apply', patchFile], { cwd: workingDir });
```

**Verify:** Run `bun run build` to ensure the type alias compiles.

## Step 2 — Convert the test file

### 2a. Remove mock.module block

Delete the entire `mock.module(...)` block and any related mock setup.

### 2b. Add module-level variables for save/restore

```typescript
// Module-level reference to the original function (saved/restored in beforeEach/afterEach)
let originalSpawnSync: typeof import('node:child_process').spawnSync;

// Module-level mock that logs calls and delegates to original
const mockSpawnSync = mock(
  (cmd: string, args: string[], opts: Record<string, unknown>) => {
    spawnCallLog.push({ cmd, args, opts: { ...opts } });
    if (originalSpawnSync) {
      return originalSpawnSync(cmd, args, opts);
    }
    return {
      pid: 12345,
      output: Buffer.alloc(0),
      stdout: Buffer.from('ok'),
      stderr: Buffer.alloc(0),
      status: 0,
      signal: null,
      error: undefined,
    } as ReturnType<typeof import('node:child_process').spawnSync>;
  },
);
```

### 2c. Update beforeEach

```typescript
beforeEach(() => {
  // Save original and replace with mock
  originalSpawnSync = engineInternals.spawnSync;
  engineInternals.spawnSync = mockSpawnSync;
  spawnCallLog.length = 0;
  tempDir = makeTempDir();
});
```

### 2d. Update afterEach

**CRITICAL:** Must include ALL of these in order:
1. Restore original function
2. Call `mockReset()` to clear mockImplementation state
3. Clean up temp directories
4. Clear call logs

```typescript
afterEach(() => {
  // 1. Restore original
  engineInternals.spawnSync = originalSpawnSync;
  // 2. Reset mock implementation to prevent leak between tests
  mockSpawnSync.mockReset();
  // 3. Clean up temp directory
  if (tempDir) {
    rmSync(tempDir, { recursive: true, force: true });
  }
  // 4. Clear call log
  spawnCallLog.length = 0;
});
```

**WARNING:** Omitting `mockReset()` causes `mockImplementation` state from one test to leak into the next test's active window.

### 2e. Update test assertions

Replace assertions that checked `mockSpawnSync.mock.calls` with assertions that check `spawnCallLog`:
```typescript
// BEFORE (with mock.module