Deterministic TUI tests: time via tea.Tick, headless via WithoutRenderer
byob-testing.6
progresstesting
Problem: a Bubble Tea model that reads time.Now() inside Update
or View is untestable — elapsed-time displays flake by the
millisecond, and exercising the program at all seems to require a
real terminal.
Idea: keep the model a pure state machine over messages, with time
as just another message. Live counters advance only via a
tea.Tick-scheduled tickMsg (the model adds one fixed
tickInterval per tick); authoritative durations arrive inside the
domain event messages and reset the local offset, so tick drift is
bounded by one event interval and self-corrects. The tick handler
stops rescheduling once the model reaches its done state, or the
program never idles.
Two test tiers fall out:
- Unit: call
Update(tickMsg{})/Update(eventMsg{...})directly and assert onView()and model state. No terminal, no goroutines, no sleeps. - Program-level: run the real
tea.Programheadlessly withtea.WithoutRenderer()and an emptytea.WithInput(&bytes. Buffer{})to detach from os.Stdin, then drive keys withp.Send(tea.KeyMsg{...})— key handling, quit paths, and message plumbing get covered without a PTY. Sending typed messages beats scripting bytes through the input reader: no raw-mode decoding in the loop, and multi-key sequences (a quit-confirm prompt, say) stay explicit in the test.
Tradeoffs: the display is quantized to the tick interval and can drift slightly between authoritative events — acceptable for a progress readout, wrong for anything billed or logged (source those from the domain events, byob-storage.5). Wall-clock reads belong in the producer that stamps the event, never in the view.
Design
const tickInterval = time.Second
func tick() tea.Cmd {
return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} })
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case eventMsg: // authoritative snapshot from the worker
m.elapsed = msg.Elapsed // resets any tick drift
return m, nil
case tickMsg:
if m.done {
return m, nil // stop advancing and stop rescheduling
}
m.elapsed += tickInterval
return m, tick()
case doneMsg:
m.done = true
return m, nil
}
return m, nil
}
// Unit test: drive messages, assert on View.
m2, _ := m.Update(tickMsg{})
if got := m2.(model).View(); !strings.Contains(got, "0:01") { ... }
// Program-level test: headless, input detached, keys via Send.
p := tea.NewProgram(newModel(...),
tea.WithoutRenderer(),
tea.WithInput(&bytes.Buffer{}), // detach from os.Stdin
)
go func() { p.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) }()
_, err := p.Run()