Cli Tools

CLI Testing Strategies and Frameworks

Complete guide to testing command-line applications built with Node.js, covering unit testing, integration testing, snapshot testing, stdin/stdout mocking, and end-to-end CLI testing frameworks.

CLI Testing Strategies and Frameworks

CLI tools are notoriously undertested. They interact with the filesystem, spawn child processes, read stdin, write to stdout and stderr, and return exit codes. Most developers skip testing because the surface area feels overwhelming. But CLI bugs are expensive — they silently corrupt data, fail in CI environments, or behave differently across operating systems. This guide covers practical testing strategies that make CLI testing as natural as testing a REST API.

Prerequisites

  • Node.js 18+
  • Jest or another test runner
  • A Node.js CLI project to test
  • Basic understanding of testing concepts

Testing Layers for CLIs

A well-tested CLI has three testing layers:

  1. Unit tests — Test individual functions: argument parsing, validation, data transformation
  2. Integration tests — Test command execution: spawn the CLI as a child process, verify stdout/stderr/exit codes
  3. Snapshot tests — Capture output and detect regressions automatically

Each layer catches different bugs. Unit tests are fast and specific. Integration tests catch wiring issues. Snapshot tests catch unexpected output changes.

Unit Testing CLI Functions

Extract logic from your CLI entry point into testable modules.

// lib/validate.js
function validateProjectName(name) {
  if (!name || typeof name !== 'string') {
    return { valid: false, error: 'Project name is required' };
  }
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
    return { valid: false, error: 'Name must be lowercase with hyphens only' };
  }
  if (name.length > 50) {
    return { valid: false, error: 'Name must be 50 characters or fewer' };
  }
  return { valid: true };
}

function parsePort(value) {
  var port = parseInt(value, 10);
  if (isNaN(port) || port < 1 || port > 65535) {
    return { valid: false, error: 'Port must be between 1 and 65535' };
  }
  return { valid: true, value: port };
}

module.exports = { validateProjectName: validateProjectName, parsePort: parsePort };
// test/validate.test.js
var validate = require('../lib/validate');

describe('validateProjectName', function() {
  test('accepts valid names', function() {
    expect(validate.validateProjectName('my-app')).toEqual({ valid: true });
    expect(validate.validateProjectName('app123')).toEqual({ valid: true });
    expect(validate.validateProjectName('a')).toEqual({ valid: true });
  });

  test('rejects empty names', function() {
    var result = validate.validateProjectName('');
    expect(result.valid).toBe(false);
    expect(result.error).toMatch(/required/);
  });

  test('rejects uppercase names', function() {
    var result = validate.validateProjectName('MyApp');
    expect(result.valid).toBe(false);
    expect(result.error).toMatch(/lowercase/);
  });

  test('rejects names starting with numbers', function() {
    var result = validate.validateProjectName('123app');
    expect(result.valid).toBe(false);
  });

  test('rejects names longer than 50 characters', function() {
    var longName = 'a'.repeat(51);
    var result = validate.validateProjectName(longName);
    expect(result.valid).toBe(false);
    expect(result.error).toMatch(/50 characters/);
  });
});

describe('parsePort', function() {
  test('parses valid ports', function() {
    expect(validate.parsePort('3000')).toEqual({ valid: true, value: 3000 });
    expect(validate.parsePort('80')).toEqual({ valid: true, value: 80 });
    expect(validate.parsePort('65535')).toEqual({ valid: true, value: 65535 });
  });

  test('rejects non-numeric values', function() {
    expect(validate.parsePort('abc').valid).toBe(false);
    expect(validate.parsePort('').valid).toBe(false);
  });

  test('rejects out-of-range ports', function() {
    expect(validate.parsePort('0').valid).toBe(false);
    expect(validate.parsePort('70000').valid).toBe(false);
    expect(validate.parsePort('-1').valid).toBe(false);
  });
});

Integration Testing with Child Processes

