Testing CLI Tools That Spawn Child Processes
A command-line tool that shells out — to git, to a compiler, to a package manager — combines three things that are individually easy to test and awkward together: its own decision logic, the external programs it runs, and the process plumbing that connects them. Tests that spawn the real tool for every case are slow and depend on whatever versions of those external programs happen to be installed; tests that mock child_process wholesale verify nothing about how the tool actually behaves when run. This guide covers separating the three, testing the decisions with a fake runner, testing the wiring by spawning the built binary against a controlled fixture, and making real external commands deterministic enough to trust. It sits under file system and process mocking.
Root Cause Analysis
The difficulty comes from where decisions live. In a typical CLI, the logic that decides what to run — which files, which flags, in what order, what to do when a step fails — is interleaved with the calls that run it. Testing the logic therefore means running the commands, and running the commands means depending on the environment.
That environment dependence is the main source of flakiness. A test that runs git log depends on the repository’s history, the installed git version, the user’s configuration and the locale. A test that runs a compiler depends on its version and its cache. None of this is the CLI’s behaviour; all of it can change a test result.
Mocking child_process globally swings too far the other way. It removes the environment, but it also removes the argument parsing, the exit-code propagation and the stream handling — the parts of a CLI most likely to break when its entry point is refactored. The practical middle is a seam: decisions talk to an injected runner, and a small number of tests exercise the real runner through the real binary.
Reproducible Setup
A small CLI that finds changed packages in a monorepo and runs their tests — a realistic shape with real decisions and real subprocesses.
// src/runner.ts — the only place processes are created
import { execa } from 'execa';
export type RunResult = { stdout: string; stderr: string; exitCode: number };
export type Runner = (cmd: string, args: string[], opts?: { cwd?: string }) => Promise<RunResult>;
export const realRunner: Runner = async (cmd, args, opts) => {
const r = await execa(cmd, args, { cwd: opts?.cwd, reject: false });
return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode ?? 1 };
};
// src/commands/test-changed.ts — the logic, with the runner injected
import type { Runner } from '../runner';
export async function testChanged(base: string, run: Runner) {
const diff = await run('git', ['diff', '--name-only', `${base}...HEAD`]);
if (diff.exitCode !== 0) return { ok: false, reason: 'git_failed', detail: diff.stderr };
const packages = [...new Set(diff.stdout.split('\n').filter((f) => f.startsWith('packages/')).map((f) => f.split('/')[1]))];
if (packages.length === 0) return { ok: true, ran: [] };
const failures: string[] = [];
for (const pkg of packages) {
const r = await run('pnpm', ['--filter', `@acme/${pkg}`, 'test'], { cwd: process.cwd() });
if (r.exitCode !== 0) failures.push(pkg);
}
return failures.length ? { ok: false, reason: 'tests_failed', failed: failures } : { ok: true, ran: packages };
}
Implementation
Step 1 — Build a scripted fake runner. Map each command to the result it should produce, and record every call so tests can assert on what would have been run.
// test/fake-runner.ts
import type { Runner, RunResult } from '../src/runner';
type Script = Record<string, Partial<RunResult>>;
export function fakeRunner(script: Script) {
const calls: string[] = [];
const run: Runner = async (cmd, args) => {
const key = [cmd, ...args].join(' ');
calls.push(key);
const match = Object.entries(script).find(([pattern]) => key.startsWith(pattern));
return { stdout: '', stderr: '', exitCode: 0, ...(match?.[1] ?? {}) };
};
return { run, calls };
}
Step 2 — Test the decisions with scripted outcomes. Every branch of the command logic is reachable by changing the script, including failures a real environment rarely produces on demand.
// src/commands/test-changed.test.ts
import { test, expect } from 'vitest';
import { testChanged } from './test-changed';
import { fakeRunner } from '../../test/fake-runner';
test('runs tests only for packages the diff touched', async () => {
const { run, calls } = fakeRunner({
'git diff': { stdout: 'packages/ui/src/Button.tsx\npackages/ui/src/Card.tsx\ndocs/README.md' },
});
expect(await testChanged('origin/main', run)).toEqual({ ok: true, ran: ['ui'] });
expect(calls).toEqual(['git diff --name-only origin/main...HEAD', 'pnpm --filter @acme/ui test']);
});
test('reports which packages failed', async () => {
const { run } = fakeRunner({
'git diff': { stdout: 'packages/ui/a.ts\npackages/api/b.ts' },
'pnpm --filter @acme/api': { exitCode: 1 },
});
expect(await testChanged('origin/main', run)).toEqual({ ok: false, reason: 'tests_failed', failed: ['api'] });
});
test('surfaces a git failure instead of running nothing', async () => {
const { run, calls } = fakeRunner({ 'git diff': { exitCode: 128, stderr: 'fatal: bad revision' } });
expect(await testChanged('nope', run)).toMatchObject({ ok: false, reason: 'git_failed' });
expect(calls).toHaveLength(1);
});
Step 3 — Keep the entry point thin and spawn it for the wiring tests. The binary parses arguments, calls the command with the real runner, prints, and sets the exit code — and only a spawned test verifies all of that together.
// src/cli.ts
#!/usr/bin/env node
import { testChanged } from './commands/test-changed';
import { realRunner } from './runner';
const base = process.argv[2] ?? 'origin/main';
const result = await testChanged(base, realRunner);
if (result.ok) {
console.log(result.ran.length ? `Tested: ${result.ran.join(', ')}` : 'No packages changed.');
} else {
console.error(result.reason === 'git_failed' ? `git failed: ${result.detail}` : `Failed: ${result.failed.join(', ')}`);
process.exitCode = 1;
}
Step 4 — Give spawned tests a controlled fixture repository. A real git needs a real repository; create a tiny one per test in a temporary directory so history is known and nothing depends on the host project.
// test/git-fixture.ts
import { execa } from 'execa';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export async function gitFixture() {
const dir = await mkdtemp(join(tmpdir(), 'cli-git-'));
const git = (...args: string[]) => execa('git', args, { cwd: dir, env: { GIT_CONFIG_NOSYSTEM: '1', HOME: dir } });
await git('init', '-b', 'main');
await git('config', 'user.email', 'test@example.test');
await git('config', 'user.name', 'Test');
await mkdir(join(dir, 'packages/ui'), { recursive: true });
await writeFile(join(dir, 'packages/ui/index.ts'), 'export {}\n');
await git('add', '.');
await git('commit', '-m', 'initial');
return { dir, git };
}
Step 5 — Spawn the built binary against the fixture. Assert on the observable contract: output, stream, and exit code.
// test/cli.integration.test.ts
import { test, expect } from 'vitest';
import { execa } from 'execa';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { gitFixture } from './git-fixture';
const BIN = new URL('../dist/cli.js', import.meta.url).pathname;
test('reports nothing to do when no package changed', async () => {
const { dir, git } = await gitFixture();
await git('checkout', '-b', 'feature');
const r = await execa('node', [BIN, 'main'], { cwd: dir, reject: false, env: { LC_ALL: 'C' } });
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe('No packages changed.');
});
test('exits non-zero with a message when the base does not exist', async () => {
const { dir } = await gitFixture();
const r = await execa('node', [BIN, 'no-such-branch'], { cwd: dir, reject: false });
expect(r.exitCode).toBe(1);
expect(r.stderr).toMatch(/^git failed:/);
});
Step 6 — Build before spawning. Spawned tests must run against the artifact users receive; a pretest:integration script that compiles first keeps them from testing a stale build.
Verification
Confirm the split is doing its job by counting where the cases are. Most should be in the fast tier; a handful in the spawned one.
npx vitest run src/commands --reporter=dot # 24 tests, 180ms
npx vitest run test/cli.integration.test.ts # 4 tests, 2.9s
Then confirm the spawned tests are genuinely environment-independent by running them with a hostile configuration: a global gitconfig that changes the default branch name and an unusual locale. They should still pass, because the fixture supplies everything git reads.
GIT_CONFIG_GLOBAL=/tmp/hostile.gitconfig LANG=de_DE.UTF-8 npx vitest run test/cli.integration.test.ts
# ✓ 4 passed
Troubleshooting
Symptom: spawned tests pass locally and fail in CI with a branch-name error. Diagnosis: git’s default branch is master on the runner and main locally, or the reverse. Fix: pass -b main to git init as the fixture does, and never rely on the host’s init.defaultBranch.
Symptom: git refuses to commit in CI. Diagnosis: there is no user identity configured on the runner. Fix: set user.name and user.email inside the fixture repository, and point HOME at the fixture so no global configuration is consulted at all.
Symptom: output assertions fail only on some machines. Diagnosis: the external program localises its messages. Fix: set LC_ALL=C in the spawned environment, and assert on your CLI’s own output rather than on the external tool’s wording wherever possible.
Symptom: the fake runner lets a test pass that fails for real. Diagnosis: the script returns output in a shape the real command never produces — no trailing newline, a different separator. Fix: capture real output once from the fixture and use it as the scripted value, so the fake matches reality by construction.
FAQ
Should I mock child_process with vi.mock instead of injecting a runner?
Injection is simpler to reason about and keeps tests independent of how the process layer is implemented. vi.mock('execa') works too, and is the pragmatic choice for existing code you cannot easily change, but it couples every test to the specific library and call shape — a switch from execa to spawn breaks every test.
How many spawned tests does a CLI need?
Enough to cover each distinct contract of the entry point: success output, failure output, each exit code, and argument handling. For most tools that is between three and eight. Anything more is usually logic that belongs in the fake-runner tier.
Can the spawned tests use a fake runner too?
They can, via an environment switch the entry point reads, but that reintroduces a test-only code path into the binary. Prefer a real fixture environment: it is not much more work, and it tests the thing users actually run.
How do I test interactive prompts?
Put the prompting library behind the same kind of seam as the runner and test the logic with scripted answers. For the entry point, most prompt libraries accept input on stdin in non-interactive mode, which a spawned test can write — covered alongside stream capture in asserting on stdout and process exit codes.
Related
- Back to File System & Process Mocking
- Asserting on stdout and process exit codes — the in-process alternative for output checks.
- Mocking the Node fs module with memfs — faking the other surface most CLIs touch.
- Running only tests affected by a change — the problem this example CLI solves.