Skip to main content
Technology & EngineeringCli Development245 lines

CLI Testing

Test CLI applications using subprocess execution, mock filesystems, and snapshot testing

Quick Summary16 lines
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 lines
Paste into your CLAUDE.md or agent config

Testing 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, or process.env without 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 HOME and XDG_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

Get CLI access →