The most reliable way to test a CLI is to run it as a child process, exactly like a user would.

// test/helpers.js
var execSync = require('child_process').execSync;
var execFile = require('child_process').execFile;
var path = require('path');

var CLI_PATH = path.join(__dirname, '..', 'bin', 'cli.js');

function runCLI(args, options) {
  var opts = Object.assign({
    encoding: 'utf8',
    timeout: 10000,
    env: Object.assign({}, process.env, options && options.env)
  }, options);

  try {
    var stdout = execSync('node ' + CLI_PATH + ' ' + args, opts);
    return {
      stdout: stdout,
      stderr: '',
      exitCode: 0
    };
  } catch (err) {
    return {
      stdout: err.stdout || '',
      stderr: err.stderr || '',
      exitCode: err.status
    };
  }
}

function runCLIAsync(args, options) {
  return new Promise(function(resolve) {
    var opts = Object.assign({
      encoding: 'utf8',
      timeout: 10000,
      env: Object.assign({}, process.env, options && options.env)
    }, options);

    execFile('node', [CLI_PATH].concat(args.split(' ')), opts, function(err, stdout, stderr) {
      resolve({
        stdout: stdout || '',
        stderr: stderr || '',
        exitCode: err ? err.code : 0
      });
    });
  });
}

module.exports = { runCLI: runCLI, runCLIAsync: runCLIAsync, CLI_PATH: CLI_PATH };
// test/cli.test.js
var helpers = require('./helpers');
var fs = require('fs');
var path = require('path');
var os = require('os');

describe('CLI', function() {
  var tempDir;

  beforeEach(function() {
    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-test-'));
  });

  afterEach(function() {
    fs.rmSync(tempDir, { recursive: true, force: true });
  });

  test('shows help with --help flag', function() {
    var result = helpers.runCLI('--help');
    expect(result.exitCode).toBe(0);
    expect(result.stdout).toContain('Usage:');
    expect(result.stdout).toContain('Options:');
  });

  test('shows version with --version flag', function() {
    var pkg = require('../package.json');
    var result = helpers.runCLI('--version');
    expect(result.exitCode).toBe(0);
    expect(result.stdout.trim()).toBe(pkg.version);
  });

  test('creates project in specified directory', function() {
    var projectName = 'test-project';
    var result = helpers.runCLI(
      projectName + ' --skip-prompts',
      { cwd: tempDir }
    );

    expect(result.exitCode).toBe(0);
    expect(fs.existsSync(path.join(tempDir, projectName))).toBe(true);
    expect(fs.existsSync(path.join(tempDir, projectName, 'package.json'))).toBe(true);
  });

  test('fails with non-zero exit code for invalid project name', function() {
    var result = helpers.runCLI('INVALID_NAME --skip-prompts');
    expect(result.exitCode).not.toBe(0);
    expect(result.stderr).toContain('lowercase');
  });

  test('fails when directory already exists', function() {
    var projectName = 'existing';
    fs.mkdirSync(path.join(tempDir, projectName));

    var result = helpers.runCLI(
      projectName + ' --skip-prompts',
      { cwd: tempDir }
    );
    expect(result.exitCode).not.toBe(0);
    expect(result.stderr).toContain('already exists');
  });

  test('respects --database flag', function() {
    var result = helpers.runCLI(
      'myapp --skip-prompts --database postgresql',
      { cwd: tempDir }
    );

    expect(result.exitCode).toBe(0);
    var pkg = JSON.parse(
      fs.readFileSync(path.join(tempDir, 'myapp', 'package.json'), 'utf8')
    );
    expect(pkg.dependencies).toHaveProperty('pg');
  });

  test('respects NODE_ENV environment variable', function() {
    var result = helpers.runCLI('--help', {
      env: { NODE_ENV: 'test' }
    });
    expect(result.exitCode).toBe(0);
  });
});

Snapshot Testing CLI Output

