# 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.

Source: https://voxgig.com/howto/test-interactive-prompts-and-tty-behaviour

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://nodejs.org/api/tty.html#readstreamistty), and the whole technique here is to
stop calling it inside the function that needs the answer.[^1] The prompt itself is
[readline](https://nodejs.org/api/readline.html), which takes its streams as arguments already.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A pseudo-terminal in the test](https://github.com/microsoft/node-pty) | You need to test terminal drawing, colours, or line editing | A native dependency that has to build on every platform in your matrix | The prompt is a question and an answer |
| [Injected streams](https://nodejs.org/api/stream.html#class-streampassthrough) | Almost every prompt, because the logic is what you are testing | A parameter on the prompt, which callers must pass | The behavior under test is the terminal itself |
| [Spawning the CLI and writing to its stdin](https://nodejs.org/api/child_process.html#child_processspawncommand-args-options) | End-to-end coverage of argument parsing through to output | A process per case, and a pipe rather than a terminal, so `isTTY` is false | You want the prompt logic covered case by case |
| [Testing only the non-interactive path](https://nodejs.org/api/test.html) | Nothing, once a prompt exists | The interactive path is the dangerous one and it stays uncovered | Always, 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.

```js title="prompt.mjs"
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.

```js title="streams.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test prompt.test.mjs
```

```text output
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.

## Related how-tos

- [Design a CLI a coding agent can drive](/howto/design-a-cli-a-coding-agent-can-drive)

- [Stub outbound HTTP calls in Node tests with nock](/howto/stub-outbound-node-http-calls-with-nock)

## Last verified

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

[^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`](https://nodejs.org/api/tty.html#readstreamistty), whose `isTTY` the
documentation describes as a boolean that is always true. On a pipe the stream is a
[`net.Socket`](https://nodejs.org/api/process.html#processstdin), 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.

[^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](https://man.freebsd.org/cgi/man.cgi?query=sysexits&sektion=3)
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.

[^3]: Standard error owes its existence to a typesetter. A 2013
[post to the Unix Heritage Society list](https://www.tuhs.org/pipermail/tuhs/2013-December/006113.html),
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.