Skip to content

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

$ just test

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.