| name | bubbletea-testing |
| description | Use this skill whenever writing tests for Bubble Tea (charmbracelet/bubbletea) TUI applications in Go. Triggers include any mention of testing Bubble Tea models, teatest, golden file testing for TUIs, testing tea.Cmd or tea.Msg, snapshot testing terminal output, or writing tests for any Go CLI/TUI that uses the Elm Architecture (Init/Update/View). Also use when the user asks about testing bubbletea components, bubbles, or lipgloss-styled views, or when they need CI-friendly TUI test patterns. Even if they just say "test my TUI" or "add tests to my Bubble Tea app", use this skill. |
Bubble Tea Testing
Write robust, CI-friendly tests for Bubble Tea TUI applications using a three-layer strategy: direct model unit tests, golden file view snapshots, and full-program integration tests via teatest.
Architecture overview
Bubble Tea's Elm Architecture (Init, Update, View) makes TUI apps inherently testable. Update(msg) -> (model, cmd) is a pure function of state and message — no terminal, program, or event loop needed for most tests.
Three-layer strategy:
| Layer | Coverage | Speed | Tool |
|---|
| 1. Direct model tests | State transitions, commands, view content | ~ms | Standard testing |
| 2. Golden file snapshots | Visual regression on View() output | ~ms | golden.RequireEqual |
| 3. Full integration | End-to-end user flows | ~seconds | teatest.NewTestModel |
Target ratio: 80% Layer 1 / 15% Layer 2 / 5% Layer 3.
Layer 1: Direct model unit tests
Constructing test messages
Build tea.Msg values directly — they are plain Go structs:
qKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}
enter := tea.KeyMsg{Type: tea.KeyEnter}
ctrlC := tea.KeyMsg{Type: tea.KeyCtrlC}
down := tea.KeyMsg{Type: tea.KeyDown}
resize := tea.WindowSizeMsg{Width: 80, Height: 24}
qKey := tea.KeyPressMsg{Type: tea.KeyRunes, Runes: []rune("q")}
click := tea.MouseClickMsg{X: 10, Y: 5, Button: tea.MouseButtonLeft}
Table-driven Update tests
The standard pattern — each case specifies initial state, message, and expected outcome:
func TestUpdate(t *testing.T) {
tests := []struct {
name string
initial model
msg tea.Msg
wantCursor int
wantQuit bool
}{
{
name: "down moves cursor",
initial: model{cursor: 0, choices: []string{"a", "b", "c"}},
msg: tea.KeyMsg{Type: tea.KeyDown},
wantCursor: 1,
},
{
name: "cursor stops at bottom",
initial: model{cursor: 2, choices: []string{"a", "b", "c"}},
msg: tea.KeyMsg{Type: tea.KeyDown},
wantCursor: 2,
},
{
name: "q triggers quit",
initial: model{},
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")},
wantQuit: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
updated, cmd := tt.initial.Update(tt.msg)
m := updated.(model)
if m.cursor != tt.wantCursor {
t.Errorf("cursor = %d, want %d", m.cursor, tt.wantCursor)
}
if tt.wantQuit {
if cmd == nil {
t.Fatal("expected quit command")
}
if _, ok := cmd().(tea.QuitMsg); !ok {
t.Error("quit command did not return QuitMsg")
}
}
})
}
}
Testing commands synchronously
tea.Cmd is func() tea.Msg. Execute it directly and inspect the result:
func TestQuitCommand(t *testing.T) {
m := model{}
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected QuitMsg, got %T", msg)
}
}
Mocking I/O dependencies
Use interfaces for anything that does real I/O, inject mocks in tests:
type DataFetcher interface {
FetchItems() ([]Item, error)
}
type model struct {
fetcher DataFetcher
items []Item
}
type mockFetcher struct {
items []Item
err error
}
func (f mockFetcher) FetchItems() ([]Item, error) { return f.items, f.err }
func TestFetchSuccess(t *testing.T) {
m := model{fetcher: mockFetcher{items: []Item{{Name: "test"}}}}
cmd := m.fetchCmd()
msg := cmd()
result, ok := msg.(itemsMsg)
if !ok {
t.Fatalf("expected itemsMsg, got %T", msg)
}
if len(result.items) != 1 {
t.Errorf("expected 1 item, got %d", len(result.items))
}
}
Chaining update–command–message cycles
Simulate multi-step flows without a running program:
func TestMultiStepFlow(t *testing.T) {
m := tea.Model(initialModel())
var cmd tea.Cmd
m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd != nil {
m, cmd = m.Update(cmd())
}
final := m.(myModel)
if final.state != resultView {
t.Errorf("expected resultView, got %v", final.state)
}
}
Testing View output with substring assertions
Prefer substring checks over exact matches — more resilient to styling changes:
func TestViewShowsSelection(t *testing.T) {
m := model{
cursor: 1,
choices: []string{"carrots", "celery", "kohlrabi"},
selected: map[int]struct{}{1: {}},
}
view := m.View()
if !strings.Contains(view, "[x] celery") {
t.Errorf("expected selected celery in view:\n%s", view)
}
if !strings.Contains(view, "[ ] carrots") {
t.Errorf("expected unselected carrots in view:\n%s", view)
}
}
Principle: Assert on intent (flags, indices, content), not styling.
Testing nested/composed models
Test parent routing and child transitions independently:
func TestParentRoutesToActiveChild(t *testing.T) {
parent := newParentModel()
updated, _ := parent.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
p := updated.(parentModel)
child := p.list.(listModel)
if child.width != 80 {
t.Errorf("child width = %d, want 80", child.width)
}
}
Layer 2: Golden file testing
Packages
| Package | Import | Use case |
|---|
golden | github.com/charmbracelet/x/exp/golden | Component-level View snapshots |
teatest | github.com/charmbracelet/x/exp/teatest | Full-program output snapshots |
Component snapshot with golden.RequireEqual
import "github.com/charmbracelet/x/exp/golden"
func TestTableRendering(t *testing.T) {
tbl := table.New(
table.WithColumns(columns),
table.WithRows(rows),
)
golden.RequireEqual(t, tbl.View())
}
golden.RequireEqual auto-escapes ANSI codes and uses go-udiff for portable diffs.
Full-program snapshot with teatest.RequireEqualOutput
func TestFullOutput(t *testing.T) {
tm := teatest.NewTestModel(t, initialModel(),
teatest.WithInitialTermSize(80, 24),
)
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
out, _ := io.ReadAll(tm.FinalOutput(t, teatest.WithFinalTimeout(3*time.Second)))
teatest.RequireEqualOutput(t, out)
}
Golden file workflow
go test ./... -update
git add testdata/*.golden
go test ./...
go test ./... -update
git diff testdata/
Layer 3: Full integration with teatest
Core API
import (
"bytes"
"io"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
)
func TestIntegration(t *testing.T) {
tm := teatest.NewTestModel(t, initialModel(),
teatest.WithInitialTermSize(80, 24),
)
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
tm.Type("hello")
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
return bytes.Contains(bts, []byte("hello"))
}, teatest.WithDuration(2*time.Second),
teatest.WithCheckInterval(100*time.Millisecond))
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
fm := tm.FinalModel(t, teatest.WithFinalTimeout(3*time.Second))
m := fm.(myModel)
if !m.submitted {
t.Error("expected submitted")
}
}
teatest API reference
| Method | Purpose | Blocks? |
|---|
NewTestModel(tb, model, opts...) | Create & start headless program | No |
tm.Send(msg) | Inject any tea.Msg | No |
tm.Type(s) | Type string as key events | No |
tm.Output() | Live output io.Reader | No |
tm.FinalOutput(tb, opts...) | Complete output after quit | Yes |
tm.FinalModel(tb, opts...) | Final tea.Model after quit | Yes |
tm.WaitFinished(tb, opts...) | Block until program exits | Yes |
WaitFor(tb, reader, cond, opts...) | Poll reader until condition true | Yes |
RequireEqualOutput(tb, out) | Golden file comparison | No |
Always set timeouts via WithFinalTimeout to prevent hanging tests.
CI determinism — the three critical fixes
1. Force a fixed color profile
Without this, golden files from a TrueColor dev terminal will mismatch in CI (no TTY).
import (
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)
func init() {
lipgloss.SetColorProfile(termenv.Ascii)
}
import "github.com/charmbracelet/colorprofile"
prog := tea.NewProgram(model, tea.WithColorProfile(colorprofile.Ascii))
Use termenv.Ascii for simplest golden files. Use termenv.TrueColor if testing color output.
2. Lock terminal dimensions
teatest.WithInitialTermSize(80, 24)
m, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
output := m.(myModel).View()
tea.WithWindowSize(80, 24)
3. Prevent git from corrupting golden files
Add to .gitattributes:
*.golden -text
testdata/** -diff linguist-generated=true
Prevents CRLF normalization and suppresses golden files from GitHub PR diffs.
Handle non-deterministic elements
Spinners, timestamps, cursor blink, and animations produce varying output. Strategies:
- Freeze spinner frame index to 0 in test setup
- Inject a clock interface for timestamps, use fixed
time.Time in tests
- Disable cursor blink before capture
- Seed RNGs with constant values
- For animations, test the final state rather than intermediate frames
Bubble Tea v2 testing options
v2 (charm.land/bubbletea/v2) adds first-class ProgramOption values for testing:
prog := tea.NewProgram(model,
tea.WithWindowSize(80, 24),
tea.WithInput(nil),
tea.WithOutput(&buf),
tea.WithoutRenderer(),
tea.WithoutSignals(),
tea.WithColorProfile(colorprofile.Ascii),
)
These replace global init() hacks with explicit per-program config.
The v2 teatest package is at github.com/charmbracelet/x/exp/teatest/v2.
Community tools
knz/catwalk — data-driven text-file tests
Test cases as plain text files with input directives:
run
type hello
key enter
----
-- view:
You typed: hello
Run with -rewrite to regenerate expected output. Good for testing individual Bubbles components.
Repo: github.com/knz/catwalk
Custom direct-model harness (Noteleaf pattern)
Drive tea.Model directly without tea.NewProgram for single-threaded, faster tests:
type TestHarness struct {
model tea.Model
}
func (h *TestHarness) SendKey(key tea.KeyType) {
var cmd tea.Cmd
h.model, cmd = h.model.Update(tea.KeyMsg{Type: key})
}
func (h *TestHarness) WaitForView(contains string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if strings.Contains(h.model.View(), contains) {
return nil
}
time.Sleep(10 * time.Millisecond)
}
return fmt.Errorf("timed out waiting for %q", contains)
}
Lighter than teatest, but doesn't test the terminal rendering pipeline.
Checklist for adding tests to a Bubble Tea app
-
Set up test infrastructure:
- Create
testdata/ directory for golden files
- Add
.gitattributes entry: *.golden -text
- Add
init() or TestMain that sets lipgloss.SetColorProfile(termenv.Ascii)
-
Layer 1 — Write table-driven Update tests for:
- Every key binding and its effect on model state
- State machine transitions (view switches, mode changes)
- Edge cases (empty lists, max cursor, error states)
- Commands returned by Update (execute synchronously, assert on msg type)
- Custom message handlers (API responses, timer ticks)
-
Layer 2 — Add golden file tests for key UI states:
- Initial/welcome screen
- Loading/spinner state
- Error display
- Main content with data populated
- Empty state
-
Layer 3 — Write integration tests for critical flows:
- Startup → input → result → quit
- Error recovery paths
- Multi-step wizards or workflows
-
CI pipeline:
- Ensure
go test ./... passes without -update
- Add
-update as a manual/explicit step only
- Consider a CI step that fails if golden files are uncommitted