Skip to content

Testing

Use @optique/testing to check parsed values, help and error output, command handler dispatch, or a CLI running as a child process. Choose the entry point that exercises the behavior your test needs:

import { /* ... */ } from "@optique/testing/parser";
import { /* ... */ } from "@optique/testing/run";
import { /* ... */ } from "@optique/testing/discover";
import { /* ... */ } from "@optique/testing/cli";

Execution boundaries

Each layer runs strictly more of the application than the one above it:

LayerRunsDoes not run
@optique/testing/parserThe parser, over a complete argument listHelp rendering, output, exits, command handlers
@optique/testing/runThe above, plus help, version, completion, and parse-error outputThe code that consumes the parsed value
@optique/testing/discoverThe above, plus command discovery, lifecycle hooks, and handler dispatchA separate CLI process
@optique/testing/cliThe entire process

The runner and discovery helpers capture help, version, completion, and parse-error output through injected stdout and stderr callbacks. Direct writes from command handlers, such as console.log(), print(), or process.stdout.write(), bypass these callbacks and are not captured by captureProgramRun(). Use a child-process test to assert on that output.

Choosing a layer

Prefer the narrowest layer that still exercises what you are asserting about.

@optique/testing/parser
Grammar questions. Does this option accept that value, does a bad value produce the error you intended, does or() pick the branch you expect? Nothing is rendered and nothing exits, so the assertions are about values and structured errors rather than text.
@optique/testing/run
Runner behavior. Does --help print the usage you expect, does an unknown option exit with the code you configured, does the parser return the value your application will receive?
@optique/testing/discover
Dispatch. Does the right command module get selected, do program hooks run in order, does a failing handler propagate its error?
@optique/testing/cli
The application as users experience it. This is also the only layer that sees the working directory, the environment, stdin, and the exit code of a real process, and the only one that needs the entry point to be runnable.

These layers are complements, not alternatives. A parser test that runs in milliseconds is a poor substitute for one end-to-end check that the binary starts at all, and the reverse is equally true.

Testing a parser

Use parseArgsSync() with a synchronous parser, or parseArgs() when the parser may be asynchronous. The latter always returns a promise, including when given a synchronous parser.

import { 
command
,
option
} from "@optique/core/primitives";
import {
parseArgsSync
} from "@optique/testing/parser";
const
parser
=
command
("serve",
option
("--watch"));
const
result
=
parseArgsSync
(
parser
, ["serve", "--watch"]);
if (
result
.
success
) {
result
.
value
; // boolean
} else {
result
.
error
;
result
.
remainingArgs
;
result
.
commandPath
;
}

A failure reports the argument suffix that remained where parsing stopped. commandPath contains the canonical names of commands matched before that point, even when the input used a command alias. Completion-time failures have an empty remainingArgs array because the complete argument list was already consumed.

Both helpers accept the same ParseOptions as the core parsing APIs, including annotations. They return parser failures as values, but exceptions thrown by the parser still propagate. parseArgs() likewise preserves promise rejections. Neither helper renders help, interprets --help/--version, writes output, exits, or dispatches a command handler.

Testing a run

Use captureRun() to exercise the same runner behavior as runAsync() without writing to process streams or terminating the test process. It accepts either a parser or a Program and always returns a promise.

import { 
argument
} from "@optique/core/primitives";
import {
string
} from "@optique/core/valueparser";
import {
captureRun
} from "@optique/testing/run";
const
parser
=
argument
(
string
());
const
result
= await
captureRun
(
parser
, {
args
: ["--help"],
programName
: "greet",
help
: "option",
colors
: false,
maxWidth
: 80,
}); if (
result
.
kind
=== "returned") {
result
.
value
; // string
} else {
result
.
exitCode
; // number
}

A normal parse produces a returned result with the inferred parser value and an exit code of zero. Help, version, completion, and parse errors produce an exited result with the requested exit code. Both variants include separate stdout and stderr strings with the same trailing newlines as the default runner writers.

The colors and maxWidth options keep runAsync()'s defaults, which depend on the test process's terminal and environment variables. Set both explicitly when asserting rendered text so the result is stable between local terminals and CI.

The capture helper supplies stdout, stderr, and onExit, so callers cannot override them. Exceptions from parsers, contexts, option callbacks, or resource disposal still reject the returned promise. Output written directly through console.log(), print(), or a process stream bypasses these callbacks and is not captured. Use the child-process layer when a test needs to observe that output or run the application code that consumes the parsed value.

Testing command discovery and dispatch