Snapshot tests capture stdout and compare against previous runs. Perfect for detecting unexpected output changes.

// test/snapshots.test.js
var helpers = require('./helpers');

describe('CLI Output Snapshots', function() {
  test('help output matches snapshot', function() {
    var result = helpers.runCLI('--help');
    expect(result.stdout).toMatchSnapshot();
  });

  test('error output matches snapshot', function() {
    var result = helpers.runCLI('--invalid-flag');
    expect(result.stderr).toMatchSnapshot();
  });

  test('list command output matches snapshot', function() {
    var result = helpers.runCLI('list --format table');
    expect(result.stdout).toMatchSnapshot();
  });
});

When you first run these tests, Jest creates a __snapshots__ directory with the captured output. On subsequent runs, it compares against the snapshot and fails if output changed.

# Update snapshots after intentional changes
npx jest --updateSnapshot

Sanitizing Snapshots

Dynamic values (timestamps, paths, versions) break snapshots. Sanitize them:

function sanitizeOutput(output) {
  return output
    .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, '{{TIMESTAMP}}')
    .replace(/\/tmp\/cli-test-[a-zA-Z0-9]+/g, '{{TEMP_DIR}}')
    .replace(/\d+\.\d+\.\d+/g, '{{VERSION}}')
    .replace(/\d+ms/g, '{{DURATION}}');
}

test('output matches snapshot', function() {
  var result = helpers.runCLI('status');
  expect(sanitizeOutput(result.stdout)).toMatchSnapshot();
});

Testing Exit Codes

Exit codes communicate success or failure to the shell. Test them explicitly.

describe('Exit Codes', function() {
  test('exits 0 on success', function() {
    var result = helpers.runCLI('--help');
    expect(result.exitCode).toBe(0);
  });

  test('exits 1 on general error', function() {
    var result = helpers.runCLI('nonexistent-command');
    expect(result.exitCode).toBe(1);
  });

  test('exits 2 on invalid arguments', function() {
    var result = helpers.runCLI('create --unknown-flag');
    expect(result.exitCode).toBe(2);
  });

  test('exits 0 when --dry-run prevents changes', function() {
    var result = helpers.runCLI('deploy --dry-run', { cwd: tempDir });
    expect(result.exitCode).toBe(0);
    expect(result.stdout).toContain('dry run');
  });
});

Mocking stdin for Interactive CLIs

Interactive CLIs read from stdin. You can pipe input in tests:

// test/interactive.test.js
var spawn = require('child_process').spawn;
var path = require('path');

var CLI_PATH = path.join(__dirname, '..', 'bin', 'cli.js');

function runInteractive(inputs, options) {
  return new Promise(function(resolve) {
    var stdout = '';
    var stderr = '';

    var child = spawn('node', [CLI_PATH], {
      env: Object.assign({}, process.env, options && options.env),
      cwd: options && options.cwd
    });

    child.stdout.on('data', function(data) {
      stdout += data.toString();

      // Send next input when prompt appears
      if (inputs.length > 0 && stdout.includes('?')) {
        var input = inputs.shift();
        setTimeout(function() {
          child.stdin.write(input + '\n');
        }, 100);
      }
    });

    child.stderr.on('data', function(data) {
      stderr += data.toString();
    });

    child.on('close', function(code) {
      resolve({
        stdout: stdout,
        stderr: stderr,
        exitCode: code
      });
    });

    // Timeout safety
    setTimeout(function() {
      child.kill();
      resolve({ stdout: stdout, stderr: stderr, exitCode: -1 });
    }, 15000);
  });
}

describe('Interactive Mode', function() {
  test('accepts project name from prompt', function() {
    return runInteractive(['my-test-app', 'JavaScript', 'y'], {
      cwd: tempDir
    }).then(function(result) {
      expect(result.exitCode).toBe(0);
      expect(result.stdout).toContain('my-test-app');
    });
  });
});

Testing Filesystem Side Effects

