skills/colonyops/hive/tui-testing

tui-testing

SKILL.md

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

  1. Layer your tests - Unit for logic, component for behavior, golden for visuals
  2. Test observable behavior - Not implementation details
  3. Use golden files for visual regression testing
  4. Create sync helpers for async operations in tests
  5. Test boundaries - Empty, single, full, overflows
  6. Set realistic dimensions - Always call SetSize before testing
  7. Use test utilities - StripANSI, KeyPress helpers, data builders
  8. Test both keybindings when multiple keys do the same thing
  9. Skip gracefully when external tools unavailable
  10. Focus integration tests on critical workflows, not every combination
Weekly Installs
39
Repository
colonyops/hive
GitHub Stars
19
First Seen
Feb 15, 2026
Installed on
opencode39
github-copilot39
codex39
gemini-cli38
amp38
kimi-cli38