Documentation Index Fetch the complete documentation index at: https://mintlify.com/charmbracelet/bubbletea/llms.txt
Use this file to discover all available pages before exploring further.
Overview
Bubble Tea applications are highly testable thanks to their functional architecture. The framework provides options for mocking input/output and controlling the test environment.
Test Program Options
Bubble Tea provides several options for creating testable programs:
Mock keyboard input:
import " bytes "
func TestApp ( t * testing . T ) {
var buf bytes . Buffer
var in bytes . Buffer
// Simulate typing "q" to quit
in . Write ([] byte ( "q" ))
p := tea . NewProgram ( model {},
tea . WithInput ( & in ),
tea . WithOutput ( & buf ),
)
}
WithOutput
Capture program output:
import " bytes "
func TestApp ( t * testing . T ) {
var buf bytes . Buffer
p := tea . NewProgram ( model {},
tea . WithOutput ( & buf ),
)
if _ , err := p . Run (); err != nil {
t . Fatal ( err )
}
output := buf . String ()
if ! strings . Contains ( output , "expected text" ) {
t . Errorf ( "expected output to contain 'expected text', got: %s " , output )
}
}
WithoutRenderer
Disable the renderer for simpler testing:
func TestLogic ( t * testing . T ) {
p := tea . NewProgram ( model {},
tea . WithoutRenderer (),
)
// Output is sent directly without rendering
}
WithWindowSize
Set initial window dimensions:
func TestResponsiveLayout ( t * testing . T ) {
p := tea . NewProgram ( model {},
tea . WithWindowSize ( 80 , 24 ),
)
// Program starts with 80x24 terminal
}
WithContext
Control program lifecycle with context:
import " context "
func TestTimeout ( t * testing . T ) {
ctx , cancel := context . WithTimeout ( context . Background (), 3 * time . Second )
defer cancel ()
p := tea . NewProgram ( & testModel {},
tea . WithContext ( ctx ),
)
if _ , err := p . Run (); err != nil {
t . Fatal ( err )
}
}
Complete Test Example
Here’s a full test from the Bubble Tea source:
func TestTeaModel ( t * testing . T ) {
var buf bytes . Buffer
var in bytes . Buffer
in . Write ([] byte ( "q" ))
ctx , cancel := context . WithTimeout ( t . Context (), 3 * time . Second )
defer cancel ()
p := NewProgram ( & testModel {},
WithContext ( ctx ),
WithInput ( & in ),
WithOutput ( & buf ),
)
if _ , err := p . Run (); err != nil {
t . Fatal ( err )
}
if buf . Len () == 0 {
t . Fatal ( "no output" )
}
}
Testing Update Logic
Test your Update method in isolation:
func TestUpdate ( t * testing . T ) {
m := model { counter : 0 }
// Test key press
newModel , cmd := m . Update ( tea . KeyPressMsg {
Code : tea . KeyEnter ,
})
m = newModel .( model )
if m . counter != 1 {
t . Errorf ( "expected counter=1, got %d " , m . counter )
}
if cmd == nil {
t . Error ( "expected command to be returned" )
}
}
Testing View Output
Test View rendering:
func TestView ( t * testing . T ) {
m := model {
items : [] string { "Item 1" , "Item 2" },
cursor : 0 ,
}
view := m . View ()
output := view . String ()
if ! strings . Contains ( output , "Item 1" ) {
t . Error ( "expected view to contain 'Item 1'" )
}
if ! strings . Contains ( output , "Item 2" ) {
t . Error ( "expected view to contain 'Item 2'" )
}
}
Testing Commands
Test command execution:
func TestCommand ( t * testing . T ) {
// Execute command
msg := fetchData ()
// Check result
switch msg := msg .( type ) {
case dataMsg :
if len ( msg . items ) == 0 {
t . Error ( "expected data to be loaded" )
}
case errMsg :
t . Errorf ( "unexpected error: %v " , msg )
default :
t . Errorf ( "unexpected message type: %T " , msg )
}
}
Testing with Mock Data
type mockHTTPClient struct {
response * http . Response
err error
}
func ( m * mockHTTPClient ) Get ( url string ) ( * http . Response , error ) {
return m . response , m . err
}
func TestHTTPCommand ( t * testing . T ) {
// Create mock response
mock := & mockHTTPClient {
response : & http . Response {
StatusCode : 200 ,
Body : io . NopCloser ( strings . NewReader ( `{"status":"ok"}` )),
},
}
// Test command with mock
msg := fetchWithClient ( mock )
switch msg := msg .( type ) {
case successMsg :
if msg . status != "ok" {
t . Errorf ( "expected status=ok, got %s " , msg . status )
}
default :
t . Errorf ( "unexpected message type: %T " , msg )
}
}
Table-Driven Tests
func TestKeyHandling ( t * testing . T ) {
tests := [] struct {
name string
key string
expected model
}{
{
name : "arrow up" ,
key : "up" ,
expected : model { cursor : 0 },
},
{
name : "arrow down" ,
key : "down" ,
expected : model { cursor : 1 },
},
{
name : "enter key" ,
key : "enter" ,
expected : model { selected : true },
},
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
m := model { cursor : 0 }
msg := tea . KeyPressMsg {}
// Set key...
newModel , _ := m . Update ( msg )
result := newModel .( model )
if result . cursor != tt . expected . cursor {
t . Errorf ( "expected cursor= %d , got %d " ,
tt . expected . cursor , result . cursor )
}
})
}
}
Testing with Specific Color Profiles
Test how your app looks with different color support:
import " github.com/charmbracelet/colorprofile "
func TestWithAscii ( t * testing . T ) {
var buf bytes . Buffer
p := tea . NewProgram ( model {},
tea . WithOutput ( & buf ),
tea . WithColorProfile ( colorprofile . Ascii ),
)
if _ , err := p . Run (); err != nil {
t . Fatal ( err )
}
// Output should not contain ANSI codes
output := buf . String ()
if strings . Contains ( output , " \x1b [" ) {
t . Error ( "expected no ANSI codes in Ascii mode" )
}
}
func TestWithTrueColor ( t * testing . T ) {
p := tea . NewProgram ( model {},
tea . WithColorProfile ( colorprofile . TrueColor ),
)
// Test with full color support
}
Testing Message Flow
func TestMessageSequence ( t * testing . T ) {
m := model {}
var receivedMsgs [] tea . Msg
// Simulate message sequence
messages := [] tea . Msg {
tea . KeyPressMsg { Code : tea . KeyEnter },
statusMsg ( 200 ),
dataMsg { items : [] string { "item" }},
}
for _ , msg := range messages {
var cmd tea . Cmd
m , cmd = m . Update ( msg )
receivedMsgs = append ( receivedMsgs , msg )
// Execute command if returned
if cmd != nil {
resultMsg := cmd ()
receivedMsgs = append ( receivedMsgs , resultMsg )
}
}
// Verify final state
if len ( m .( model ). items ) != 1 {
t . Errorf ( "expected 1 item, got %d " , len ( m .( model ). items ))
}
}
Testing Init
func TestInit ( t * testing . T ) {
m := model {}
cmd := m . Init ()
if cmd == nil {
t . Error ( "expected Init to return a command" )
}
// Execute the command
msg := cmd ()
// Verify the message type
if _ , ok := msg .( tickMsg ); ! ok {
t . Errorf ( "expected tickMsg, got %T " , msg )
}
}
Disable input entirely:
func TestWithoutInput ( t * testing . T ) {
p := tea . NewProgram ( model {},
tea . WithInput ( nil ),
)
// No input will be processed
}
Integration Tests
Test the full program flow:
func TestFullProgram ( t * testing . T ) {
var buf bytes . Buffer
var in bytes . Buffer
// Simulate user interaction
in . WriteString ( "down \n " ) // Move cursor down
in . WriteString ( "down \n " ) // Move cursor down again
in . WriteString ( " " ) // Select item
in . WriteString ( "q" ) // Quit
ctx , cancel := context . WithTimeout ( context . Background (), 5 * time . Second )
defer cancel ()
p := tea . NewProgram ( initialModel (),
tea . WithContext ( ctx ),
tea . WithInput ( & in ),
tea . WithOutput ( & buf ),
)
finalModel , err := p . Run ()
if err != nil {
t . Fatal ( err )
}
m := finalModel .( model )
if m . cursor != 2 {
t . Errorf ( "expected cursor at position 2, got %d " , m . cursor )
}
if ! m . selected [ 2 ] {
t . Error ( "expected item 2 to be selected" )
}
}
Best Practices
Your Update function is pure - test it directly without running the full program: func TestUpdate ( t * testing . T ) {
m := model {}
m , cmd := m . Update ( tea . KeyPressMsg { Code : tea . KeyEnter })
// Assert expectations
}
Mock External Dependencies
Create interfaces for external services: type HTTPClient interface {
Get ( url string ) ( * http . Response , error )
}
// Use mock in tests
func TestWithMock ( t * testing . T ) {
mock := & mockHTTPClient { ... }
// Test with mock
}
Always use context.WithTimeout in tests to prevent hanging: ctx , cancel := context . WithTimeout ( t . Context (), 3 * time . Second )
defer cancel ()
Commands are just functions - test them directly: func TestFetchData ( t * testing . T ) {
msg := fetchData ()
// Verify message
}
Test multiple scenarios efficiently: tests := [] struct {
name string
input tea . Msg
expected model
}{
// Test cases...
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
// Test logic
})
}
Test Utilities
Helper Functions
// Helper to create test program
func newTestProgram ( t * testing . T , m tea . Model ) ( * tea . Program , * bytes . Buffer ) {
var buf bytes . Buffer
var in bytes . Buffer
p := tea . NewProgram ( m ,
tea . WithInput ( & in ),
tea . WithOutput ( & buf ),
tea . WithoutRenderer (),
)
return p , & buf
}
// Helper to simulate key press
func pressKey ( t * testing . T , m tea . Model , key string ) ( tea . Model , tea . Cmd ) {
msg := tea . KeyPressMsg {}
// Configure msg based on key string
return m . Update ( msg )
}
Golden Files
Compare output against golden files:
func TestViewGolden ( t * testing . T ) {
m := model { items : [] string { "Item 1" , "Item 2" }}
view := m . View ()
got := view . String ()
golden := filepath . Join ( "testdata" , "view.golden" )
if * update {
os . WriteFile ( golden , [] byte ( got ), 0644 )
}
want , err := os . ReadFile ( golden )
if err != nil {
t . Fatal ( err )
}
if got != string ( want ) {
t . Errorf ( "output mismatch \n got: \n %s \n want: \n %s " , got , want )
}
}
Common Pitfalls
Race Conditions : Never access model from goroutines. Always send messages:// Bad: Race condition
go func () {
m . data = fetch () // Don't do this!
}()
// Good: Send message
go func () {
p . Send ( dataMsg { data : fetch ()})
}()
Blocking Commands : Commands that block forever will hang tests. Always use timeouts:ctx , cancel := context . WithTimeout ( context . Background (), 5 * time . Second )
defer cancel ()
p := tea . NewProgram ( m , tea . WithContext ( ctx ))