Test a wizard without a terminal¶
Test the steps, not the prompts¶
Wizard never talks to the terminal itself. It calls
WizardStep::prompt, reads the Result<StepOutcome, InquireError> it
gets back, and moves an index. Everything interactive lives inside your
step implementations.
So a test replaces the step, not the terminal. There is no test harness to configure, no PTY to allocate and no feature flag to switch on.
Write a scripted step¶
Give the step a queue of outcomes and let it pop one per call. This is the pattern the crate's own test suite uses:
use std::sync::Mutex;
use async_trait::async_trait;
use rtb_tui::{InquireError, StepOutcome, Wizard, WizardStep};
#[derive(Default)]
struct State {
visits: Vec<&'static str>,
}
struct Scripted {
name: &'static str,
outcomes: Mutex<Vec<Result<StepOutcome, InquireError>>>,
}
#[async_trait]
impl WizardStep<State> for Scripted {
fn name(&self) -> &'static str {
self.name
}
async fn prompt(&self, state: &mut State) -> Result<StepOutcome, InquireError> {
state.visits.push(self.name);
self.outcomes.lock().unwrap().pop().expect("step ran out of outcomes")
}
}
A constructor keeps the call sites readable. prompt pops from the end
of the queue, so the script goes in reversed:
fn scripted(
name: &'static str,
script: Vec<Result<StepOutcome, InquireError>>,
) -> Scripted {
let mut outcomes = script;
outcomes.reverse();
Scripted { name, outcomes: Mutex::new(outcomes) }
}
The Mutex is doing real work here. prompt takes &self, and
WizardStep requires Send + Sync, so a step that needs to mutate
per-call data has to use interior mutability — a plain RefCell will
not satisfy Sync.
Recording visits is what makes back-navigation testable: the
assertion is on the sequence of step names, not on anything rendered.
Assert on the visit order, not on output¶
A three-step wizard where the third step escapes back to the second produces this:
#[tokio::test]
async fn escape_reruns_the_previous_step() {
let wizard = Wizard::<State>::builder()
.initial(State::default())
.step(scripted("greet", vec![Ok(StepOutcome::Next), Ok(StepOutcome::Next)]))
.step(scripted("name", vec![Ok(StepOutcome::Next), Ok(StepOutcome::Next)]))
.step(scripted(
"confirm",
vec![Err(InquireError::OperationCanceled), Ok(StepOutcome::Next)],
))
.build();
let state = wizard.run().await.expect("scenario completes");
assert_eq!(state.visits, vec!["greet", "name", "confirm", "name", "confirm"]);
}
Every step needs enough scripted outcomes for every time it will run. A step that is visited twice needs two entries, and running out is a panic inside the step rather than a wizard error — which is the behaviour you want, because it fails the test loudly.
Test the spinner the same way — by doing nothing¶
cargo test captures stderr, so console::Term::stderr().is_term() is
false and a Spinner constructed inside a test is inert. Constructing,
messaging, finishing and dropping one all complete without writing
anything.
That means a spinner test asserts absence of a panic rather than presence of output:
#[test]
fn spinner_is_inert_without_a_tty() {
let mut spinner = Spinner::new("starting");
spinner.set_message("middle");
spinner.finish();
}
There is no way to make the spinner think it has a terminal in a test. The TTY check reads the real stderr and there is no injection point, so the drawing path is not covered by the suite at all.
Run them¶
which runs cargo nextest run and falls back to cargo test. The
whole suite is headless and offline — no TTY, no network, no fixtures
on disk.
Next¶
- Wizard reference — the navigation rules the scripted outcomes are exercising.