Architecture

Testing Interactive Prompts and stdin

Test Python CLI prompts, confirmations, passwords, and piped input - supply stdin through the runner and cover the non-interactive path properly.

Updated

Interactive prompts are pleasant for users and awkward for tests, because a test has nobody to answer them. The runner solves that by supplying stdin — but the more important lesson is that most tests should not go through the prompt at all.

TL;DR

  • Supply answers with runner.invoke(app, argv, input="prod\ny\n"), one line per prompt, in order.
  • Test the prompt behaviour once; test everything else through the flag that skips it.
  • Always cover the non-interactive path: no terminal means fail with a message naming the flag, never hang.
  • Passwords read without echoing, so the answer does not appear in captured output.
  • A missing input line aborts the command rather than hanging, which is what makes this testable.

Prerequisites

A command that prompts — typer.prompt, typer.confirm, click.prompt, click.confirm or Rich's Prompt.ask — plus pytest and the framework's CliRunner.

Supplying answers

from typer.testing import CliRunner

from mytool.cli import app

runner = CliRunner(mix_stderr=False)

def test_destroy_asks_for_confirmation():
    result = runner.invoke(app, ["destroy", "prod"], input="y\n")

    assert result.exit_code == 0
    assert "Really destroy prod?" in result.stdout
How supplied input is consumed A test supplying three lines of input to a command that asks two questions and reads one value from standard input. How supplied input is consumed input="prod\ny\n" one line per prompt, in order "prod" answers the environment prompt "y" answers the confirmation (nothing left) a third prompt would abort A missing line raises an abort, not a hang Prompts echo into the captured output Passwords are read without echoing Order matters: the string is consumed line by line as each prompt asks.

The string is consumed line by line as each prompt asks, so the order of the answers must match the order of the questions. If a command asks two questions and you supply one line, the second prompt hits end-of-input and the command aborts — the exit code becomes 1, which is a much better failure mode than a hanging test.

An empty line accepts the default:

def test_environment_prompt_defaults_to_staging():
    result = runner.invoke(app, ["deploy"], input="\ny\n")     # accept default, then confirm

    assert result.exit_code == 0
    assert "deploying to staging" in result.stdout

Test the prompt once, then skip it

Testing a command that asks a question A decision diagram for testing interactive commands: supply the answer through the runner input for prompt behaviour, or pass the flag to test the non-interactive path. Testing a command that asks a question Which behaviour is this test about? The prompt itself — defaults, re-asking, validation Feed stdin runner.invoke(..., input="y\n") Everything after the value is known Pass the flag no prompt, faster, clearer Most tests want the second branch: prompts are one behaviour, not a prerequisite for every other test.

A test that supplies input to reach the behaviour it actually cares about is fragile: adding a confirmation prompt to the command breaks every one of them. Cover the prompt itself in one or two tests, and give every other test the flag that bypasses it.

def test_prompted_value_is_used():                       # about the prompt
    result = runner.invoke(app, ["deploy"], input="prod\ny\n")
    assert "deploying to prod" in result.stdout

def test_deploy_scales_replicas(cli):                    # about the behaviour
    result = cli.invoke(app, ["deploy", "--env", "prod", "--yes", "--replicas", "5"])
    assert result.exit_code == 0

This is also a design pressure worth feeling: if there is no flag that skips the prompt, your command cannot be used in automation either. Adding --yes for the tests makes the tool better for cron jobs at the same time.

The non-interactive path

The behaviour that matters most in production is what happens when nobody is there. A prompt in a CI job hangs the pipeline until it times out, and the log gives no clue why.

def confirm(question: str, *, assume_yes: bool) -> bool:
    if assume_yes:
        return True
    if not sys.stdin.isatty():
        err.print("[red]refusing to prompt without a terminal — pass --yes[/]")
        raise typer.Exit(2)
    return typer.confirm(question, default=False)

Testing it needs one wrinkle: under the runner, stdin is a captured stream and isatty() is already False, so the non-interactive branch is the default in tests.

def test_refuses_to_prompt_without_a_terminal(cli):
    result = cli.invoke(app, ["destroy", "prod"])          # no input supplied

    assert result.exit_code == 2
    assert "--yes" in result.stderr                        # the message names the fix

To exercise the interactive branch you have to say so explicitly, which is a good argument for routing the check through one helper you can override:

def test_prompts_when_a_terminal_is_present(cli, monkeypatch):
    monkeypatch.setattr("mytool.console.stdin_is_a_terminal", lambda: True)

    result = cli.invoke(app, ["destroy", "prod"], input="n\n")

    assert result.exit_code == 1                           # declined
    assert "Really destroy" in result.stdout
What the runner does with supplied input When input is supplied to the runner, the prompt reads from it as if a user had typed, and the echoed answer appears in the captured output. What the runner does with supplied input Test Runner Prompt Output invoke(app, [], input="prod\ny\n") stdin is the supplied string question and echoed answer assert on result.output Each prompt consumes one line, so the order of the answers must match the order of the questions.

