tui-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

TUI Testing Best Practices

TUI测试最佳实践

Comprehensive testing strategies for Bubbletea v2 applications, based on the hive diff viewer implementation.
基于hive差异查看器实现的Bubbletea v2应用综合性测试策略。

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
采用分层测试方法,针对不同关注点使用不同测试类型:
Unit Tests          → 纯逻辑、状态转换
Component Tests     → 结合合成消息的Update/View行为测试
Golden File Tests   → 渲染输出的视觉回归测试
Integration Tests   → 基于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
测试文件与实现文件一一对应:
internal/tui/diff/
├── diffviewer.go
├── diffviewer_test.go           # 组件行为测试
├── diffviewer_editor_test.go    # 特定功能测试
├── filetree.go
├── filetree_test.go
├── lineparse.go
├── lineparse_test.go            # 纯函数测试
├── model.go
├── model_test.go
└── testdata/                    # 黄金文件目录
    ├── TestFileTreeView_Empty.golden
    ├── TestFileTreeView_SingleFile.golden
    └── TestDiffViewerView_NormalMode.golden
命名规范:
  • <component>_test.go
    - 主组件测试文件
  • <component>_<feature>_test.go
    - 特定功能测试文件
  • Test<Component><Method>_<Scenario>
    - 测试函数命名
  • Test<Component><Method>_<Scenario>.golden
    - 黄金文件命名

Unit Testing Pure Functions

纯函数的单元测试

Parse/Transform Logic

解析/转换逻辑

For functions that transform data without UI state:
go
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
对于不涉及UI状态的数据转换函数:
go
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)
}
核心原则:
  • 使用
    require.*
    确保前置条件必须通过
  • 使用
    assert.*
    验证实际测试条件
  • 测试边缘情况(空值、单个条目、边界值)
  • 测试错误场景

Edge Cases to Cover

需覆盖的边缘情况

go
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
}
go
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

Update逻辑测试

Test state transitions directly:
go
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
}
直接测试状态转换:
go
func TestDiffViewerScrollDown(t *testing.T) {
    file := &gitdiff.File{
        // ... 包含10行内容的文件 ...
    }

    m := NewDiffViewer(file)
    loadFileSync(&m, file)  // 用于异步加载的辅助函数
    m.SetSize(80, 8) // 高度8 = 3行标题 + 5行内容

    // 初始位置
    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) // 视口尚未滚动

    // 移动到视口底部(第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)

    // 再滚动一次触发视口滚动
    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:
go
// 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.
针对异步操作,创建同步辅助函数:
go
// loadFileSync 同步执行异步加载操作以用于测试
func loadFileSync(m *DiffViewerModel, file *gitdiff.File) {
    cmd := m.SetFile(file)
    if cmd != nil {
        // 执行命令获取消息
        msg := cmd()
        // 应用消息
        *m, _ = m.Update(msg)
    }
}
这让测试可以控制时序,无需处理异步复杂度。

Navigation Testing Pattern

导航测试模式

go
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).
go
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)

    // 测试使用'j'键向下导航
    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) // 仍停留在最后一项
}
当多个按键实现相同功能时(如vim风格),需测试所有按键绑定

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

基础黄金文件测试

go
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
go
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()

    // 去除ANSI代码以生成可读性更高的黄金文件
    golden.RequireEqual(t, []byte(tuitest.StripANSI(output)))
}
黄金文件(
testdata/TestFileTreeView_SingleFile.golden
):
 main.go

Selection and Highlighting Tests

选择与高亮测试

For visual modes with highlighting:
go
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.
针对带高亮的视觉模式:
go
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()

    // 保留ANSI代码以验证高亮效果
    golden.RequireEqual(t, []byte(output))
}
决策点: 高亮测试保留ANSI代码,布局测试去除ANSI代码。

Testing Multiple Scenarios

多场景测试

Use table-driven pattern with golden files:
go
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
结合表格驱动模式与黄金文件:
go
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)))
        })
    }
}
这会生成:
  • testdata/TestFileTreeView_Icons/ASCII.golden
  • testdata/TestFileTreeView_Icons/NerdFonts.golden

Updating Golden Files

更新黄金文件

bash
undefined
bash
undefined

Update all golden files

更新所有黄金文件

go test ./... -update
go test ./... -update

Update specific test

更新特定测试的黄金文件

go test ./internal/tui/diff -run TestFileTreeView_SingleFile -update
undefined
go test ./internal/tui/diff -run TestFileTreeView_SingleFile -update
undefined

Test Utilities

测试工具

Standard Test Helpers

标准测试辅助函数

