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 inithang fix (an unguarded wizard blocking on non-TTY stdin) and theconfig migrate-credentialscoverage 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.
WithHideFuncis 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
(
promptManualTokenreturnsErrNoTokenEntered).
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:
- It auto-enables when
TERM=dumb. Inform.go,NewFormcallsWithAccessible(true)whenos.Getenv("TERM") == "dumb". No code change required to flip it on, just the env var. - It reads from
os.Stdinby default.Form.RunWithContextdispatches torunAccessible(output|os.Stdout, input|os.Stdin). Each field'sRunAccessible(w, r)does a simple line read fromr.
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.
Approach A: accessible mode + scripted stdin (recommended for existing code)¶
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 throughPromptPassword, which reads the raw terminal fd (it type-assertsr.(interface{ Fd() uintptr })and puts it in raw mode). A plainos.Pipewon't behave, and huh swallows the failure, leaving the value blank. Test secret entry withformtest.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 thedevnullswap they spam the test log. (Note huh writes the prompt tooutput|os.Stdout, not stderr.) - Always restore globals. The helper restores
os.Stdin/os.Stdoutviat.Cleanup;t.SetenvrestoresTERM. Never leave them swapped: later tests in the sequential phase would inherit them. TERM=dumbonly affects huh. It does not change your code's behaviour; it only flips huh's renderer to the line-based accessible path.
See also¶
- Testing & Mocking: the general unit-testing guide, race-avoidance rules, and
internal/exectestfakes. pkg/cmd/config/migrate_forms_test.go: the realwithScriptedStdintests for the migrate wizard.pkg/setup/forge/drive_test.go: the forge wizards' drivers, one per route (singleAuthIOanddualEnvIOanswer accessible prompts;dualCredentialIOdrives keys for the password page).- config migrate-wizard coverage spec: the decision record behind choosing accessible mode over a seam refactor.