How to Test CLI Tools: A Practical Guide for Node.js Developers
You built a CLI tool. It works when you run it manually. But how do you write automated tests for something that reads from stdin, writes to stdout, exits with specific codes, and interacts with the file system?
Testing CLI tools is different from testing libraries or APIs. There's no function to import and assert against — your "interface" is a process that takes arguments and produces output. This guide shows you how to test that process reliably.
The Testing Challenge
CLI tools have unique testing requirements:
- Process exit codes need to be verified
- stdout and stderr need to be captured separately
- File system side effects need isolation
- Interactive prompts need simulation
- Arguments and flags need combinatorial testing
- Piped input needs to work correctly
Let's tackle each one.
Setup: Use Vitest (or Jest) with execa
npm install -D vitest execa
execa is the key ingredient. It runs child processes and gives you clean access to stdout, stderr, and exit codes:
// test/cli.test.js
import { describe, it, expect } from 'vitest';
import { execa, execaNode } from 'execa';
const CLI = './bin/mytool.js';
Pattern 1: Testing Exit Codes
The most critical test for any CI/CD-ready CLI:
describe('exit codes', () => {
it('exits 0 on success', async () => {
const result = await execaNode(CLI, ['check', 'valid-input']);
expect(result.exitCode).toBe(0);
});
it('exits 1 when errors are found', async () => {
try {
await execaNode(CLI, ['check', 'bad-input']);
expect.fail('Should have exited with non-zero code');
} catch (error) {
expect(error.exitCode).toBe(1);
}
});
it('exits 2 for invalid arguments', async () => {
try {
await execaNode(CLI, ['nonexistent-command']);
} catch (error) {
expect(error.exitCode).toBe(2);
}
});
});
Pattern 2: Testing stdout and stderr
describe('output', () => {
it('writes results to stdout', async () => {
const result = await execaNode(CLI, ['audit', 'test-fixture.json']);
expect(result.stdout).toContain('Score:');
expect(result.stdout).toContain('/100');
});
it('writes progress to stderr', async () => {
const result = await execaNode(CLI, ['audit', 'test-fixture.json']);
expect(result.stderr).toContain('Running audit');
// stdout should NOT contain progress messages
expect(result.stdout).not.toContain('Running');
});
it('outputs valid JSON with --json flag', async () => {
const result = await execaNode(CLI, ['audit', 'test-fixture.json', '--json']);
const parsed = JSON.parse(result.stdout);
expect(parsed).toHaveProperty('score');
expect(typeof parsed.score).toBe('number');
});
});
Pattern 3: Testing with Fixtures
Create test fixtures that represent known inputs with known expected outputs:
test/
fixtures/
valid-config.json # Should pass all checks
invalid-config.json # Should fail with specific errors
empty.json # Edge case: empty file
large-file.json # Performance test
import { join } from 'node:path';
const FIXTURES = join(import.meta.dirname, 'fixtures');
describe('with fixtures', () => {
it('passes valid config', async () => {
const result = await execaNode(CLI, ['check', join(FIXTURES, 'valid-config.json')]);
expect(result.exitCode).toBe(0);
});
it('reports errors in invalid config', async () => {
try {
await execaNode(CLI, ['check', join(FIXTURES, 'invalid-config.json')]);
} catch (error) {
expect(error.exitCode).toBe(1);
expect(error.stdout).toContain('missing required field');
}
});
it('handles empty files gracefully', async () => {
const result = await execaNode(CLI, ['check', join(FIXTURES, 'empty.json')]);
expect(result.stderr).toContain('Empty file');
});
});
Pattern 4: Testing Piped Input (stdin)
describe('stdin support', () => {
it('reads from stdin when no file argument', async () => {
const result = await execaNode(CLI, ['filter', '--level', 'error'], {
input: '2026-03-19 ERROR Something broke\n2026-03-19 INFO All good\n'
});
expect(result.stdout).toContain('ERROR Something broke');
expect(result.stdout).not.toContain('INFO All good');
});
it('handles empty stdin', async () => {
const result = await execaNode(CLI, ['filter'], {
input: ''
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('');
});
});
Pattern 5: Testing File System Side Effects
For tools that create or modify files, use temporary directories:
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
describe('file output', () => {
let tempDir;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'cli-test-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true });
});
it('creates output file', async () => {
const outputPath = join(tempDir, 'report.json');
await execaNode(CLI, ['audit', 'input.json', '--output', outputPath]);
const content = await readFile(outputPath, 'utf-8');
const report = JSON.parse(content);
expect(report).toHaveProperty('score');
});
});
Pattern 6: Snapshot Testing for Output Format
When your CLI output format matters (tables, reports, etc.), use snapshot testing:
describe('output format', () => {
it('renders report table correctly', async () => {
const result = await execaNode(CLI, ['audit', join(FIXTURES, 'known-input.json')]);
// Strip ANSI color codes for consistent snapshots
const clean = result.stdout.replace(/\u001b\[[0-9;]*m/g, '');
expect(clean).toMatchSnapshot();
});
});
Pattern 7: Testing Flag Combinations
CLI tools often have many flags that interact. Test the combinations that matter:
describe('flag combinations', () => {
const flagSets = [
{ args: ['--json'], check: (out) => JSON.parse(out) },
{ args: ['--quiet'], check: (out) => expect(out).toBe('') },
{ args: ['--json', '--quiet'], check: (out) => expect(out).toBe('') }, // quiet wins
{ args: ['--threshold', '90'], shouldFail: true },
{ args: ['--threshold', '10'], shouldFail: false },
];
for (const { args, check, shouldFail } of flagSets) {
it(`handles ${args.join(' ')}`, async () => {
try {
const result = await execaNode(CLI, ['audit', 'fixture.json', ...args]);
if (shouldFail) expect.fail('Expected non-zero exit');
if (check) check(result.stdout);
} catch (error) {
if (!shouldFail) throw error;
expect(error.exitCode).toBe(1);
}
});
}
});
Running Tests in CI
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
Test on multiple OS/Node combinations. CLI tools are notorious for platform-specific bugs — especially around path separators, line endings, and shell behavior.
The Minimum Test Suite
Every CLI tool should have at least these tests:
- Happy path — normal input produces correct output
- Error path — bad input exits non-zero with helpful error
- No arguments — shows help or usage info
-
--version— prints version number -
--json— outputs valid JSON (if supported) - Edge cases — empty input, huge input, special characters
That's 6 tests. Start there. Add more as bugs surface.
Conclusion
Testing CLI tools requires different tools and patterns than testing libraries, but it's not harder. execa gives you the process-level interface you need. Fixtures give you reproducible inputs. Temporary directories isolate file system effects.
The payoff is huge: a test suite that catches regressions before your users do, runs in CI on every push, and gives you confidence to refactor and ship new features.
Wilson Xu builds and tests developer CLI tools, with 8+ packages published on npm. Find his work at dev.to/chengyixu.
Top comments (0)