TUI Testing Best Practices
Comprehensive testing strategies for Bubbletea v2 applications, based on the hive diff viewer implementation.
Testing Strategy Overview
Use a layered approach with different test types for different concerns:
Unit Tests → Pure logic, state transformations
Component Tests → Update/View behavior with synthetic messages
Golden File Tests → Visual regression testing of rendered output
Integration Tests → End-to-end workflows with teatest
Test Organization
File Structure
Match test files to implementation files:
internal/tui/diff/
├── diffviewer.go
├── diffviewer_test.go # Component behavior tests
├── diffviewer_editor_test.go # Feature-specific tests
├── filetree.go
├── filetree_test.go
├── lineparse.go
├── lineparse_test.go # Pure function tests
├── model.go
├── model_test.go
└── testdata/ # Golden files
├── TestFileTreeView_Empty.golden
├── TestFileTreeView_SingleFile.golden
└── TestDiffViewerView_NormalMode.golden
Naming convention:
--- a/file.go
+++ b/file.go
@@ -1,3 +1,4 @@
package main
func main() {
+ fmt.Println("hello")
}
lines
,
err
:=
ParseDiffLines
(
diff
)
require
.
NoError
(
t
,
err
)
require
.
Len
(
t
,
lines
,
7
)
// 2 headers + 1 hunk + 4 content lines
// Test specific line properties
assert
.
Equal
(
t
,
LineTypeFileHeader
,
lines
[
0
]
.
Type
)
assert
.
Equal
(
t
,
"--- a/file.go"
,
lines
[
0
]
.
Content
)
assert
.
Equal
(
t
,
LineTypeAdd
,
lines
[
5
]
.
Type
)
assert
.
Equal
(
t
,
"\tfmt.Println(\"hello\")"
,
lines
[
5
]
.
Content
)
assert
.
Equal
(
t
,
0
,
lines
[
5
]
.
OldLineNum
)
// Not in old file
assert
.
Equal
(
t
,
3
,
lines
[
5
]
.
NewLineNum
)
}
Key principles:
Use
require.
for preconditions that must pass
Use
assert.
for actual test conditions
Test edge cases (empty, single item, boundaries)
Test error conditions
Edge Cases to Cover
func
TestParseDiffLines_EmptyDiff
(
t
*
testing
.
T
)
{
lines
,
err
:=
ParseDiffLines
(
""
)
require
.
NoError
(
t
,
err
)
assert
.
Empty
(
t
,
lines
)
}
func
TestParseDiffLines_MultipleHunks
(
t
*
testing
.
T
)
{
// Test line number tracking across hunks
}
func
TestParseDiffLines_WithDeletions
(
t
*
testing
.
T
)
{
// Test that deleted lines have NewLineNum = 0
}
Component Testing
Testing Update Logic
Test state transitions directly:
func
TestDiffViewerScrollDown
(
t
*
testing
.
T
)
{
file
:=
&
gitdiff
.
File
{
// ... file with 10 lines ...
}
m
:=
NewDiffViewer
(
file
)
loadFileSync
(
&
m
,
file
)
// Helper for async loading
m
.
SetSize
(
80
,
8
)
// Height 8 = 3 header + 5 content
// Initial position
assert
.
Equal
(
t
,
0
,
m
.
offset
)
assert
.
Equal
(
t
,
0
,
m
.
cursorLine
)
// Move cursor down
m
,
_
=
m
.
Update
(
tea
.
KeyPressMsg
(
tea
.
Key
{
Code
:
'j'
}
)
)
assert
.
Equal
(
t
,
1
,
m
.
cursorLine
)
assert
.
Equal
(
t
,
0
,
m
.
offset
)
// Viewport doesn't scroll yet
// Move to bottom of viewport (line 4)
for
i
:=
0
;
i
<
3
;
i
++
{
m
,
_
=
m
.
Update
(
tea
.
KeyPressMsg
(
tea
.
Key
{
Code
:
'j'
}
)
)
}
assert
.
Equal
(
t
,
4
,
m
.
cursorLine
)
assert
.
Equal
(
t
,
0
,
m
.
offset
)
// One more scroll triggers viewport scroll
m
,
_
=
m
.
Update
(
tea
.
KeyPressMsg
(
tea
.
Key
{
Code
:
'j'
}
)
)
assert
.
Equal
(
t
,
5
,
m
.
cursorLine
)
assert
.
Equal
(
t
,
1
,
m
.
offset
)
// Viewport scrolled down
}
Test Helper Pattern
For async operations, create sync helpers:
// loadFileSync executes async loading synchronously for tests
func
loadFileSync
(
m
*
DiffViewerModel
,
file
*
gitdiff
.
File
)
{
cmd
:=
m
.
SetFile
(
file
)
if
cmd
!=
nil
{
// Execute command to get message
msg
:=
cmd
(
)
// Apply message
*
m
,
_
=
m
.
Update
(
msg
)
}
}
This lets tests control timing without dealing with async complexity.
Navigation Testing Pattern
func
TestFileTreeNavigationDown
(
t
*
testing
.
T
)
{
files
:=
[
]
*
gitdiff
.
File
{
{
NewName
:
"file1.go"
}
,
{
NewName
:
"file2.go"
}
,
{
NewName
:
"file3.go"
}
,
}
m
:=
NewFileTree
(
files
,
&
config
.
Config
{
}
)
assert
.
Equal
(
t
,
0
,
m
.
selected
)
// Test down with 'j'
m
,
_
=
m
.
Update
(
tea
.
KeyPressMsg
(
tea
.
Key
{
Code
:
'j'
}
)
)
assert
.
Equal
(
t
,
1
,
m
.
selected
)
// Test down with arrow key
m
,
_
=
m
.
Update
(
tea
.
KeyPressMsg
(
tea
.
Key
{
Code
:
tea
.
KeyDown
}
)
)
assert
.
Equal
(
t
,
2
,
m
.
selected
)
// Test boundary - can't go past last
m
,
_
=
m
.
Update
(
tea
.
KeyPressMsg
(
tea
.
Key
{
Code
:
'j'
}
)
)
assert
.
Equal
(
t
,
2
,
m
.
selected
)
// Still at last item
}
Test both keybindings
when multiple keys do the same thing (vim-style).
Golden File Testing
When to Use Golden Files
Golden files are ideal for:
Visual regression testing
- Catch unintended rendering changes
Complex rendering logic
- Easier than manual string building
Layout verification
- Ensure components render correctly at different sizes
Basic Golden File Test
func
TestFileTreeView_SingleFile
(
t
*
testing
.
T
)
{
files
:=
[
]
*
gitdiff
.
File
{
{
NewName
:
"main.go"
}
,
}
cfg
:=
&
config
.
Config
{
TUI
:
config
.
TUIConfig
{
}
,
}
m
:=
NewFileTree
(
files
,
cfg
)
m
.
SetSize
(
40
,
10
)
output
:=
m
.
View
(
)
// Strip ANSI for readable golden files
golden
.
RequireEqual
(
t
,
[
]
byte
(
tuitest
.
StripANSI
(
output
)
)
)
}
Golden file (
testdata/TestFileTreeView_SingleFile.golden
):
main.go
Selection and Highlighting Tests
For visual modes with highlighting:
func
TestDiffViewerView_SingleLineSelection
(
t
*
testing
.
T
)
{
file
:=
createTestFile
(
)
m
:=
NewDiffViewer
(
file
)
loadFileSync
(
&
m
,
file
)
m
.
SetSize
(
80
,
15
)
// Enter visual mode
m
,
_
=
m
.
Update
(
tea
.
KeyPressMsg
(
tea
.
Key
{
Code
:
'v'
}
)
)
assert
.
True
(
t
,
m
.
selectionMode
)
output
:=
m
.
View
(
)
// Keep ANSI codes to verify highlighting
golden
.
RequireEqual
(
t
,
[
]
byte
(
output
)
)
}
Decision point:
Keep ANSI codes for highlighting tests, strip for layout tests.
Testing Multiple Scenarios
Use table-driven pattern with golden files:
func
TestFileTreeView_Icons
(
t
*
testing
.
T
)
{
tests
:=
[
]
struct
{
name
string
iconStyle IconStyle
}
{
{
"ASCII"
,
IconStyleASCII
}
,
{
"NerdFonts"
,
IconStyleNerdFonts
}
,
}
files
:=
[
]
*
gitdiff
.
File
{
{
NewName
:
"main.go"
}
,
{
NewName
:
"README.md"
}
,
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
m
:=
NewFileTree
(
files
,
&
config
.
Config
{
}
)
m
.
iconStyle
=
tt
.
iconStyle
m
.
SetSize
(
40
,
10
)
output
:=
m
.
View
(
)
golden
.
RequireEqual
(
t
,
[
]
byte
(
tuitest
.
StripANSI
(
output
)
)
)
}
)
}
}
This generates:
testdata/TestFileTreeView_Icons/ASCII.golden
testdata/TestFileTreeView_Icons/NerdFonts.golden
Updating Golden Files
Update all golden files
go test ./ .. . -update
Update specific test
go test ./internal/tui/diff -run TestFileTreeView_SingleFile -update Test Utilities Standard Test Helpers Create shared utilities in pkg/tuitest : // StripANSI removes escape codes and trailing whitespace func StripANSI ( s string ) string { s = ansi . Strip ( s ) lines := strings . Split ( s , "\n" ) var result [ ] string for _ , line := range lines { trimmed := strings . TrimRight ( line , " " ) result = append ( result , trimmed ) } return strings . TrimRight ( strings . Join ( result , "\n" ) , "\n" ) } // Helper functions for creating messages func KeyPress ( key rune ) tea . Msg { return tea . KeyPressMsg ( tea . Key { Code : key } ) } func KeyDown ( ) tea . Msg { return tea . KeyPressMsg ( tea . Key { Code : tea . KeyDown } ) } func WindowSize ( w , h int ) tea . WindowSizeMsg { return tea . WindowSizeMsg { Width : w , Height : h } } Test Data Builders For complex test data: func createTestFile ( ) * gitdiff . File { return & gitdiff . File { OldName : "test.go" , NewName : "test.go" , TextFragments : [ ] * gitdiff . TextFragment { { OldPosition : 1 , OldLines : 3 , NewPosition : 1 , NewLines : 3 , Lines : [ ] gitdiff . Line { { Op : gitdiff . OpContext , Line : "package main\n" } , { Op : gitdiff . OpDelete , Line : "old line\n" } , { Op : gitdiff . OpAdd , Line : "new line\n" } , } , } , } , } } func createMultiHunkFile ( ) * gitdiff . File { // ... builder for multi-hunk scenarios } Testing Async Operations Pattern: Synchronous Execution in Tests func TestDiffViewerAsyncLoading ( t * testing . T ) { file := createLargeFile ( ) m := NewDiffViewer ( file ) // SetFile returns a command cmd := m . SetFile ( file ) require . NotNil ( t , cmd ) // Execute synchronously msg := cmd ( ) m , _ = m . Update ( msg ) // Verify content loaded assert . NotEmpty ( t , m . content ) assert . False ( t , m . loading ) } Testing Loading States func TestDiffViewerLoadingState ( t * testing . T ) { m := NewDiffViewer ( nil ) // Before loading assert . False ( t , m . loading ) assert . Empty ( t , m . content ) // Initiate load (but don't execute command) file := createTestFile ( ) cmd := m . SetFile ( file ) assert . NotNil ( t , cmd ) // Note: loading state is set when command executes, // not when it's created // After load completes msg := cmd ( ) m , _ = m . Update ( msg ) assert . False ( t , m . loading ) assert . NotEmpty ( t , m . content ) } Testing External Tool Integration Delta/Syntax Highlighting func TestDeltaIntegration ( t * testing . T ) { // Skip if delta not available if err := CheckDeltaAvailable ( ) ; err != nil { t . Skip ( "delta not available" ) } diff := "--- a/file.go\n+++ b/file.go\n@@ -1 +1 @@\n-old\n+new\n" // Test with delta enabled highlighted , _ := ApplyDelta ( diff ) assert . NotEqual ( t , diff , highlighted ) assert . Contains ( t , highlighted , "\x1b[" ) // Contains ANSI codes // Test without delta plain , _ := generateDiffContent ( nil , false ) assert . NotContains ( t , plain , "\x1b[" ) } Mock External Dependencies For tests that shouldn't depend on external tools: func TestDiffViewerWithoutDelta ( t * testing . T ) { // Force delta unavailable m := NewDiffViewer ( createTestFile ( ) ) m . deltaAvailable = false cmd := m . SetFile ( createTestFile ( ) ) msg := cmd ( ) m , _ = m . Update ( msg ) // Should still work, just without highlighting assert . NotEmpty ( t , m . content ) } Editor Integration Testing Testing Editor Launch func TestOpenInEditor ( t * testing . T ) { // Set test editor oldEditor := os . Getenv ( "EDITOR" ) defer os . Setenv ( "EDITOR" , oldEditor ) os . Setenv ( "EDITOR" , "echo" ) m := NewDiffViewer ( createTestFile ( ) ) loadFileSync ( & m , createTestFile ( ) ) // Get line number to open lineNum := 5 // Open editor command cmd := m . openInEditor ( "/tmp/test.go" , lineNum ) msg := cmd ( ) // Should receive editor finished message if finishMsg , ok := msg . ( editorFinishedMsg ) ; ok { assert . NoError ( t , finishMsg . err ) } } Note: Use echo or similar non-interactive command for testing. Component Boundary Testing File Tree State func TestFileTreeCollapse ( t * testing . T ) { files := [ ] * gitdiff . File { { NewName : "src/main.go" } , { NewName : "src/util.go" } , } m := NewFileTree ( files , & config . Config { } ) m . SetSize ( 40 , 20 ) // Should start hierarchical and expanded assert . True ( t , m . hierarchical ) assert . NotEmpty ( t , m . tree ) assert . False ( t , m . tree [ 0 ] . Collapsed ) // Collapse first directory m , _ = m . Update ( tea . KeyPressMsg ( tea . Key { Code : tea . KeyLeft } ) ) // Directory should be collapsed assert . True ( t , m . tree [ 0 ] . Collapsed ) // Selection should stay valid assert . GreaterOrEqual ( t , m . selected , 0 ) assert . Less ( t , m . selected , len ( m . tree ) ) } Integration Testing Patterns End-to-End Workflows func TestDiffReviewWorkflow ( t * testing . T ) { files := [ ] * gitdiff . File { { NewName : "file1.go" } , { NewName : "file2.go" } , } m := New ( files , & config . Config { } ) m . SetSize ( 120 , 40 ) // 1. Start in file tree assert . Equal ( t , FocusFileTree , m . focused ) // 2. Navigate to second file m , _ = m . Update ( tea . KeyPressMsg ( tea . Key { Code : 'j' } ) ) assert . Equal ( t , 1 , m . fileTree . selected ) // 3. Switch to diff viewer m , _ = m . Update ( tea . KeyPressMsg ( tea . Key { Code : tea . KeyTab } ) ) assert . Equal ( t , FocusDiffViewer , m . focused ) // 4. Scroll in diff viewer m , _ = m . Update ( tea . KeyPressMsg ( tea . Key { Code : 'j' } ) ) assert . Equal ( t , 1 , m . diffViewer . cursorLine ) // 5. Open help m , _ = m . Update ( tea . KeyPressMsg ( tea . Key { Code : '?' } ) ) assert . True ( t , m . showHelp ) // 6. Close help m , _ = m . Update ( tea . KeyPressMsg ( tea . Key { Code : '?' } ) ) assert . False ( t , m . showHelp ) } Common Testing Pitfalls ❌ Don't Test Implementation Details // BAD: Testing internal state that could change func TestDiffViewerInternals ( t * testing . T ) { m := NewDiffViewer ( file ) assert . NotNil ( t , m . cache ) // Implementation detail! } // GOOD: Test observable behavior func TestDiffViewerCaching ( t * testing . T ) { m := NewDiffViewer ( file ) // First load cmd1 := m . SetFile ( file ) msg1 := cmd1 ( ) m , _ = m . Update ( msg1 ) content1 := m . content // Second load of same file cmd2 := m . SetFile ( file ) msg2 := cmd2 ( ) m , _ = m . Update ( msg2 ) content2 := m . content // Should get same content (implying cache worked) assert . Equal ( t , content1 , content2 ) } ❌ Don't Ignore Dimensions // BAD: Testing without setting size func TestScrolling ( t * testing . T ) { m := NewDiffViewer ( file ) // m.height is 0, viewport calculations will break! m , _ = m . Update ( tea . KeyPressMsg ( tea . Key { Code : 'j' } ) ) } // GOOD: Always set size before testing func TestScrolling ( t * testing . T ) { m := NewDiffViewer ( file ) m . SetSize ( 80 , 40 ) // Realistic dimensions m , _ = m . Update ( tea . KeyPressMsg ( tea . Key { Code : 'j' } ) ) } ❌ Don't Skip Boundaries // GOOD: Test edge cases func TestScrollBoundaries ( t * testing . T ) { // Test scroll up at top m . offset = 0 m , _ = m . Update ( tea . KeyPressMsg ( tea . Key { Code : 'k' } ) ) assert . Equal ( t , 0 , m . offset ) // Shouldn't go negative // Test scroll down at bottom m . offset = len ( m . lines ) - m . contentHeight ( ) m , _ = m . Update ( tea . KeyPressMsg ( tea . Key { Code : 'j' } ) ) assert . Equal ( t , len ( m . lines ) - m . contentHeight ( ) , m . offset ) } Test Coverage Goals Aim for: Unit tests: 100% for pure functions (parsers, transformers) Component tests: 80%+ for Update logic (state transitions, navigation) Golden files: Key scenarios for each component (normal, edge cases, modes) Integration tests: Critical workflows only (don't test every combination) Running Tests
All tests
mise run test
Watch specific tasks
mise watch test
Specific package
go test ./internal/tui/diff
Specific test
go test ./internal/tui/diff -run TestDiffViewerScrollDown
With coverage
mise run coverage
Update golden files
go test ./ .. . -update
Verbose output
go test ./internal/tui/diff -v Summary Layer your tests - Unit for logic, component for behavior, golden for visuals Test observable behavior - Not implementation details Use golden files for visual regression testing Create sync helpers for async operations in tests Test boundaries - Empty, single, full, overflows Set realistic dimensions - Always call SetSize before testing Use test utilities - StripANSI, KeyPress helpers, data builders Test both keybindings when multiple keys do the same thing Skip gracefully when external tools unavailable Focus integration tests on critical workflows, not every combination