How-to › Ship a CLI or REPL

How to test interactive prompts and terminal behavior in a CLI#

Test a confirmation prompt without a real terminal, by passing the streams and the terminal flag in rather than reading them from the process.

Audience
API producer
Level
intermediate
Topic
Test, package and distribute a CLI
Languages
TypeScript and JavaScript
Verified
Tags

Your test suite covers every command but skips the prompts, because a prompt reads from process.stdin and the test runner has no terminal. The suite hangs the first time anyone tries, so the prompts went untested. Then a release makes the confirmation default to yes when no terminal is attached, and a scheduled job deletes production data.

What you get

You will end up with a prompt you can drive from a test with no terminal at all, asserting what was printed and what was decided. You also get an explicit rule for what happens when nobody is watching. This is for you if your CLI asks a question before doing something destructive.

Short answer

Take the input stream, the output stream, and whether a terminal is attached as arguments to the prompt. A test then drives it with in-memory streams and asserts both what was printed and what was decided. Treat an absent terminal as a refusal, and give that refusal its own exit code so a caller can tell it from a declined confirmation.

You will need

Node 22 or later, and a CLI with at least one prompt. The detection everyone reaches for is isTTY on the stream, and the whole technique here is to stop calling it inside the function that needs the answer.1 The prompt itself is readline, which takes its streams as arguments already.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A pseudo-terminal in the testYou need to test terminal drawing, colours, or line editingA native dependency that has to build on every platform in your matrixThe prompt is a question and an answer
Injected streamsAlmost every prompt, because the logic is what you are testingA parameter on the prompt, which callers must passThe behavior under test is the terminal itself
Spawning the CLI and writing to its stdinEnd-to-end coverage of argument parsing through to outputA process per case, and a pipe rather than a terminal, so isTTY is falseYou want the prompt logic covered case by case
Testing only the non-interactive pathNothing, once a prompt existsThe interactive path is the dangerous one and it stays uncoveredAlways, when a prompt exists at all

The choice is between testing the logic and testing the terminal. Almost always it is the logic: what the question said, what a blank answer means, what happens with no terminal at all. Because those are decisions in your code, none of them needs a real device to exercise.

Spawning the real binary has its place as one end-to-end case. It cannot cover the interactive branch, because a pipe is not a terminal, and that is the branch you most want covered. Use it to prove the wiring and use injected streams for the behavior.

Pass the terminal in

Three arguments, and the function stops depending on the process it runs in.

export async function confirm({ question, input, output, isTTY, assumeYes = false }) {
  if (assumeYes) return { answer: true, via: 'flag' }
  if (!isTTY) return { answer: false, via: 'no-terminal' }

Returning how the answer was reached matters as much as the answer. A caller that knows the confirmation came from a flag can log it differently from one a person typed, and a test can assert the path rather than guessing from the result.

The refusal on no terminal is the safety property. A prompt that cannot be shown has not been answered, and treating silence as agreement is how an unattended job becomes an incident.

Drive it with streams the test owns

An in-memory pair stands in for the terminal and records what was written.

export function fakeTerminal(typed = []) {
  const input = new PassThrough()
  const output = new PassThrough()
  const written = []
  output.on('data', (chunk) => written.push(chunk.toString()))
  for (const line of typed) input.write(`${line}\n`)
  if (!typed.length) input.end()
  return { input, output, written, close: () => input.end() }
}

Assert on what was printed as well as on the decision. A prompt whose default changed from [y/N] to [Y/n] is a behavior change nobody will see in a test that only checks the return value.

End the input when there is nothing typed. A prompt waiting on a stream that never closes is the hang from the opening paragraph, reproduced inside your own test suite.

Check it worked

Six situations, covering the flag, the typed answers, and both terminal states.

node demo.mjs
terminal, types y              exit 0  retired mtr_8f2 (confirmed by prompt)  [printed: Retire mtr_8f2? [y/N]]
terminal, types nothing        exit 1  not retiring mtr_8f2  [printed: Retire mtr_8f2? [y/N]]
terminal, types no             exit 1  not retiring mtr_8f2  [printed: Retire mtr_8f2? [y/N]]
piped, no terminal             exit 3  refusing to retire mtr_8f2 with no terminal: pass --yes
piped, with --yes              exit 0  retired mtr_8f2 (confirmed by flag)
terminal, with --yes           exit 0  retired mtr_8f2 (confirmed by flag)

Line four is the case that never gets tested and always matters. There is no terminal, no flag, and the command refuses with its own exit code rather than proceeding.2 Lines five and six show the flag behaving identically whether or not somebody is watching, which is the property that makes the tool predictable.

The printed prompt is captured in the same line as the decision, which is what lets a reviewer check the wording without running anything. [y/N] with the capital on the refusal is the convention, and it is worth asserting rather than assuming.

node --test prompt.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 120.066825

When it goes wrong

The suite hangs on one test. A prompt is waiting for a line on a stream nobody closed. Close the input in the test, and give the runner a timeout so a hang fails rather than blocks. A blocked suite tells you nothing, and a failed one names the case.

The prompt works by hand and not under a task runner. Some runners give a child a pipe rather than a terminal. That is the no-terminal path, and it should refuse rather than surprise anyone.

Output appears in the wrong order. The prompt writes to standard output and the logs go there too. Send the question to standard error, which also keeps piped output clean.3 A prompt is not a result, and the two streams exist to say which is which.

A default answer changed without the tests noticing. The test asserts the boolean and not the text. Assert on the printed prompt as well. The text is part of the interface, and a capital letter in it is the difference between a safe default and a dangerous one.

When not to do this

Do not simulate a terminal to test a question. A pseudo-terminal is a native dependency for a case that two streams cover, and it will fail to build on somebody’s platform during a release.

Do not add a prompt to a command that scripts call. Every prompt is a place automation can stall, and a flag with a refusal by default gives you the same safety with none of the waiting.

Do not let the terminal check decide whether a destructive action is allowed. It should decide how the question is asked. Whether the action proceeds is a flag, so the rule holds whether or not somebody is watching.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. The property is a class marker rather than a probe. When Node finds a terminal on file descriptor 0, process.stdin is created as a tty.ReadStream, whose isTTY the documentation describes as a boolean that is always true. On a pipe the stream is a net.Socket, which the documentation does not credit with the property at all. The documentation’s own example wraps the check in Boolean() before printing it, which is what a value needs when it is either true or absent. ↩︎ Back to text

  2. There is a header for this, and it is deprecated. sysexits.h first appeared in 4BSD with fifteen codes, 64 to 78, starting past the numbers that random programs already return. There is one for a bad command line, one for a missing user, one for a missing host, and twelve for further misfortunes. FreeBSD’s manual page files the header under legacy and discourages its use. Its BUGS section is two sentences long: the interface is not portable, and the choice of an appropriate exit value is often ambiguous. ↩︎ Back to text

  3. Standard error owes its existence to a typesetter. A 2013 post to the Unix Heritage Society list, signed by nothing but its address, scj at yaccman.com, recalls feeding phototypeset paper through the developer. Minutes later came a single beautifully typeset line that read “cannot open file foobar” and nothing else. The grumbles were loud enough and near enough to the right people, and the standard error file was born a couple of days later. The stream that keeps a prompt out of piped output was invented to keep an error out of print. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.