Use captureProgramRun() to exercise runProgram(), including command loading, lifecycle hooks, and handler dispatch. Pass either a dir to discover command modules or commands for a static registry, including entries returned by commandsFromModules().

This test checks both help and a parse error without running the handler:

import 
assert
from "node:assert/strict";
import {
argument
} from "@optique/core/primitives";
import {
string
} from "@optique/core/valueparser";
import {
defineCommand
} from "@optique/discover/command";
import {
captureProgramRun
} from "@optique/testing/discover";
let
calls
= 0;
const
greet
=
defineCommand
({
path
: ["greet"],
parser
:
argument
(
string
()),
handler
() {
calls
++;
}, }); const
options
= {
commands
: [
greet
],
metadata
: {
name
: "example" },
colors
: false,
maxWidth
: 80,
}; const
help
= await
captureProgramRun
({
...
options
,
args
: ["greet", "--help"],
});
assert
.
equal
(
help
.
exitCode
, 0);
assert
.
match
(
help
.
stdout
, /Usage:/);
assert
.
equal
(
help
.
stderr
, "");
const
failure
= await
captureProgramRun
({ ...
options
,
args
: ["greet"] });
assert
.
equal
(
failure
.
exitCode
, 1);
assert
.
notEqual
(
failure
.
stderr
, "");
assert
.
equal
(
calls
, 0);

For dispatch, assert on an observable handler effect. The helper waits for asynchronous handlers and lifecycle cleanup before resolving:

import 
assert
from "node:assert/strict";
import {
argument
} from "@optique/core/primitives";
import {
string
} from "@optique/core/valueparser";
import {
defineCommand
} from "@optique/discover/command";
import {
captureProgramRun
} from "@optique/testing/discover";
const
greeted
: string[] = [];
const
greet
=
defineCommand
({
path
: ["greet"],
parser
:
argument
(
string
()),
async
handler
(
name
) {
await
Promise
.
resolve
();
greeted
.
push
(
name
);
}, }); const
result
= await
captureProgramRun
({
commands
: [
greet
],
metadata
: {
name
: "example" },
args
: ["greet", "Ada"],
colors
: false,
maxWidth
: 80,
});
assert
.
equal
(
result
.
exitCode
, 0);
assert
.
deepEqual
(
greeted
, ["Ada"]);

ProgramRunResult contains exitCode, stdout, and stderr. A normal completion has exit code zero; help, version, completion, and parse errors report the requested exit code. It has no parsed value or kind field because runProgram() dispatches the value to the selected handler.

CaptureProgramRunOptions<R> preserves RunProgramOptions<R>, including hook resource types, but reserves stdout, stderr, and onExit for capture. Each output callback appends its string followed by one newline, matching the default writers, even when the string is empty or already ends in a newline. The helper inherits argument, color, and width defaults from runProgram(); set args, colors, and maxWidth explicitly for repeatable tests.

Errors from discovery, imports, handlers, hooks, and resource disposal reject the promise according to runProgram()'s error handling, including its onError rules. They are not converted into exit results. Commands and hooks run in the test process, so their side effects are real. Direct writes through console.log(), print(), or process streams bypass capture; use a child process to assert on those writes.

Testing a real CLI

Use createCliRunner() when the test needs to observe the complete application, including direct console output, stdin, and process exit status:

import 
assert
from "node:assert/strict";
import {
createCliRunner
} from "@optique/testing/cli";
const
cli
=
createCliRunner
({
entrypoint
: new
URL
("./cli.mjs", import.meta.
url
),
}); const
help
= await
cli
.
invoke
("--help");
assert
.
equal
(
help
.
exitCode
, 0);
assert
.
match
(
help
.
stdout
, /Usage:/);
const
result
= await
cli
.
invoke
({
args
: ["greet"],
stdin
: "Ada\n",
env
: {
GREETING
: "Hello",
DEBUG
:
undefined
},
timeout
: 10_000,
});
assert
.
equal
(
result
.
stdout
, "Hello, Ada!\n");

CliResult contains separate UTF-8 stdout and stderr strings, exitCode: number | null, and signal: string | null. A nonzero exit code is a result, including a CLI's parse error or uncaught handler exception. A signal termination reports its signal instead of an exit code. Output is preserved without trimming, including CRLF, ANSI escapes, and trailing newlines. The runner does not force color or terminal width settings; configure these in your CLI or environment when asserting rendered text.

Selecting a runtime

