Asserting on stdout and Process Exit Codes
For a command-line tool, output and exit status are the interface. A build script that prints the wrong summary or exits zero after a failure is broken in exactly the way its users will notice, and a CI pipeline that relies on the exit code will silently pass work it should have rejected. Yet these are the parts of a CLI most often left untested, because asserting on them in-process is awkward: writes go to the real terminal, process.exit ends the test worker, and asynchronous output arrives after the assertion runs. This guide covers four techniques in order of preference — injected streams, captured writes, exit spies and spawned processes — and when each is the right one. It sits under file system and process mocking.
Root Cause Analysis
The difficulty is that stdout, stderr and the exit mechanism are process-global, and the test runner shares the process. Replacing process.stdout.write captures the program’s output but also the reporter’s; calling process.exit terminates the program and the test worker with it. Every in-process technique is a negotiation between capturing what the program does and not breaking the runner around it.
Asynchrony adds a second layer. Node’s streams buffer, and a program that writes its final line and then returns may have that line flushed after the test’s assertion has already run. The assertion sees incomplete output, and the missing text appears later in the reporter’s log, making the failure look like a runner bug.
Much of this disappears if the program does not reach for globals at all. A command function that receives its output streams as parameters and returns an exit code, rather than calling process.exit, can be tested with plain in-memory streams and a return-value assertion. The global-capture techniques below exist for code that was not written that way — which is most existing code.
Reproducible Setup
A command written to be testable, alongside an entry point that connects it to the real process.
// src/commands/lint-config.ts — takes streams, returns a code
import type { Writable } from 'node:stream';
export type Io = { stdout: Writable; stderr: Writable };
export async function lintConfig(paths: string[], io: Io): Promise<number> {
let problems = 0;
for (const path of paths) {
const issues = await checkFile(path);
for (const issue of issues) {
io.stderr.write(`${path}:${issue.line} ${issue.message}\n`);
problems++;
}
}
io.stdout.write(problems ? `${problems} problem(s) found\n` : 'All config files are valid\n');
return problems ? 1 : 0;
}
// src/bin/lint-config.ts — the only place that touches the process
import { lintConfig } from '../commands/lint-config';
process.exitCode = await lintConfig(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr });
Setting process.exitCode rather than calling process.exit lets pending output flush before the process ends, which also removes a class of truncated-output bugs in production.
Implementation
Step 1 — Test the command with in-memory streams. A PassThrough stream collects writes; the returned number is the exit code. Nothing global is touched.
// test/io.ts
import { PassThrough } from 'node:stream';
export function memoryIo() {
const out = new PassThrough();
const err = new PassThrough();
const read = (s: PassThrough) => () => (s.read() ?? '').toString();
return { io: { stdout: out, stderr: err }, stdout: read(out), stderr: read(err) };
}
// src/commands/lint-config.test.ts
import { test, expect } from 'vitest';
import { lintConfig } from './lint-config';
import { memoryIo } from '../../test/io';
test('exits zero and reports success for valid files', async () => {
const { io, stdout, stderr } = memoryIo();
expect(await lintConfig(['fixtures/valid.json'], io)).toBe(0);
expect(stdout()).toBe('All config files are valid\n');
expect(stderr()).toBe('');
});
test('writes each problem to stderr and exits one', async () => {
const { io, stdout, stderr } = memoryIo();
expect(await lintConfig(['fixtures/two-errors.json'], io)).toBe(1);
expect(stderr()).toMatch(/two-errors\.json:3 .*\n.*two-errors\.json:9/);
expect(stdout()).toBe('2 problem(s) found\n');
});
Step 2 — Capture global writes for code that uses console or process.stdout directly. Spy on the write method, collect chunks, and restore in a hook so the reporter is never left silenced.
// test/capture.ts
import { vi, afterEach } from 'vitest';
export function captureOutput() {
const out: string[] = [];
const err: string[] = [];
const o = vi.spyOn(process.stdout, 'write').mockImplementation((c: any) => (out.push(String(c)), true));
const e = vi.spyOn(process.stderr, 'write').mockImplementation((c: any) => (err.push(String(c)), true));
return { stdout: () => out.join(''), stderr: () => err.join(''), restore: () => (o.mockRestore(), e.mockRestore()) };
}
afterEach(() => vi.restoreAllMocks()); // belt and braces: never leave the reporter silenced
Step 3 — Turn process.exit into a catchable error. Code that calls process.exit(1) directly would end the worker; a spy that throws stops execution at the same point and carries the code into the assertion.
// test/exit.ts
import { vi } from 'vitest';
export class ExitError extends Error {
constructor(public code: number) { super(`process.exit(${code})`); }
}
export function trapExit() {
return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new ExitError(code ?? 0);
}) as never);
}
// src/legacy/deploy.test.ts
import { test, expect } from 'vitest';
import { trapExit, ExitError } from '../../test/exit';
import { captureOutput } from '../../test/capture';
import { deploy } from './deploy';
test('exits 2 with a message when the target is missing', async () => {
trapExit();
const out = captureOutput();
await expect(deploy({ target: undefined })).rejects.toEqual(new ExitError(2));
expect(out.stderr()).toContain('No deployment target specified');
});
Step 4 — Assert on process.exitCode for code that sets rather than exits. It is a plain property, so the test reads it and resets it afterwards.
import { afterEach, test, expect } from 'vitest';
afterEach(() => { process.exitCode = undefined; });
test('marks the process as failed without exiting', async () => {
await runChecks(['bad.json']);
expect(process.exitCode).toBe(1);
});
Step 5 — Feed stdin for commands that read input. A Readable built from a string stands in for piped input when the command accepts a stream; for the global process.stdin, spawn the binary and write to it instead.
import { Readable } from 'node:stream';
test('reads records from stdin and counts them', async () => {
const { io, stdout } = memoryIo();
const stdin = Readable.from(['{"id":1}\n', '{"id":2}\n']);
expect(await countRecords({ ...io, stdin })).toBe(0);
expect(stdout()).toBe('2 records\n');
});
Step 6 — Spawn only to verify the entry point. One or two tests that run the built binary confirm the wiring — that arguments reach the command and the returned code becomes the process’s exit status — which in-process tests cannot show.
import { execa } from 'execa';
test('the binary exits with the command’s code', async () => {
const r = await execa('node', ['dist/bin/lint-config.js', 'fixtures/two-errors.json'], { reject: false });
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe('2 problem(s) found');
});
Verification
Check that output is complete at the moment of assertion by adding a final line to the command and confirming the test sees it every time, including under load. Truncated output is the characteristic symptom of asserting before streams drain.
for i in $(seq 1 20); do npx vitest run src/commands/lint-config.test.ts --silent || echo "run $i failed"; done
Then confirm the reporter is never left silenced. Make one capture test fail deliberately and check that the failure message still appears in the terminal; if it does not, a spy is not being restored.
Troubleshooting
Symptom: the test run ends abruptly with no summary. Diagnosis: code called process.exit and nothing trapped it. Fix: install the throwing spy before calling the code, or refactor the code to set process.exitCode and return, which is better behaviour in production too.
Symptom: captured output is empty although the code prints. Diagnosis: it prints with console.log, which in some runners is intercepted before reaching process.stdout.write. Fix: spy on console.log as well, or — preferably — have the code write to an injected stream so the question never arises.
Symptom: coloured output breaks string assertions. Diagnosis: the program detects a terminal and emits ANSI escape codes. Fix: set NO_COLOR=1 or FORCE_COLOR=0 for the test, or strip escape codes before asserting; do not assert on colour codes unless colour is the behaviour under test.
Symptom: exit-code assertions pass but the real binary exits zero. Diagnosis: the entry point ignores the command’s return value. Fix: this is exactly what the single spawned test in Step 6 exists to catch — it verifies the one line of wiring that no in-process test can.
FAQ
Should commands return an exit code or throw?
Return one for expected outcomes — validation failures, “nothing to do”, partial success — and throw for genuinely exceptional ones. The entry point converts a thrown error into a non-zero code and a message. That split keeps normal failure paths easy to assert on and leaves unexpected errors with their stack traces intact.
Is snapshot testing reasonable for CLI output?
For help text and long formatted reports, a snapshot is a sensible way to notice unintended changes. For error messages and summaries, explicit assertions are better, because they state which parts of the output matter. Strip timings and paths before snapshotting, or every run produces a diff.
How do I test output that includes timings or paths?
Normalise before asserting: replace durations with a placeholder, and make paths relative to a known root. Better still, inject a clock and a root directory into the command so the output is deterministic by construction rather than cleaned up afterwards.
Does this work in Jest?
The techniques carry over directly — jest.spyOn(process, 'exit'), jest.spyOn(process.stdout, 'write') — with the same caveat about restoring spies so the reporter keeps working. The injected-stream approach needs no runner-specific code at all, which is another reason to prefer it.
Related
- Back to File System & Process Mocking
- Testing CLI tools that spawn child processes — the spawned-binary tier in depth.
- Isolating environment variables per test — the other process-global state CLIs depend on.
- Spying on default exports in Vitest — more on spies and their restoration.