describe('File Generation', function() {
  var tempDir;

  beforeEach(function() {
    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-test-'));
  });

  afterEach(function() {
    fs.rmSync(tempDir, { recursive: true, force: true });
  });

  test('creates expected directory structure', function() {
    helpers.runCLI('init myapp --skip-prompts', { cwd: tempDir });

    var projectDir = path.join(tempDir, 'myapp');
    var expectedDirs = ['src', 'src/routes', 'src/models', 'test'];

    expectedDirs.forEach(function(dir) {
      var fullPath = path.join(projectDir, dir);
      expect(fs.existsSync(fullPath)).toBe(true);
      expect(fs.statSync(fullPath).isDirectory()).toBe(true);
    });
  });

  test('generates valid package.json', function() {
    helpers.runCLI('init myapp --database postgresql --skip-prompts', {
      cwd: tempDir
    });

    var pkgPath = path.join(tempDir, 'myapp', 'package.json');
    expect(fs.existsSync(pkgPath)).toBe(true);

    var pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
    expect(pkg.name).toBe('myapp');
    expect(pkg.dependencies).toHaveProperty('express');
    expect(pkg.dependencies).toHaveProperty('pg');
  });

  test('does not overwrite existing files without --force', function() {
    var projectDir = path.join(tempDir, 'myapp');
    fs.mkdirSync(projectDir);
    fs.writeFileSync(path.join(projectDir, 'important.txt'), 'do not delete');

    var result = helpers.runCLI('init myapp --skip-prompts', { cwd: tempDir });

    expect(result.exitCode).not.toBe(0);
    expect(
      fs.readFileSync(path.join(projectDir, 'important.txt'), 'utf8')
    ).toBe('do not delete');
  });

  test('creates executable files with correct permissions', function() {
    helpers.runCLI('init myapp --skip-prompts', { cwd: tempDir });

    var binPath = path.join(tempDir, 'myapp', 'bin', 'cli.js');
    if (process.platform !== 'win32') {
      var stats = fs.statSync(binPath);
      var isExecutable = (stats.mode & parseInt('111', 8)) !== 0;
      expect(isExecutable).toBe(true);
    }
  });
});

Testing with Different Environments

describe('Environment Handling', function() {
  test('uses PORT from environment', function() {
    var result = helpers.runCLI('config show', {
      env: { PORT: '8080' }
    });
    expect(result.stdout).toContain('8080');
  });

  test('uses default port when PORT not set', function() {
    var env = Object.assign({}, process.env);
    delete env.PORT;
    var result = helpers.runCLI('config show', { env: env });
    expect(result.stdout).toContain('3000');
  });

  test('respects NO_COLOR environment variable', function() {
    var result = helpers.runCLI('--help', {
      env: { NO_COLOR: '1' }
    });
    // Should not contain ANSI escape codes
    expect(result.stdout).not.toMatch(/\x1b\[/);
  });

  test('works in CI environment', function() {
    var result = helpers.runCLI('init myapp --skip-prompts', {
      cwd: tempDir,
      env: { CI: 'true', TERM: 'dumb' }
    });
    expect(result.exitCode).toBe(0);
  });
});

Framework: oclif Testing

If your CLI is built with oclif, it provides built-in test utilities:

// test/commands/init.test.js
var test = require('@oclif/test').test;
var expect = require('chai').expect;

describe('init command', function() {
  test
    .stdout()
    .command(['init', 'myapp', '--skip-prompts'])
    .it('creates a new project', function(ctx) {
      expect(ctx.stdout).to.contain('Project created');
    });

  test
    .stderr()
    .command(['init', ''])
    .exit(1)
    .it('fails with empty project name', function(ctx) {
      expect(ctx.stderr).to.contain('required');
    });
});

Complete Working Example

// test/setup.js — shared test utilities
var fs = require('fs');
var path = require('path');
var os = require('os');
var execSync = require('child_process').execSync;

var CLI_PATH = path.join(__dirname, '..', 'bin', 'cli.js');

function createTempDir() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'cli-test-'));
}

