Skip to content

Testing huh / charm interactive forms

Interactive prompts in GTB are built on charm.land/huh. A form's Run() method takes over the terminal and blocks on real keyboard input, so a naive unit test either hangs forever (no TTY to read) or can't assert anything. This page covers the three ways to test form-driving code headlessly, when to reach for each, and the sharp edges.

This is the same class of problem behind the gtb init hang fix (an unguarded wizard blocking on non-TTY stdin) and the config migrate-credentials coverage work. See the migrate-wizard spec.

TL;DR: pick an approach

Situation Approach Parallel-safe?
Code runs its form through setup.RunForm(ctx, p, form) (every form in pkg/) D. Props.IO with internal/formtest Yes
You need to assert field-level keystroke behaviour (a hide function, a dynamic select) C. Drive the form as a tea.Model, or D with formtest.Keys Yes
Legacy code calls huh.NewForm(...).Run() directly and you can't change it A. Accessible mode + scripted stdin (TERM=dumb, swap os.Stdin) No (serial)
The field is a password (EchoMode(huh.EchoModePassword)) D with formtest.Keys, or C (accessible mode can't script it, see gotchas) —

The default for framework code is D. Injecting a form creator (the old "B, WithForm pattern") is gone: it left the wizard's own forms untested and put test-only options in the public API (spec 0198).

D. Props.IO and internal/formtest

Props.IO is the invocation's streams (props.StdIO{} is the process's). setup.RunForm runs every form on them, applies the IO's accessible decision, and refuses a stdin that is neither a terminal nor accessible before the form opens. A test sets the IO and drives the real form:

p := &props.Props{IO: props.StdIO{
    Stdin:          formtest.Answers("2", "MY_VAR", "y"), // one answer per field
    Stdout:         io.Discard,
    Stderr:         io.Discard,
    AccessibleMode: true,
}}
require.NoError(t, RunAIInit(ctx, p, dir))

formtest.Answers is what a person types at accessible prompts: an option's number for a select, a line for an input, y/n for a confirm. It yields one answer per Read, because huh reads each field through a fresh buffered reader and a plain strings.Reader would lose every answer after the first.

For behaviour that only the TUI path has (a hide function, OptionsFunc, a password field), drive keys instead:

p := &props.Props{IO: formtest.TUI(formtest.Keys(formtest.Down, formtest.Enter, "MY_VAR", formtest.Enter))}

Keys paces one sequence per Read (the parser merges bytes that arrive together) and TUI is an IO that reports interactive so the form runs headless with no renderer. Slower (tens of milliseconds a key), so reach for it only when the accessible route cannot express the behaviour.

A wizard that runs several forms in turn (the Bitbucket credentials, then the SSH key, then the upload question) takes formtest.TUIForms(script1, script2, ...), one script per form. A form's program reads ahead of the keys it has handled and keeps the surplus when it quits, so a later form's keys on a shared reader are lost.

Three facts about accessible mode decide which route a test takes (huh v2.0.3, form.go runAccessible):

  • Every field of every group is asked, in order. WithHideFunc is not consulted, so an answers script covers the hidden pages too, and a test that a page is hidden has to drive keys.
  • Group titles and descriptions are not printed. Assert on field titles.
  • A field's error is swallowed. A password input with no terminal behind it fails with "password asking needs a tty" and the bound value stays blank. A wizard that must have the value checks for the blank itself (promptManualToken returns ErrNoTokenEntered).

