tui-testing
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 testsTest<Component><Method>_<Scenario>- Test function namesTest<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.goldentestdata/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