Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
dimetron avatar

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-testing

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs1
repo stars117
Last updatedAugust 5, 2026
Repositorydimetron/pi-go

What it does

Write CI-friendly unit, golden-snapshot, and integration tests for Bubble Tea TUI applications in Go.

Files

SKILL.mdMarkdownGitHub ↗

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:

LayerCoverageSpeedTool
1. Direct model testsState transitions, commands, view content~msStandard testing
2. Golden file snapshotsVisual regression on View() output~msgolden.RequireEqual
3. Full integrationEnd-to-end user flows~secondsteatest.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

PackageImportUse case
goldengithub.com/charmbracelet/x/exp/goldenComponent-level View snapshots
teatestgithub.com/charmbracelet/x/exp/teatestFull-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

MethodPurposeBlocks?
NewTestModel(tb, model, opts...)Create & start headless programNo
tm.Send(msg)Inject any tea.MsgNo
tm.Type(s)Type string as key eventsNo
tm.Output()Live output io.ReaderNo
tm.FinalOutput(tb, opts...)Complete output after quitYes
tm.FinalModel(tb, opts...)Final tea.Model after quitYes
tm.WaitFinished(tb, opts...)Block until program exitsYes
WaitFor(tb, reader, cond, opts...)Poll reader until condition trueYes
RequireEqualOutput(tb, out)Golden file comparisonNo

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=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),           // 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: 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})
    // 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 .gitattributes entry: *.golden -text
  • Add init() or TestMain that sets lipgloss.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 -update as a manual/explicit step only
  • Consider a CI step that fails if golden files are uncommitted

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.