The answers route touches nothing global and is parallel-safe. The key route is time-paced (huh's group transitions are asynchronous commands), so tests that drive keys do not call t.Parallel().

How huh makes this possible: accessible mode

huh ships a first-class accessible mode (built for screen readers) that replaces the full-screen TUI with plain line-based prompts. Two facts make it the key to headless testing:

  1. It auto-enables when TERM=dumb. In form.go, NewForm calls WithAccessible(true) when os.Getenv("TERM") == "dumb". No code change required to flip it on, just the env var.
  2. It reads from os.Stdin by default. Form.RunWithContext dispatches to runAccessible(output|os.Stdout, input|os.Stdin). Each field's RunAccessible(w, r) does a simple line read from r.

So setting TERM=dumb and feeding os.Stdin drives the real production form, no stubbing, no refactor.

Accessible input formats per field

What you write to stdin depends on the field type:

Field Reads Feed Empty line
huh.NewInput() (normal) one line (PromptString) "MY_VALUE\n" uses the field's default value
huh.NewConfirm() y/n (PromptBool) "y\n" or "n\n" uses the default ([Y/n] vs [y/N])
huh.NewSelect() a 1-based option number (PromptInt) "2\n" uses the default option
huh.NewText() one line "some text\n" default
huh.NewNote() nothing (display only) — —
huh.NewInput().EchoMode(huh.EchoModePassword) raw terminal fd (PromptPassword) not scriptable via a pipe —

Invalid input (a failing Validate) re-prompts: the field loops and consumes another line. Always feed a value that passes validation, or the test will block waiting for the next line.

Drop this helper into your _test.go (it lives in pkg/cmd/config/migrate_forms_test.go for the migrate wizard):

// withScriptedStdin runs fn with huh in accessible mode (TERM=dumb) and os.Stdin
// fed from script, restoring both afterwards. Tests using it must NOT call
// t.Parallel(): it mutates process-global os.Stdin/os.Stdout and TERM.
func withScriptedStdin(t *testing.T, script string, fn func()) {
    t.Helper()
    t.Setenv("TERM", "dumb") // huh → accessible mode; also forbids t.Parallel

    r, w, err := os.Pipe()
    require.NoError(t, err)

    origIn, origOut := os.Stdin, os.Stdout
    os.Stdin = r

    devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
    require.NoError(t, err)
    os.Stdout = devnull // swallow the accessible prompt text

    t.Cleanup(func() {
        os.Stdin, os.Stdout = origIn, origOut
        _ = devnull.Close()
    })

    go func() {
        _, _ = io.WriteString(w, script)
        _ = w.Close()
    }()

    fn()
}

Example: an input prompt

func TestResolveEnvVarName_InteractivePrompt(t *testing.T) {
    withScriptedStdin(t, "MY_CUSTOM_TOKEN\n", func() {
        name, err := resolveEnvVarName(MigrateOptions{}, literalCredential{Key: "github.auth.value"})
        require.NoError(t, err)
        assert.Equal(t, "MY_CUSTOM_TOKEN", name)
    })
}

Example: a confirm prompt, plus a real side effect

func TestInstructAndVerifyEnvVar_ConfirmedButUnset(t *testing.T) {
    withScriptedStdin(t, "y\n", func() {
        // Var deliberately not exported → the post-confirm verification fails.
        err := instructAndVerifyEnvVar("UNSET_TOKEN", literalCredential{Key: "github.auth.value"}, false)
        require.Error(t, err)
        assert.Contains(t, err.Error(), "is not set in the current environment")
    })
}

Why these tests must be serial

withScriptedStdin swaps process-global os.Stdin/os.Stdout and sets TERM. t.Setenv deliberately panics if t.Parallel() was called, which is the safety interlock: these tests run in go test's sequential phase, where the package's parallel tests are paused, so nothing else reads os.Stdin concurrently. This is a contained, test-only use of global state (not a production mocking hook) so it does not violate the no-package-level-hooks rule. See also t.Parallel() + t.Setenv() are incompatible.

Approach C: drive the form as a tea.Model

Every huh.Form is a Bubble Tea model. If your code hands you the *huh.Form (rather than calling .Run() itself), you can feed synthetic key events and assert on form.State: fully parallel, no global state. This is how huh tests itself.

// Minimal key-event helpers (huh keeps these unexported; copy them into your test).
func keypress(r rune) tea.KeyPressMsg {
    return tea.KeyPressMsg(tea.Key{Text: string(r), Code: r, ShiftedCode: r})
}
func key(code rune) tea.KeyPressMsg { return tea.KeyPressMsg(tea.Key{Code: code}) }

func TestMyForm(t *testing.T) {
    t.Parallel()

    var name string
    form := huh.NewForm(huh.NewGroup(
        huh.NewInput().Value(&name),
    ))
    form.Update(form.Init())

    m, _ := form.Update(keypress('g'))
    m, _ = m.Update(keypress('t'))
    m, _ = m.Update(keypress('b'))
    m, _ = m.Update(key(tea.KeyEnter)) // submit

    assert.Equal(t, huh.StateCompleted, m.(*huh.Form).State)
    assert.Equal(t, "gtb", name)
}

Use this when you specifically need to assert keystroke-level behaviour (navigation, filtering, validation feedback) rather than just the final value.

Gotchas

  • Password fields aren't scriptable at accessible prompts. EchoMode(huh.EchoModePassword) routes through PromptPassword, which reads the raw terminal fd (it type-asserts r.(interface{ Fd() uintptr }) and puts it in raw mode). A plain os.Pipe won't behave, and huh swallows the failure, leaving the value blank. Test secret entry with formtest.Keys (D) or C.
  • Feed enough lines, then close the writer. A form with N fields reads N lines. Under-feeding leaves the read blocking. The helper closes the pipe writer after writing, so a stuck read surfaces as a fast EOF rather than a hang.
  • Validation loops consume extra lines. If a value fails Validate, the field re-prompts and reads again. Feed values that pass, or script the retry explicitly.
  • Redirect os.Stdout. Accessible prompts print to stdout; without the devnull swap they spam the test log. (Note huh writes the prompt to output|os.Stdout, not stderr.)
  • Always restore globals. The helper restores os.Stdin/os.Stdout via t.Cleanup; t.Setenv restores TERM. Never leave them swapped: later tests in the sequential phase would inherit them.
  • TERM=dumb only affects huh. It does not change your code's behaviour; it only flips huh's renderer to the line-based accessible path.

See also