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
More from colonyops/hive
tui-component-design
Best practices for building maintainable, testable TUI components using Bubbletea v2 and the Charm ecosystem. Covers component organization, state management, async operations, visual modes, and common pitfalls.
54inbox
This skill should be used when the user asks to "check my inbox", "read my messages", "any unread messages?", "check for new messages", "see my inbox", or needs to read inter-agent messages from other hive sessions. Provides guidance on reading, filtering, and managing inbox messages.
18config
This skill should be used when the user asks to "configure hive", "setup hive for my workflow", "customize session spawn", "add tmux integration", "create custom keybindings", "add user commands", or needs guidance on hive configuration, rules, spawn commands, terminal integration, or keybindings.
18docs-update
Update documentation after code changes. Use when adding features, changing keybindings, modifying config options, or updating TUI behavior. Automatically finds and updates relevant documentation files (README, recipes, help text).
16session-info
This skill should be used when the user asks "what's my session ID?", "show my inbox topic", "get session info", "what session am I in?", "my agent ID", or needs to retrieve current hive session details for messaging coordination or debugging.
16wait
This skill should be used when the user asks to "wait for message from agent X", "block until response", "wait for handoff", "synchronize with other agents", "wait for acknowledgment", or needs to block execution until messages arrive on specific topics with configurable timeout.
15