Create shared utilities in
pkg/tuitest
:
go
// 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}
}
pkg/tuitest
中创建共享工具:
go
// StripANSI 移除转义码和尾随空格
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:
go
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
}
针对复杂测试数据:
go
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 {
    // ... 用于多hunk场景的构建器
}

Testing Async Operations

异步操作测试

Pattern: Synchronous Execution in Tests

模式:测试中的同步执行

go
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)
}
go
func TestDiffViewerAsyncLoading(t *testing.T) {
    file := createLargeFile()
    m := NewDiffViewer(file)

    // SetFile返回一个命令
    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

加载状态测试

go
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)
}
go
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

Delta/语法高亮

go
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[")
}
go
func TestDeltaIntegration(t *testing.T) {
    // 如果delta不可用则跳过测试
    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"

    // 测试启用delta的情况
    highlighted, _ := ApplyDelta(diff)
    assert.NotEqual(t, diff, highlighted)
    assert.Contains(t, highlighted, "\x1b[") // 包含ANSI代码

    // 测试不启用delta的情况
    plain, _ := generateDiffContent(nil, false)
    assert.NotContains(t, plain, "\x1b[")
}

Mock External Dependencies

模拟外部依赖

For tests that shouldn't depend on external tools:
go
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)
}
针对不应依赖外部工具的测试:
go
func TestDiffViewerWithoutDelta(t *testing.T) {
    // 强制标记delta不可用
    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

编辑器启动测试

go
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.
go
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)
    }
}
注意: 使用
echo
或类似非交互式命令进行测试。

Component Boundary Testing

组件边界测试

File Tree State

文件树状态

go
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))
}
go
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

端到端工作流

go
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)
}
go
func TestDiffReviewWorkflow(t *testing.T) {
    files := []*gitdiff.File{
        {NewName: "file1.go"},
        {NewName: "file2.go"},
    }

    m := New(files, &config.Config{})
    m.SetSize(120, 40)

    // 1. 初始处于文件树界面
    assert.Equal(t, FocusFileTree, m.focused)

    // 2. 导航到第二个文件
    m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j'}))
    assert.Equal(t, 1, m.fileTree.selected)

    // 3. 切换到差异查看器
    m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab}))
    assert.Equal(t, FocusDiffViewer, m.focused)

    // 4. 在差异查看器中滚动
    m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j'}))
    assert.Equal(t, 1, m.diffViewer.cursorLine)

    // 5. 打开帮助
    m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: '?'}))
    assert.True(t, m.showHelp)

    // 6. 关闭帮助
    m, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: '?'}))
    assert.False(t, m.showHelp)
}

Common Testing Pitfalls

常见测试陷阱

❌ Don't Test Implementation Details

❌ 不要测试实现细节

go
// 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)
}
go
// 错误:测试可能变更的内部状态
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

❌ 不要忽略尺寸设置

go
// 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'}))
}
go
// 错误:未设置尺寸就进行测试
func TestScrolling(t *testing.T) {
    m := NewDiffViewer(file)
    // m.height为0,视口计算会出错!
    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

❌ 不要跳过边界测试

go
// 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)
}
go
// 正确:测试边缘情况
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)
目标:
  • 单元测试: 纯函数(解析器、转换器)达到100%覆盖率
  • 组件测试: Update逻辑(状态转换、导航)达到80%+覆盖率
  • 黄金文件: 每个组件的关键场景(正常、边缘情况、不同模式)
  • 集成测试: 仅覆盖核心工作流(无需测试所有组合)

Running Tests

运行测试

bash
undefined
bash
undefined

All tests

所有测试

task test
task test

Watch mode

监听模式

task test:watch
task test:watch

Specific package

特定包

go test ./internal/tui/diff
go test ./internal/tui/diff

Specific test

特定测试

go test ./internal/tui/diff -run TestDiffViewerScrollDown
go test ./internal/tui/diff -run TestDiffViewerScrollDown

With coverage

带覆盖率统计

task coverage
task coverage

Update golden files

更新黄金文件

go test ./... -update
go test ./... -update

Verbose output

详细输出

go test ./internal/tui/diff -v
undefined
go test ./internal/tui/diff -v
undefined

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
  1. 分层测试 - 单元测试针对逻辑,组件测试针对行为,黄金文件针对视觉效果
  2. 测试可观察行为 - 而非实现细节
  3. 使用黄金文件 进行视觉回归测试
  4. 为异步操作创建同步辅助函数
  5. 测试边界情况 - 空值、单个条目、满值、溢出
  6. 设置真实尺寸 - 测试前始终调用SetSize
  7. 使用测试工具 - StripANSI、KeyPress辅助函数、数据构建器
  8. 测试所有按键绑定 当多个按键实现相同功能时
  9. 外部工具不可用时优雅跳过测试
  10. 集成测试聚焦核心工作流,而非所有组合