With entrypoint, the child uses the current runtime's executable. Deno receives run before the entry point; Node.js and Bun receive the entry point directly. runtimeArgs goes before the entry point. Parent runtime flags are not inherited, and the helper does not install dependencies, build TypeScript, or infer a loader. Use runnable JavaScript on Node.js versions that cannot execute your TypeScript source directly, or supply an explicit loader.

Deno permissions must be explicit:

import { 
createCliRunner
} from "@optique/testing/cli";
const
cli
=
createCliRunner
({
entrypoint
: new
URL
("./cli.ts", import.meta.
url
),
runtimeArgs
: ["--allow-read", "--allow-env"],
});

To select a different executable, supply a nonempty command array instead of entrypoint and runtimeArgs:

import { 
createCliRunner
} from "@optique/testing/cli";
const
cli
=
createCliRunner
({
command
: ["node", "./dist/cli.js"] });
await
cli
.
invoke
("--version");

Commands and arguments are passed without a shell. Spaces and shell metacharacters remain literal; no quoting, splitting, or expansion is applied.

Invocation settings

invoke(...args) accepts individual string arguments. invoke({ args, ... }) adds per-call options; args defaults to an empty array. stdin is a UTF-8 string and the input pipe always receives EOF, even when stdin is omitted. The runner reads stdout and stderr concurrently while writing input.

cwd, env, timeout, signal, and cleanup can be set on the factory and overridden on each invocation. The factory fixes its working directory and entry point to absolute paths when created. A relative invocation cwd is resolved against that factory directory and does not relocate the entry point. Factory arrays, environment settings, and URL paths are copied, so later edits to them do not change the runner.

The child inherits the parent's environment at invocation time, then applies factory and per-call overrides in that order. An undefined value removes a variable from the supplied environment, although the runtime or operating system may restore system variables such as PATH. Environment names are case-insensitive on Windows. Invocations do not change the parent process's environment or working directory and can run concurrently.

The default timeout is 5,000 milliseconds; 0 disables it. Values must be integers between 0 and 2147483647. The limit includes output collection, so an exited CLI whose descendants keep its pipes open can still time out. An invocation signal replaces the factory signal. An already-aborted signal prevents the child from starting.

Failures and cleanup

Execution failures, timeouts, cancellation, input/output failures, and cleanup failures reject with CliInvocationError. Its reason is "spawn", "timeout", "aborted", "io", or "cleanup", respectively. The error contains partial stdout and stderr, observed exitCode and signal, and an underlying cause when available:

import { 
CliInvocationError
,
createCliRunner
} from "@optique/testing/cli";
const
cli
=
createCliRunner
({
entrypoint
: "./cli.mjs",
timeout
: 1_000 });
try { await
cli
.
invoke
("serve");
} catch (
error
) {
if (!(
error
instanceof
CliInvocationError
)) throw
error
;
console
.
error
(
error
.
reason
,
error
.
stdout
,
error
.
stderr
);
}

Invalid factory options throw TypeError or RangeError; invalid invocation options reject the promise. If cleanup also fails, reason becomes "cleanup" and an AggregateError cause preserves the original failure and cleanup errors, in that order. Cleanup errors identify the failed stage and preserve underlying process or pipe errors as their own cause.

cleanup: "child" is the default and terminates only the direct child on failure. Choose cleanup: "tree" when the CLI starts other processes that should also be terminated on failure. On POSIX systems this creates a process group, sends SIGTERM, waits up to one second, and then sends SIGKILL if needed, allowing another second for final cleanup. Windows terminates the child forcibly; tree cleanup uses the system taskkill.exe /T /F with a combined two-second bound for the tool and subsequent cleanup. The tool can use the remaining cleanup time without a separate one-second cutoff. Cleanup time is additional to the invocation timeout.

Tree cleanup handles ordinary descendants, not processes that escape their group or become orphaned. On Windows, if the direct child has already exited, the runner does not start a new taskkill using its old PID. Normal completion does not trigger a process-tree sweep. This helper is not a process sandbox; fixtures that deliberately detach descendants need their own cleanup.

Shared contracts

The package root holds only the contracts that mean the same thing at more than one boundary. Today that is CapturedOutput, the pair of captured streams:

import type { CapturedOutput } from "@optique/testing";

function 
assertQuiet
(
output
: CapturedOutput): void {
if (
output
.
stderr
!== "") throw new
Error
(
output
.
stderr
);
}

The two streams stay separate and their text is preserved exactly, including trailing newlines, so a test can assert which stream a message reached.

Results that differ by layer stay with the layer that produces them. A parse failure, an intentional runner exit, and a terminating signal are not the same kind of outcome, and flattening them into one result shape would hide the distinction that made these layers worth separating.