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 secret (EchoMode(setup.SecretEchoMode(io))) |
D with answers (it reads a plain line on a pipe), or formtest.Keys for the TUI |
— |
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, and an empty
string for Enter. A multi-select is confirmed with 0. setup.RunForm hands
huh one line per read, so a plain strings.Reader works as well. Answers that
run out before the questions do are setup.ErrInputEnded, so a test gives one
line per question it expects to be asked.
For behaviour that only the TUI path has (reactive text as it changes, a password field's masking), 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.
Facts about accessible mode that decide which route a test takes (huh
v2.0.3, form.go runAccessible):
- huh asks every field of every group, ignoring
WithHideFunc, and never refreshes a*Funcbinding. The framework's and the generator's wizards declare their pages throughinternal/formpageinstead, which runs each visible page on its own and builds it when it is asked. So for those wizards an answers script covers only the visible pages, and a test can assert a hidden page is absent from the transcript. A form passed straight tosetup.RunFormstill has huh's behaviour. - An empty line is validated as
""before huh falls back to the field's value. A text field that validates wraps its validator informpage.ValidateAnswer, so Enter keeps a pre-filled answer. - Group titles and descriptions are not printed. Assert on field titles.
- A password needs a terminal. huh reads an accessible password straight
from the terminal's descriptor, fails without one ("password asking needs a
tty") and swallows the error, leaving the value blank. A secret field takes
its echo mode from
setup.SecretEchoMode(io): hidden at a terminal, whose descriptorsetup.RunFormpasses through, and a plain line on a pipe, where nothing echoes anyway (#108). So an answers script can supply a secret.
The upstream fixes are proposed in huh#780, huh#832 and huh#833; when a huh
release carries them, internal/formpage's accessible path and
ValidateAnswer can go.
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¶
- A hard-coded
EchoModePasswordisn't scriptable at accessible prompts. It 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. Usesetup.SecretEchoMode(io)for the field and script it with answers, or test it withformtest.Keys(D) or C. The terminal path is covered with a real pseudo-terminal (pkg/setup/form_secret_test.go). - 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.