tui-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTUI 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.goldenNaming convention:
- - Main component tests
<component>_test.go - - Feature-specific tests
<component>_<feature>_test.go - - Test function names
Test<Component><Method>_<Scenario> - - Golden file names
Test<Component><Method>_<Scenario>.golden
测试文件与实现文件一一对应:
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 for preconditions that must pass
require.* - Use for actual test conditions
assert.* - 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.gogo
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.goSelection 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.goldentestdata/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.goldentestdata/TestFileTreeView_Icons/NerdFonts.golden
Updating Golden Files
更新黄金文件
bash
undefinedbash
undefinedUpdate all golden files
更新所有黄金文件
go test ./... -update
go test ./... -update
Update specific test
更新特定测试的黄金文件
go test ./internal/tui/diff -run TestFileTreeView_SingleFile -update
undefinedgo test ./internal/tui/diff -run TestFileTreeView_SingleFile -update
undefinedTest Utilities
测试工具
Standard Test Helpers
标准测试辅助函数
Create shared utilities in :
pkg/tuitestgo
// 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/tuitestgo
// 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 or similar non-interactive command for testing.
echogo
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)
}
}注意: 使用或类似非交互式命令进行测试。
echoComponent 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
undefinedbash
undefinedAll 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
undefinedgo test ./internal/tui/diff -v
undefinedSummary
总结
- 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
- 分层测试 - 单元测试针对逻辑,组件测试针对行为,黄金文件针对视觉效果
- 测试可观察行为 - 而非实现细节
- 使用黄金文件 进行视觉回归测试
- 为异步操作创建同步辅助函数
- 测试边界情况 - 空值、单个条目、满值、溢出
- 设置真实尺寸 - 测试前始终调用SetSize
- 使用测试工具 - StripANSI、KeyPress辅助函数、数据构建器
- 测试所有按键绑定 当多个按键实现相同功能时
- 外部工具不可用时优雅跳过测试
- 集成测试聚焦核心工作流,而非所有组合