Testing CLIs
Testing a CLI is harder than testing a library. Libraries are functions — you call them and check the return value. CLIs are processes — they read from argv, env vars, config files, and stdin, then write to stdout, stderr, and the filesystem. Lots of moving parts.
Why It's Hard
The naive way to test a CLI:
# run the command
output=$(mycli greet Alice --loud)
# check the output
[ "$output" = "HELLO, ALICE!" ] || echo "FAIL"This works, technically. But it's slow (spawns a new process each time), fragile (depends on exact output formatting), and limited (how do you test env vars? config files? interactive prompts? error messages?).
Real-world CLIs have tests like:
- "If
--regionis missing butDEPLOY_REGIONis set, use the env var" - "If both flag and config file provide a value, the flag wins"
- "If the prompt is cancelled, exit with code 1"
- "In JSON mode, framework errors should be structured JSON on stdout"
Good luck doing that with shell scripts.
Two Approaches
1. Subprocess Testing (Black-Box)
Run the actual compiled binary as a child process:
import { } from 'node:child_process';
import { } from 'node:util';
const = ();
try {
const { , } = await (
'./mycli',
['greet', 'Alice'],
);
expect().toBe('Hello, Alice!\n');
expect().toBe('');
} catch () {
// execFileAsync throws on non-zero exit; read error.code if you need the exit code
throw ;
}Pros: Tests the real thing. Catches packaging issues.
Cons: Slow. Hard to mock env/config. Can't test prompts easily. Platform-dependent.
2. In-Process Testing (White-Box)
Run the command handler as a function, injecting all inputs:
import { } from '@kjanat/dreamcli/testkit';
const = await (, ['Alice', '--loud']);
(.).(['HELLO, ALICE!\n']);
(.).(0);Pros: Fast. Full control. Can inject env, config, prompt answers, output capture.
Cons: Doesn't test the actual binary entry point.
Most CLI frameworks don't give you option 2. You're stuck shelling out and parsing text. This is a solved problem — the test harness just needs to exist as a first-class feature.
The examples below use dreamcli's test harness, but the patterns apply to any framework that offers in-process testing.
What to Test
Happy Paths
The command works with valid input:
import { } from '@kjanat/dreamcli/testkit';
const = await (, ['Alice']);
(.).(['Hello, Alice!\n']);
(.).(0);Flag Resolution
Flags resolve from the right source:
import { } from '@kjanat/dreamcli/testkit';
// env var provides the value
const = await (, [], {
: { : 'eu' },
});
(.).('eu');Error Cases
Bad input produces helpful errors:
import { } from '@kjanat/dreamcli/testkit';
const = await (, ['--unknown']);
(.).(2);
(..('')).('Unknown flag');Missing Required Values
Required flags that aren't provided, fail clearly:
import { } from '@kjanat/dreamcli/testkit';
const = await (, []);
(.)..(0);
(..('')).(
'Missing required',
);JSON Mode
Structured output is valid JSON:
import { } from '@kjanat/dreamcli/testkit';
const = await (, [], {
: true,
});
const = .(..(''));
().();Interactive Prompts
Prompt answers resolve correctly:
import { } from '@kjanat/dreamcli/testkit';
const = await (, [], {
: ['eu'],
});
(.).(0);Prompt Cancellation
Ctrl+C during a prompt exits gracefully:
import {
,
,
} from '@kjanat/dreamcli/testkit';
const = await (, [], {
: [],
});
(.)..(0);Isolation
Good CLI tests don't touch real state:
- No
process.argvmutation — pass argv as a parameter - No real env vars — inject env as an object
- No real filesystem — inject config as an object
- No real TTY — capture output to arrays
- No real prompts — provide answers programmatically
Each test runs in isolation. No beforeEach cleanup, no shared state, no order dependencies.
What's Next?
- Getting Started — build your first dreamcli command
- Testing Commands guide — dreamcli's in-process test harness