tui-testing

安装量: 41
排名: #17705

安装

npx skills add https://github.com/colonyops/hive --skill 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: test.go - Main component tests test.go - Feature-specific tests Test - Test function names Test_.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

返回排行榜