function removeTempDir(dir) {
  fs.rmSync(dir, { recursive: true, force: true });
}

function run(args, options) {
  var cmd = 'node ' + CLI_PATH + ' ' + args;
  var opts = {
    encoding: 'utf8',
    timeout: 30000,
    cwd: options && options.cwd || process.cwd(),
    env: Object.assign({}, process.env, { NO_COLOR: '1' }, options && options.env)
  };

  try {
    var stdout = execSync(cmd, opts);
    return { stdout: stdout, stderr: '', exitCode: 0 };
  } catch (err) {
    return {
      stdout: err.stdout || '',
      stderr: err.stderr || '',
      exitCode: err.status || 1
    };
  }
}

function sanitize(output) {
  return output
    .replace(/\d{4}-\d{2}-\d{2}/g, '{{DATE}}')
    .replace(/\d+\.\d+\.\d+/g, '{{VERSION}}')
    .replace(/\/tmp\/cli-test-[^\s/]+/g, '{{TMPDIR}}');
}

module.exports = {
  CLI_PATH: CLI_PATH,
  createTempDir: createTempDir,
  removeTempDir: removeTempDir,
  run: run,
  sanitize: sanitize
};
{
  "scripts": {
    "test": "jest --verbose",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testTimeout": 30000,
    "testEnvironment": "node"
  }
}

Common Issues and Troubleshooting

1. Tests Pass Locally but Fail in CI

EACCES: permission denied, mkdir '/opt/project'

The CLI uses the current working directory by default. In CI, paths may differ. Always use cwd option and temp directories:

var result = run('init myapp', { cwd: tempDir });

2. Snapshot Tests Fail Across Platforms

- Expected: "Created project in /tmp/cli-test-abc123"
+ Received: "Created project in C:\\Users\\...\\Temp\\cli-test-abc123"

Paths differ between OS. Sanitize platform-specific content:

function sanitize(output) {
  return output.replace(/[A-Z]:\\[^\s]+/g, '{{PATH}}')
               .replace(/\/tmp\/[^\s]+/g, '{{PATH}}');
}

3. Interactive Tests Hang

# Test never completes — waiting for input

The CLI is waiting for a prompt that does not match your expected pattern. Add timeouts and debug output:

setTimeout(function() {
  console.error('Timeout. Captured stdout:', stdout);
  child.kill();
}, 10000);

4. Tests Leak Temp Files

ls /tmp/cli-test-*
# Hundreds of leftover test directories

Always clean up in afterEach, and use afterAll as a safety net:

afterEach(function() { removeTempDir(tempDir); });
afterAll(function() {
  // Emergency cleanup
  var tmpBase = os.tmpdir();
  fs.readdirSync(tmpBase)
    .filter(function(d) { return d.startsWith('cli-test-'); })
    .forEach(function(d) {
      fs.rmSync(path.join(tmpBase, d), { recursive: true, force: true });
    });
});

Best Practices

  • Test the CLI as a child process, not by importing functions. Integration tests catch argument parsing, environment variable handling, and exit code bugs that unit tests miss.
  • Always use temp directories for filesystem tests. Never create files in the project directory during tests.
  • Clean up in afterEach, not just afterAll. Each test should start with a clean slate.
  • Set NO_COLOR=1 in test environments. ANSI escape codes make assertions and snapshots fragile.
  • Test exit codes explicitly. 0 for success, 1 for errors, 2 for usage errors. Scripts downstream depend on these.
  • Use snapshot tests for output stability. They catch accidental regressions in help text, error messages, and formatting.
  • Set reasonable timeouts. CLI tests that spawn processes need longer timeouts than unit tests. 30 seconds is a good default.
  • Test on multiple platforms in CI. Use a GitHub Actions matrix with ubuntu-latest, macos-latest, and windows-latest.

References

Powered by Contentful