
Bubbletea Testing
- 1 installs
- 117 repo stars
- Updated August 5, 2026
- dimetron/pi-go
Write CI-friendly unit, golden-snapshot, and integration tests for Bubble Tea TUI applications in Go.
About
Provides CI-friendly test patterns for Bubble Tea TUI apps in Go across unit, golden-snapshot, and integration layers. A developer uses it when adding tests to a Bubble Tea or Elm-Architecture Go TUI.
- Three-layer strategy: direct model unit tests, golden-file view snapshots, and teatest integration tests
- Table-driven Update tests build tea.Msg structs directly for both Bubble Tea v1 and v2
Bubbletea Testing by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,750 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dimetron/pi-go --skill bubbletea-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 117 |
| Last updated | August 5, 2026 |
| Repository | dimetron/pi-go ↗ |
What it does
Write CI-friendly unit, golden-snapshot, and integration tests for Bubble Tea TUI applications in Go.
Files
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
1. Set up test infrastructure:
- Create
testdata/directory for golden files - Add
.gitattributesentry:*.golden -text - Add
init()orTestMainthat setslipgloss.SetColorProfile(termenv.Ascii)
2. 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)
3. 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
4. Layer 3 — Write integration tests for critical flows:
- Startup → input → result → quit
- Error recovery paths
- Multi-step wizards or workflows
5. 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