| name | tui-testing |
| description | Comprehensive testing strategies for Bubbletea v2 applications. Covers unit tests, component tests, golden file testing, async operations, external tool integration, and common testing pitfalls. |
| compatibility | claude |
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)
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)
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) {
}
func TestParseDiffLines_WithDeletions(t *testing.T) {
}
Component Testing
Testing Update Logic
Test state transitions directly:
func TestDiffViewerScrollDown(t *testing.T) {
file := &gitdiff.File{
}
m := NewDiffViewer(file)
loadFileSync(&m, file)
m.SetSize(80, 8)
assert.Equal(t, 0, m.offset)
assert.Equal(t, 0, m.cursorLine)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j'}))
assert.Equal(t, 1, m.cursorLine)
assert.Equal(t, 0, m.offset)
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)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j'}))
assert.Equal(t, 5, m.cursorLine)
assert.Equal(t, 1, m.offset)
}
Test Helper Pattern
For async operations, create sync helpers:
func loadFileSync(m *DiffViewerModel, file *gitdiff.File) {
cmd := m.SetFile(file)
if cmd != nil {
msg := cmd()
*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)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j'}))
assert.Equal(t, 1, m.selected)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown}))
assert.Equal(t, 2, m.selected)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j'}))
assert.Equal(t, 2, m.selected)
}
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()
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)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'v'}))
assert.True(t, m.selectionMode)
output := m.View()
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
go test ./... -update
go test ./internal/tui/diff -run TestFileTreeView_SingleFile -update
Test Utilities
Standard Test Helpers
Create shared utilities in pkg/tuitest:
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")
}
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 {
}
Testing Async Operations
Pattern: Synchronous Execution in Tests
func TestDiffViewerAsyncLoading(t *testing.T) {
file := createLargeFile()
m := NewDiffViewer(file)
cmd := m.SetFile(file)
require.NotNil(t, cmd)
msg := cmd()
m, _ = m.Update(msg)
assert.NotEmpty(t, m.content)
assert.False(t, m.loading)
}
Testing Loading States
func TestDiffViewerLoadingState(t *testing.T) {
m := NewDiffViewer(nil)
assert.False(t, m.loading)
assert.Empty(t, m.content)
file := createTestFile()
cmd := m.SetFile(file)
assert.NotNil(t, cmd)
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) {
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"
highlighted, _ := ApplyDelta(diff)
assert.NotEqual(t, diff, highlighted)
assert.Contains(t, highlighted, "\x1b[")
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) {
m := NewDiffViewer(createTestFile())
m.deltaAvailable = false
cmd := m.SetFile(createTestFile())
msg := cmd()
m, _ = m.Update(msg)
assert.NotEmpty(t, m.content)
}
Editor Integration Testing
Testing Editor Launch
func TestOpenInEditor(t *testing.T) {
oldEditor := os.Getenv("EDITOR")
defer os.Setenv("EDITOR", oldEditor)
os.Setenv("EDITOR", "echo")
m := NewDiffViewer(createTestFile())
loadFileSync(&m, createTestFile())
lineNum := 5
cmd := m.openInEditor("/tmp/test.go", lineNum)
msg := cmd()
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)
assert.True(t, m.hierarchical)
assert.NotEmpty(t, m.tree)
assert.False(t, m.tree[0].Collapsed)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyLeft}))
assert.True(t, m.tree[0].Collapsed)
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)
assert.Equal(t, FocusFileTree, m.focused)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j'}))
assert.Equal(t, 1, m.fileTree.selected)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab}))
assert.Equal(t, FocusDiffViewer, m.focused)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j'}))
assert.Equal(t, 1, m.diffViewer.cursorLine)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: '?'}))
assert.True(t, m.showHelp)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: '?'}))
assert.False(t, m.showHelp)
}
Common Testing Pitfalls
❌ Don't Test Implementation Details
func TestDiffViewerInternals(t *testing.T) {
m := NewDiffViewer(file)
assert.NotNil(t, m.cache)
}
func TestDiffViewerCaching(t *testing.T) {
m := NewDiffViewer(file)
cmd1 := m.SetFile(file)
msg1 := cmd1()
m, _ = m.Update(msg1)
content1 := m.content
cmd2 := m.SetFile(file)
msg2 := cmd2()
m, _ = m.Update(msg2)
content2 := m.content
assert.Equal(t, content1, content2)
}
❌ Don't Ignore Dimensions
func TestScrolling(t *testing.T) {
m := NewDiffViewer(file)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j'}))
}
func TestScrolling(t *testing.T) {
m := NewDiffViewer(file)
m.SetSize(80, 40)
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j'}))
}
❌ Don't Skip Boundaries
func TestScrollBoundaries(t *testing.T) {
m.offset = 0
m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'k'}))
assert.Equal(t, 0, m.offset)
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
mise run test
mise watch test
go test ./internal/tui/diff
go test ./internal/tui/diff -run TestDiffViewerScrollDown
mise run coverage
go test ./... -update
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