Golang单元测试风格优化指南

Golang单元测试风格优化指南 大家好,Gophers!

我们对常见的单元测试风格感到不满意,并且认为我们找到了一种具有明显优势的风格。详细的比较可以在这里找到:https://symflower.com/en/company/blog/2022/better-table-driven-testing/

我们还在我们的 VS Code 扩展中添加了对维护此类测试的支持:https://marketplace.visualstudio.com/items?itemName=symflower.symflower。您可以使用命令或上下文菜单项来维护测试。

为了获得更好的堆栈跟踪,需要进行一些更改,因为 t.Run 会从另一个位置调用测试函数。我们正在努力将这些更改推送到上游。

我们非常期待您对这种风格和扩展的反馈。同时,也很有兴趣听听其他可能帮助大家编写更好测试的方法和惯例。

祝好!


更多关于Golang单元测试风格优化指南的实战教程也可以访问 https://www.itying.com/category-94-b0.html

4 回复

你好 @zimmskal

我见过很多单元测试插件/项目,这个项目显然有一些优点。我主要关心的是:

  • 当验证函数包含复杂逻辑(代码量较大)时,测试会是什么样子
  • 当存在许多测试用例时,表格驱动测试会是什么样子——这有多容易理解

我喜欢你的创意和这个插件,请继续努力。

更多关于Golang单元测试风格优化指南的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


针对 github.com/stretchr/testify 的第一个变更已作为 PR 推送:https://github.com/stretchr/testify/pull/1161: “在测试失败堆栈跟踪中包含子测试的位置”。如果您认为这是一个好的行为变更,请给予您的表情符号支持。

telo_tade:

如果验证函数包含复杂的逻辑(规模较大),测试会是什么样子?

确实规模会更大,但无论采用哪种测试风格,情况都是如此。这完全取决于想要检查什么。在我们的案例中,如果逻辑变得过于庞大(相对于简单的输入/输出),这也是代码/API 需要重构的一个信号。

我们经常遇到的一个问题是,对于“复杂对象”,除非有构造函数,否则我们无法直接使用其字段。因此,我们经常在“验证”调用之前编写初始化代码。您目前如何测试包含复杂对象的代码?(例如,需要多次方法调用来进行设置。)

telo_tade:

当存在许多测试用例时,表格测试会是什么样子——是否容易理解?

我们使用这种风格已经多年,发现有两种情况极为常见:

  • “验证”调用是顺序的,没有分组,因为代码很简单。
  • “验证”调用按类别和子类别分组。例如,对于我们的扩展,我们将它们分组为“Go”和“Java”测试用例,以及针对测试风格和不同错误的子类别。

telo_tade:

我喜欢您的倡议和这个插件,请继续努力。

谢谢,很高兴听到您这么说 🙂 如果您有任何请求或发现了错误,请告诉我们。我们目前正在努力使我们的单元测试生成(包含输入和输出的测试)达到与 Java 支持相当的水平。因此,您可以使用生成的测试,也可以编写手动测试。我们很想知道您的想法 Tutorial - Symflower CLI

这种基于结构体的表格驱动测试风格确实比传统的匿名结构体切片更清晰。以下是一个对比示例:

传统风格:

func TestAdd(t *testing.T) {
    tests := []struct {
        name   string
        a, b   int
        want   int
    }{
        {"positive", 2, 3, 5},
        {"negative", -1, -1, -2},
        {"zero", 0, 0, 0},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Add(tt.a, tt.b)
            if got != tt.want {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
            }
        })
    }
}

优化后的结构体风格:

type addTestCase struct {
    name string
    a    int
    b    int
    want int
}

func (tc addTestCase) run(t *testing.T) {
    t.Helper()
    got := Add(tc.a, tc.b)
    if got != tc.want {
        t.Errorf("Add(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
    }
}

func TestAdd(t *testing.T) {
    for _, tc := range []addTestCase{
        {name: "positive", a: 2, b: 3, want: 5},
        {name: "negative", a: -1, b: -1, want: -2},
        {name: "zero", a: 0, b: 0, want: 0},
    } {
        t.Run(tc.name, tc.run)
    }
}

这种风格的优点:

  1. 类型安全,编译器会检查字段类型
  2. 可复用测试逻辑
  3. 更好的IDE支持(自动补全、重构)
  4. 测试用例定义更清晰

对于复杂测试,可以进一步扩展:

type parseTestCase struct {
    name        string
    input       string
    wantResult  *Result
    wantErr     bool
    setup       func() *Parser
    teardown    func(*Parser)
}

func (tc parseTestCase) run(t *testing.T) {
    t.Helper()
    
    if tc.setup != nil {
        parser := tc.setup()
        defer func() {
            if tc.teardown != nil {
                tc.teardown(parser)
            }
        }()
    }
    
    result, err := Parse(tc.input)
    if (err != nil) != tc.wantErr {
        t.Fatalf("Parse() error = %v, wantErr %v", err, tc.wantErr)
    }
    
    if !reflect.DeepEqual(result, tc.wantResult) {
        t.Errorf("Parse() = %v, want %v", result, tc.wantResult)
    }
}

这种模式特别适合需要setup/teardown的测试场景,测试逻辑封装在方法中,测试表只关注测试数据。

回到顶部