byob-go-cli

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 on View() and model state. No terminal, no goroutines, no sleeps.
  • Program-level: run the real tea.Program headlessly with tea.WithoutRenderer() and an empty tea.WithInput(&bytes. Buffer{}) to detach from os.Stdin, then drive keys with p.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()