dimetron / bubbletea-testing
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:
// v1
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}
// v2 renames
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() // execute synchronously
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
}
// Test mock
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
// Step 1: user presses enter
m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
// Step 2: execute the resulting command, feed msg back
if cmd != nil {
m, cmd = m.Update(cmd())
}
// Assert final state
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),
)
// Compares against testdata/TestTableRendering.golden
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)))
// Compares against testdata/TestFullOutput.golden
teatest.RequireEqualOutput(t, out)
}Golden file workflow
# 1. Generate initial golden files
go test ./... -update
# 2. Commit them
git add testdata/*.golden
# 3. Normal test runs — fails if output differs
go test ./...
# 4. After intentional UI changes — regenerate and review diff
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),
)
// Interact
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
tm.Type("hello")
// Assert intermediate output (polls reader)
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))
// Quit and assert final state
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).
// v1 — in test init or TestMain
import (
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)
func init() {
lipgloss.SetColorProfile(termenv.Ascii)
}
// v2 — per-program
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
teatest.WithInitialTermSize(80, 24)
// Direct model tests — send resize before View()
m, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
output := m.(myModel).View()
// v2
tea.WithWindowSize(80, 24)3. Prevent git from corrupting golden files
Add to .gitattributes:
*.golden -text
testdata/** -diff linguist-generated=truePrevents 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.Timein 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), // Fixed dimensions
tea.WithInput(nil), // Disable input
tea.WithOutput(&buf), // Redirect to buffer
tea.WithoutRenderer(), // Headless, no rendering
tea.WithoutSignals(), // Ignore OS signals
tea.WithColorProfile(colorprofile.Ascii), // Fixed colors
)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: helloRun 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})
// Optionally execute cmd and feed back
}
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
.gitattributesentry:*.golden -text - Add
init()orTestMainthat setslipgloss.SetColorProfile(termenv.Ascii)
- Create
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
-updateas a manual/explicit step only - Consider a CI step that fails if golden files are uncommitted
- Ensure