Testing

CLI Forge provides a TestHarness class that lets you test argument parsing and command resolution without executing handlers. Combined with standard Node.js testing tools, you can build a thorough test suite for your CLI.

The test harness

Import TestHarness alongside your CLI instance and a test runner:

import { describe, it } from 'node:test';
import * as assert from 'node:assert';

// We can reuse the CLI from the basic-cli example
import cli from './basic-cli';
import { TestHarness } from 'cli-forge';

Then use .parse() to simulate an invocation without running the handler:

describe('Basic CLI', () => {
  it('should parse the hello command', async () => {
    // The TestHarness is used to simulate CLI invocations, without actually running the commands.
    // If you want to test how the CLI is parsing arguments, you can use the TestHarness.
    // If you actually want to test the CLI handlers, you should extract the handler logic
    // to a separate function and test that function directly.
    const harness = new TestHarness(cli);

    // The parse method returns the parsed arguments and the command chain that was resolved.
    // The command chain contains each command that was resolved during parsing. If its empty,
    // then the root command was resolved. If it contains ['hello'], then the hello command was resolved.
    const { args, commandChain } = await harness.parse([
      'hello',
      '--name',
      'sir',
    ]);

    assert.deepStrictEqual(commandChain, ['hello']);
    assert.deepStrictEqual(args, { name: 'sir', unmatched: [] });
  });
});

What the harness returns

The .parse() method returns an object with:

  • args — the fully parsed arguments, including defaults and coerced values
  • commandChain — an array of command names that were resolved. An empty array means the root command was matched; ['hello'] means the hello subcommand was resolved.

You can verify both what was parsed and which command would run — without side effects.

When to use the harness vs. testing handlers directly

Test goalApproach
Verify argument parsing and defaultsUse TestHarness
Verify command resolution and routingUse TestHarness
Test handler business logicExtract handler logic to a function, test it directly
Test the full CLI end-to-endRun the CLI as a subprocess

The harness is intentionally focused on parsing. Handler logic is best tested by extracting it into plain functions that can be unit tested independently.

Unit testing parsed arguments

Use the harness to verify that options are parsed correctly, defaults are applied, and validation works:

import { TestHarness } from 'cli-forge';
import { describe, it } from 'node:test';
import * as assert from 'node:assert';

import myCli from './my-cli';

describe('argument parsing', () => {
  const harness = new TestHarness(myCli);

  it('applies default values', async () => {
    const { args } = await harness.parse(['serve']);
    assert.strictEqual(args.port, 3000);
  });

  it('overrides defaults with CLI args', async () => {
    const { args } = await harness.parse(['serve', '--port', '8080']);
    assert.strictEqual(args.port, 8080);
  });

  it('resolves nested subcommands', async () => {
    const { commandChain } = await harness.parse(['db', 'migrate']);
    assert.deepStrictEqual(commandChain, ['db', 'migrate']);
  });
});

Testing handler logic

Rather than testing handlers through the CLI, extract the business logic into standalone functions:

// handler logic in a separate module
export function greet(name: string, shout: boolean): string {
  const message = `Hello, ${name}!`;
  return shout ? message.toUpperCase() : message;
}

// CLI definition
cli('my-app').command('greet', {
  builder: (cmd) =>
    cmd
      .option('name', { type: 'string', required: true })
      .option('shout', { type: 'boolean', default: false }),
  handler: (args) => {
    console.log(greet(args.name, args.shout));
  },
});
// test the function directly
it('greets with name', () => {
  assert.strictEqual(greet('World', false), 'Hello, World!');
});

it('shouts when requested', () => {
  assert.strictEqual(greet('World', true), 'HELLO, WORLD!');
});

This pattern gives you fast, focused tests without process overhead.

End-to-end testing

For integration confidence, run your CLI as a subprocess and assert on its output. CLI Forge's own examples use this approach via the functional-examples test runner:

npx functional-examples test

You can also write e2e tests manually with child_process:

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const exec = promisify(execFile);

it('prints help when no command is given', async () => {
  const { stdout } = await exec('npx', ['tsx', './bin/my-cli.ts', '--help']);
  assert.ok(stdout.includes('Usage:'));
});

Testing strategy summary

A well-tested CLI typically has three layers:

  1. Harness tests — fast, focused tests for parsing, defaults, and command routing using TestHarness
  2. Unit tests — test handler business logic as plain functions, independent of the CLI framework
  3. E2E tests — subprocess tests that verify the full invocation including output format and exit codes

Start with harness and unit tests for fast feedback, then add e2e tests for critical user-facing flows.