CLI Testing
Test CLI applications using subprocess execution, mock filesystems, and snapshot testing
You are an expert in testing command-line tools, covering subprocess execution, output assertions, interactive prompt testing, and integration testing strategies. ## Key Points - Separate CLI wiring (argument parsing) from business logic so handlers can be unit tested without spawning subprocesses — subprocess tests are slower and harder to debug. - Always strip ANSI color codes from output before snapshot comparisons, since color support varies across environments and will cause false failures in CI. - Test exit codes explicitly — many CLI bugs manifest as a command silently exiting 0 on failure because error handling forgot to set the exit code. - Snapshot tests for CLI output break on every formatting change — use them sparingly for stable interfaces and prefer targeted assertions (`toContain`, `toMatch`) for output that evolves. ## Quick Example ```bash npm install --save-dev vitest execa memfs ```
skilldb get cli-development-skills/CLI TestingFull skill: 245 linesTesting CLI Applications — CLI Development
You are an expert in testing command-line tools, covering subprocess execution, output assertions, interactive prompt testing, and integration testing strategies.
Overview
CLI testing involves verifying that commands produce correct output, exit codes, file system changes, and handle edge cases like missing arguments or invalid input. The main approaches are: running the CLI as a subprocess (integration), importing and calling command handlers directly (unit), and testing interactive prompts with simulated input.
Setup & Configuration
Install testing dependencies:
npm install --save-dev vitest execa memfs
Project structure for testable CLIs:
src/
cli.ts # argument parsing and wiring
commands/
init.ts # exports handler function (testable)
build.ts
lib/
config.ts # pure business logic (testable)
test/
cli.test.ts # integration tests (subprocess)
commands/
init.test.ts # unit tests (direct import)
fixtures/
valid-project/
empty-dir/
Core Patterns
Subprocess integration tests
import { describe, it, expect } from 'vitest';
import { execaNode } from 'execa';
import { resolve } from 'path';
const cli = resolve(__dirname, '../bin/cli.js');
describe('CLI integration', () => {
it('shows help with --help', async () => {
const result = await execaNode(cli, ['--help']);
expect(result.stdout).toContain('Usage:');
expect(result.exitCode).toBe(0);
});
it('exits with code 1 on unknown command', async () => {
try {
await execaNode(cli, ['nonexistent']);
} catch (error) {
expect(error.exitCode).toBe(1);
expect(error.stderr).toContain('Unknown command');
}
});
it('creates project directory with init', async () => {
const tmpDir = await fs.mkdtemp(join(tmpdir(), 'cli-test-'));
const result = await execaNode(cli, ['init', 'my-project'], { cwd: tmpDir });
expect(result.exitCode).toBe(0);
expect(fs.existsSync(join(tmpDir, 'my-project', 'package.json'))).toBe(true);
await fs.rm(tmpDir, { recursive: true });
});
});
Unit testing command handlers
// src/commands/init.ts — export the handler separately from CLI wiring
export async function initProject(name: string, options: InitOptions): Promise<void> {
// ...logic
}
// test/commands/init.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { initProject } from '../../src/commands/init';
import * as fs from 'fs/promises';
vi.mock('fs/promises');
describe('initProject', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('creates directory and writes package.json', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await initProject('my-app', { template: 'default' });
expect(fs.mkdir).toHaveBeenCalledWith('my-app', { recursive: true });
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('package.json'),
expect.stringContaining('"name": "my-app"')
);
});
it('throws on invalid project name', async () => {
await expect(initProject('INVALID NAME!', { template: 'default' }))
.rejects.toThrow('Invalid project name');
});
});
Testing stdout and stderr output
import { describe, it, expect, vi } from 'vitest';
describe('output formatting', () => {
it('prints table in correct format', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await listCommand({ format: 'table' });
const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
expect(output).toContain('Name');
expect(output).toContain('Status');
expect(output).toMatchSnapshot();
consoleSpy.mockRestore();
});
});
Snapshot testing for CLI output
import { it, expect } from 'vitest';
import { execaNode } from 'execa';
it('help output matches snapshot', async () => {
const result = await execaNode(cli, ['--help']);
// Strip ANSI codes for stable snapshots
const clean = result.stdout.replace(/\x1b\[[0-9;]*m/g, '');
expect(clean).toMatchSnapshot();
});
it('error messages match snapshot', async () => {
try {
await execaNode(cli, ['deploy', '--env', 'invalid']);
} catch (error) {
const clean = error.stderr.replace(/\x1b\[[0-9;]*m/g, '');
expect(clean).toMatchSnapshot();
}
});
Testing with temporary directories
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
describe('file operations', () => {
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), 'cli-test-'));
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
it('generates config file', async () => {
await execaNode(cli, ['init', '--config-only'], { cwd: testDir });
const config = JSON.parse(
await readFile(join(testDir, '.myrc.json'), 'utf-8')
);
expect(config).toHaveProperty('version');
expect(config.version).toBe(1);
});
});
Testing exit codes
it('exits 0 on success', async () => {
const result = await execaNode(cli, ['validate', 'valid-input']);
expect(result.exitCode).toBe(0);
});
it('exits 1 on validation error', async () => {
const result = await execaNode(cli, ['validate', 'bad-input'], { reject: false });
expect(result.exitCode).toBe(1);
});
it('exits 2 on missing arguments', async () => {
const result = await execaNode(cli, ['validate'], { reject: false });
expect(result.exitCode).toBe(2);
});
Best Practices
- Separate CLI wiring (argument parsing) from business logic so handlers can be unit tested without spawning subprocesses — subprocess tests are slower and harder to debug.
- Always strip ANSI color codes from output before snapshot comparisons, since color support varies across environments and will cause false failures in CI.
- Test exit codes explicitly — many CLI bugs manifest as a command silently exiting 0 on failure because error handling forgot to set the exit code.
Core Philosophy
A CLI is an API with a human-readable interface. Test it like an API: verify inputs produce expected outputs, error cases return correct exit codes, and side effects (files created, config changed) match expectations. The fact that the interface is text-based rather than JSON-based does not make it less testable — it just requires different assertion strategies.
Separate testability from execution. The most common testing mistake in CLI development is tangling argument parsing with business logic so tightly that the only way to test is by spawning a subprocess. Design commands as importable handler functions that accept typed options and return results. Test these directly for speed and precision, then use subprocess tests sparingly to verify the full wiring.
Test the unhappy paths more than the happy ones. Users will pass wrong arguments, pipe unexpected input, run commands in empty directories, and Ctrl+C at inopportune moments. Your tests should cover missing arguments, invalid input, permission errors, and interrupted operations — because these are the scenarios that determine whether users trust your tool.
Anti-Patterns
-
Only testing with subprocess execution — spawning a child process for every test is slow, hard to debug, and makes it difficult to mock dependencies; unit test command handlers directly and reserve subprocess tests for integration verification.
-
Snapshot testing volatile output — snapshotting output that includes timestamps, file paths, or color codes produces flaky tests that break on every environment change; use targeted assertions for stable content and strip variable elements.
-
Depending on global state in tests — reading from
~/.config,/usr/local, orprocess.envwithout isolation means tests pass locally but fail in CI or on other developers' machines; use temp directories and explicit environment overrides. -
Not testing exit codes — many CLI bugs manifest as a command silently exiting with code 0 on failure because error handling forgot to set
process.exitCode; always assert the exit code alongside output content. -
Skipping error message tests — verifying that error messages are clear and actionable is just as important as verifying correct output; users encountering errors will see these messages as the sole debugging aid.
Common Pitfalls
- Tests that depend on the global filesystem (home directory config files, global installs) are fragile and break in CI — use temp directories and mock environment variables like
HOMEandXDG_CONFIG_HOME. - Snapshot tests for CLI output break on every formatting change — use them sparingly for stable interfaces and prefer targeted assertions (
toContain,toMatch) for output that evolves.
Install this skill directly: skilldb add cli-development-skills
Related Skills
Chalk Picocolors
Style terminal output with chalk and picocolors for colored, formatted CLI text
Clack Prompts
Build beautiful interactive CLI prompts using @clack/prompts with minimal boilerplate
CLI Distribution
Package and distribute CLI tools via npm/npx, standalone binaries with pkg, and Homebrew taps
Commander Js
Build structured CLI applications with Commander.js including commands, options, and argument parsing
Ink React CLI
Build rich interactive terminal UIs using Ink, a React renderer for the command line
Oclif Framework
Build production-grade, extensible CLI tools using the oclif framework with TypeScript