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:
-
<component>_test.go
-
Main component tests
-
<component>_<feature>_test.go
-
Feature-specific tests
-
Test<Component><Method>_<Scenario>
-
Test function names
-
Test<Component><Method>_<Scenario>.golden
-
Golden file names
Unit Testing Pure Functions
Parse/Transform Logic
For functions that transform data without UI state:
func TestParseDiffLines_SimpleDiff(t *testing.T) { diff := `--- 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