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