Passwords and hidden input

Hidden prompts read from the same stream but do not echo, so the answer never appears in the captured output:

def test_login_accepts_a_password():
    result = runner.invoke(app, ["login", "--user", "ana"], input="hunter2\n")

    assert result.exit_code == 0
    assert "hunter2" not in result.stdout                  # never echoed
    assert "logged in as ana" in result.stdout

That second assertion is worth keeping permanently. It fails the day somebody switches hide_input=True off, or logs the value while debugging — which is the way secrets end up in CI logs.

For confirmation-style password prompts (confirmation_prompt=True), supply the value twice:

result = runner.invoke(app, ["init"], input="hunter2\nhunter2\n")

Reading piped data rather than prompting

Some commands read a document from stdin rather than asking questions. The runner handles that identically, and the interesting tests are the edge cases:

def test_reads_a_json_document_from_stdin(cli):
    result = cli.invoke(app, ["apply", "-"], input='{"replicas": 3}')

    assert result.exit_code == 0
    assert "applied 1 change" in result.stdout

def test_empty_stdin_is_a_clear_error(cli):
    result = cli.invoke(app, ["apply", "-"], input="")

    assert result.exit_code == 65
    assert "no input on stdin" in result.stderr

The empty-stream case is the one real users hit — an upstream command produced nothing — and the default failure without that check is a JSONDecodeError traceback that tells them nothing.

UX considerations

Three behaviours make a prompting command pleasant, and each is worth a test.

Offer a default and show it. typer.confirm("Deploy now?", default=False) renders as [y/N], which tells the user what pressing Enter does. A prompt with no default and no indication is a small cruelty.

Re-ask on invalid input rather than failing. Both frameworks do this for typed prompts, and it is worth confirming with a test that supplies a bad answer followed by a good one: input="maybe\ny\n".

Never prompt for something a flag could supply. Every prompt should have a corresponding option, so the same command works interactively and in a script. That is the property that makes the tool automatable, and the tests fall out of it naturally.

Testing the behaviour

A compact suite for a prompting command looks like this:

@pytest.mark.parametrize("answer,expected_code", [("y\n", 0), ("n\n", 1)])
def test_confirmation_outcomes(cli, monkeypatch, answer, expected_code):
    monkeypatch.setattr("mytool.console.stdin_is_a_terminal", lambda: True)
    assert cli.invoke(app, ["destroy", "prod"], input=answer).exit_code == expected_code

def test_yes_flag_skips_the_prompt(cli):
    result = cli.invoke(app, ["destroy", "prod", "--yes"])
    assert result.exit_code == 0
    assert "Really destroy" not in result.stdout           # no question was asked

def test_no_terminal_and_no_flag_fails_fast(cli):
    assert cli.invoke(app, ["destroy", "prod"]).exit_code == 2

Four tests cover the whole surface: both answers, the bypass, and the automation path.

Conclusion

Prompts are testable, but they should not be a prerequisite for testing anything else. Pin the prompt behaviour in a couple of tests, give every prompt a flag that skips it, and make sure the non-interactive path fails immediately with a message naming that flag. The result is a command that is comfortable to use by hand and safe to put in a pipeline.

Frequently asked questions

Why does my test hang instead of failing?

Under the runner it should not — stdin is a captured stream, so a prompt with no input available raises an abort. A genuine hang usually means the command bypassed the framework and read from the real terminal directly, for example with getpass.getpass(), which opens /dev/tty rather than reading stdin.

How do I supply different answers to different prompts?

One line per prompt, in the order they are asked, in a single string: input="prod\n5\ny\n". If the order is hard to keep straight, that is a signal the command asks too many questions — consider taking the values as options with sensible defaults instead.

Can I assert on the exact prompt text?

You can, since prompts echo into the captured output, but prefer asserting on a distinctive fragment. Pinning the whole line makes every wording improvement a test failure; asserting that the environment name appears in the question catches the mistakes that matter.

How do I test a Rich prompt?

The same way — rich.prompt.Prompt.ask reads from the console's input stream, which the runner replaces. If you constructed the console with an explicit file=, pass the console in as a parameter so tests can supply one wired to a string buffer.

Should confirmation be a prompt or a flag?

Both. Destructive commands deserve a confirmation for interactive users and a --yes for automation, and the flag should be the only way to proceed when there is no terminal. A tool that can only be used interactively is a tool nobody can schedule, and being able to schedule the tool is usually most of the reason it exists at all.

Should the confirmation prompt appear in the tests' captured output?

Yes, and asserting on a fragment of it is worthwhile — it proves the question was actually asked rather than skipped by a default that changed. Keep the assertion to the distinctive part, such as the resource name, so that rewording the prompt does not break it.

What if a command prompts only when a value is missing?

Test both branches: one invocation supplying the value as a flag, asserting no prompt appeared, and one supplying it through stdin. That pair pins the rule "prompt only for what was not given", which is exactly the behaviour that makes the command usable in a pipeline.