发布于 2026-01-06 8 阅读
0

通过编写测试学习 Go:结构体、方法、接口和表驱动测试 结构体、方法和接口

通过编写测试来学习 Go:结构体、方法、接口和表驱动测试

结构、方法和接口

这是名为“通过编写测试来学习 Go”的在研项目的第三篇博文,该项目的目标是熟悉 Go 语言并学习 TDD(测试驱动开发)的相关技术。

结构、方法和接口

假设我们需要一些几何代码来计算给定高度和宽度的矩形的周长。我们可以编写一个Perimeter(width float64, height float64)函数,其中float64表示浮点数,例如 。123.45

现在你应该对TDD周期相当熟悉了。

先编写测试题。

func TestPerimeter(t *testing.T) {
    got := Perimeter(10.0, 10.0)
    want := 40.0

    if got != want {
        t.Errorf("got %.2f want %.2f", got, want)
    }
}
Enter fullscreen mode Exit fullscreen mode

注意新的格式字符串吗?这f是为了我们的格式float64,而.2表示打印两位小数。

尝试运行测试

./shapes_test.go:6:9: undefined: Perimeter

编写最少的代码来运行测试,并检查失败的测试输出。

func Perimeter(width float64, height float64) float64 {
    return 0
}
Enter fullscreen mode Exit fullscreen mode

结果shapes_test.go:10: got 0 want 40

编写足够的代码使其通过测试。

func Perimeter(width float64, height float64) float64 {
    return 2 * (width + height)
}
Enter fullscreen mode Exit fullscreen mode

到目前为止,一切都很简单。现在我们来创建一个名为 `array` 的函数Area(width, height float64),它返回矩形的面积。

尝试自己按照 TDD 流程来完成。

你应该最终得到类似这样的测试结果。

func TestPerimeter(t *testing.T) {
    got := Perimeter(10.0, 10.0)
    want := 40.0

    if got != want {
        t.Errorf("got %.2f want %.2f", got, want)
    }
}

func TestArea(t *testing.T) {
    got := Area(12.0, 6.0)
    want := 72.0

    if got != want {
        t.Errorf("got %.2f want %.2f", got, want)
    }
}
Enter fullscreen mode Exit fullscreen mode

以及类似这样的代码

func Perimeter(width float64, height float64) float64 {
    return 2 * (width + height)
}

func Area(width float64, height float64) float64 {
    return width * height
}
Enter fullscreen mode Exit fullscreen mode

重构

我们的代码虽然能实现功能,但其中并没有明确说明矩形的特征。粗心的开发者可能会尝试将三角形的宽度和高度传递给这些函数,却没意识到它们会返回错误的结果。

我们可以给这些函数起更具体的名字,比如RectangleArea`.`。更简洁的办法是定义我们自己的类型` Rectangle,它封装了这个概念。

我们可以使用结构体创建一个简单的类型。结构体就是一个命名的字段集合,你可以在其中存储数据。

像这样声明一个结构体

type Rectangle struct {
    Width float64
    Height float64
}
Enter fullscreen mode Exit fullscreen mode

现在让我们重构测试,使用 `s`Rectangle而不是普通的float64`s`。

func TestPerimeter(t *testing.T) {
    rectangle := Rectangle{10.0, 10.0}
    got := Perimeter(rectangle)
    want := 40.0

    if got != want {
        t.Errorf("got %.2f want %.2f", got, want)
    }
}

func TestArea(t *testing.T) {
    rectangle := Rectangle{12.0, 6.0}
    got := Area(rectangle)
    want := 72.0

    if got != want {
        t.Errorf("got %.2f want %.2f", got, want)
    }
}
Enter fullscreen mode Exit fullscreen mode

请记住在尝试修复之前运行测试,您应该会收到类似这样的有用错误信息。

./shapes_test.go:7:18: not enough arguments in call to Perimeter
    have (Rectangle)
    want (float64, float64)
Enter fullscreen mode Exit fullscreen mode

您可以使用以下语法访问结构体的字段myStruct.field

修改这两个函数以修复测试。

func Perimeter(rectangle Rectangle) float64 {
    return 2 * (rectangle.Width + rectangle.Height)
}

func Area(rectangle Rectangle) float64 {
    return rectangle.Width * rectangle.Height
}
Enter fullscreen mode Exit fullscreen mode

我希望您同意,将 a 传递Rectangle给函数可以更清晰地表达我们的意图,但使用结构体还有更多好处,我们稍后会谈到。

我们的下一个要求是编写一个Area绘制圆的函数。

先编写测试题。

func TestArea(t *testing.T) {

    t.Run("rectangles", func(t *testing.T) {
        rectangle := Rectangle{12, 6}
        got := Area(rectangle)
        want := 72.0

        if got != want {
            t.Errorf("got %.2f want %.2f", got, want)
        }
    })

    t.Run("circles", func(t *testing.T) {
        circle := Circle{10}
        got := Area(circle)
        want := 314.16

        if got != want {
            t.Errorf("got %.2f want %.2f", got, want)
        }
    })

}
Enter fullscreen mode Exit fullscreen mode

尝试运行测试

./shapes_test.go:28:13: undefined: Circle

编写最少的代码来运行测试,并检查失败的测试输出。

我们需要定义我们的Circle类型。

type Circle struct {
    Radius float64
}
Enter fullscreen mode Exit fullscreen mode

现在再尝试运行测试。

./shapes_test.go:29:14: cannot use circle (type Circle) as type Rectangle in argument to Area

某些编程语言允许你这样做:

func Area(circle Circle) float64 { ... }
func Area(rectangle Rectangle) float64 { ... }
Enter fullscreen mode Exit fullscreen mode

但在围棋中却不行。

./shapes.go:20:32: Area redeclared in this block

我们有两种选择

  • 你可以让同名函数出现在不同的中。所以我们可以在一个新包中创建它Area(Circle),但这在这里感觉有点杀鸡用牛刀了。
  • 我们可以改为在新定义的类型上定义方法。

什么是方法?

到目前为止,我们只编写了函数,但我们已经使用了一些方法。当我们调用时,我们是在调用我们( )实例上的t.Errorf方法ErrorFttesting.T

方法与函数非常相似,但方法需要通过对特定类型的实例调用才能执行。而函数则可以在任何地方调用,例如,Area(rectangle)方法只能对“事物”调用。

举个例子会有帮助,所以我们先修改测试用例,改为调用方法,然后再修复代码。

func TestArea(t *testing.T) {

    t.Run("rectangles", func(t *testing.T) {
        rectangle := Rectangle{12, 6}
        got := rectangle.Area()
        want := 72.0

        if got != want {
            t.Errorf("got %.2f want %.2f", got, want)
        }
    })

    t.Run("circles", func(t *testing.T) {
        circle := Circle{10}
        got := circle.Area()
        want := 314.1592653589793

        if got != want {
            t.Errorf("got %f want %f", got, want)
        }
    })

}
Enter fullscreen mode Exit fullscreen mode

如果我们尝试运行测试,我们会得到

./shapes_test.go:19:19: rectangle.Area undefined (type Rectangle has no field or method Area)
./shapes_test.go:29:16: circle.Area undefined (type Circle has no field or method Area)
Enter fullscreen mode Exit fullscreen mode

类型“Circle”没有字段或方法“Area”。

我想再次强调一下这个编译器有多棒。花时间仔细阅读错误信息非常重要,这从长远来看对你大有裨益。

编写最少的代码来运行测试,并检查失败的测试输出。

让我们为类型添加一些方法

type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64  {
    return 0
}

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64  {
    return 0
}
Enter fullscreen mode Exit fullscreen mode

声明方法的语法几乎与声明函数的语法相同,因为它们非常相似。唯一的区别在于方法接收器的语法func (receiverName RecieverType) MethodName(args)

当你的方法被调用到该类型的变量上时,你可以通过该变量获取对其数据的引用receiverName。在许多其他编程语言中,这是隐式完成的,你可以通过访问接收器来获取数据this

在 Go 语言中,接收变量的命名约定是使用类型名称的首字母。

如果你尝试重新运行测试,它们现在应该可以编译并给出一些失败的输出。

编写足够的代码使其通过测试。

现在让我们通过修复新方法来使矩形测试通过。

func (r Rectangle) Area() float64  {
    return r.Width * r.Height
}
Enter fullscreen mode Exit fullscreen mode

如果重新运行测试,矩形测试应该通过,但圆形测试应该仍然失败。

为了使 circleArea函数通过,我们将借用包Pi中的常量math(记得导入它)。

func (c Circle) Area() float64  {
    return math.Pi * c.Radius * c.Radius
}
Enter fullscreen mode Exit fullscreen mode

重构

我们的测试中存在一些重复项。

我们只想获取一组形状Area()对它们调用该方法,然后检查结果。

我们希望能够编写某种checkArea函数,可以将Rectangles 和Circles 都传递给它,但如果我们尝试传入一个不是形状的东西,则编译会失败。

使用 Go 语言,我们可以通过接口来编码这种意图。

接口在像 Go 这样的静态类型语言中是一个非常强大的概念,因为它们允许你创建可以与不同类型一起使用的函数,并创建高度解耦的代码,同时仍然保持类型安全。

让我们通过重构测试来引入这个概念。

func TestArea(t *testing.T) {

    checkArea := func(t *testing.T, shape Shape, want float64) {
        t.Helper()
        got := shape.Area()
        if got != want {
            t.Errorf("got %.2f want %.2f", got, want)
        }
    }

    t.Run("rectangles", func(t *testing.T) {
        rectangle := Rectangle{12, 6}
        checkArea(t, rectangle, 72.0)
    })

    t.Run("circles", func(t *testing.T) {
        circle := Circle{10}
        checkArea(t, circle, 314.1592653589793)
    })

}
Enter fullscreen mode Exit fullscreen mode

我们要创建一个类似其他练习中的辅助函数,但这次我们需要Shape传入一个形状参数。如果我们尝试用一个非形状参数调用这个函数,它将无法编译。

一个物体如何变成一个形状?我们只需Shape使用接口声明告诉 Go a 是什么即可。

type Shape interface {
    Area() float64
}
Enter fullscreen mode Exit fullscreen mode

我们正在创建一个新的,type就像我们之前创建和一样RectangleCircle但这次它是一个interface而不是一个struct

将此代码添加到代码中后,测试即可通过。

等等,什么?

这与大多数其他编程语言中的接口截然不同。通常,你需要编写代码来表达My type Foo implements interface Bar……

但就我们而言……

  • Rectangle有一个名为的方法Area,该方法返回一个,float64因此它满足Shape接口要求。
  • Circle有一个名为的方法Area,该方法返回一个,float64因此它满足Shape接口要求。
  • string它没有这样的方法,因此不符合接口要求。
  • ETC。

在 Go 语言中,接口解析是隐式的。如果你传入的类型与接口要求的类型匹配,程序就能编译通过。

解耦

注意,我们的辅助函数无需关心形状是 A Rectangle、BCircle还是 C。Triangle通过声明接口,辅助函数与具体类型解耦,只需提供完成其工作所需的方法即可。

这种使用接口来声明所需功能的方法在软件设计中非常重要,我们将在后面的章节中详细介绍。

进一步重构

现在你已经对结构体有了一些了解,我们可以引入“表驱动测试”了。

当您想要构建一个可以用相同方式进行测试的测试用例列表时,表格驱动测试非常有用。

func TestArea(t *testing.T) {

    areaTests := []struct {
        shape Shape
        want  float64
    }{
        {Rectangle{12, 6}, 72.0},
        {Circle{10}, 314.1592653589793},
    }

    for _, tt := range areaTests {
        got := tt.shape.Area()
        if got != tt.want {
            t.Errorf("got %.2f want %.2f", got, tt.want)
        }
    }

}

Enter fullscreen mode Exit fullscreen mode

[]struct这里唯一的新语法是创建“匿名结构体”。我们使用两个字段 `is`shape和 `is`来声明一个结构体切片want。然后我们用案例填充数组。

然后,我们像处理其他切片一样遍历它们,使用结构体字段来运行我们的测试。

你可以看到,对于开发者来说,引入一个新的形状、实现Area它,然后将其添加到测试用例中是非常容易的。此外,如果发现一个错误,Area也很容易添加一个新的测试用例来测试它,然后再进行修复。

基于表格的测试工具非常实用,但请确保您确实需要在测试中加入额外的噪声。如果您希望测试接口的各种实现方式,或者传递给函数的数据有许多不同的要求需要测试,那么基于表格的测试工具就非常适合。

让我们通过添加另一个形状并进行测试来演示这一切;这个形状是三角形。

先编写测试题。

为我们的新形状添加新测试非常简单,只需将其添加{Triangle{12, 6}, 36.0},到列表中即可。

func TestArea(t *testing.T) {

    areaTests := []struct {
        shape Shape
        want  float64
    }{
        {Rectangle{12, 6}, 72.0},
        {Circle{10}, 314.1592653589793},
        {Triangle{12, 6}, 36.0},
    }

    for _, tt := range areaTests {
        got := tt.shape.Area()
        if got != tt.want {
            t.Errorf("got %.2f want %.2f", got, tt.want)
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

尝试运行测试

记住,要不断尝试运行测试,让编译器引导你找到解决方案。

编写最少的代码来运行测试,并检查失败的测试输出。

./shapes_test.go:25:4: undefined: Triangle

我们尚未定义三角形。

type Triangle struct {
    Base   float64
    Height float64
}
Enter fullscreen mode Exit fullscreen mode

再试一次

./shapes_test.go:25:8: cannot use Triangle literal (type Triangle) as type Shape in field value:
    Triangle does not implement Shape (missing Area method)
Enter fullscreen mode Exit fullscreen mode

它提示我们不能使用三角形作为形状,因为它没有Area()方法,所以需要添加一个空的实现来让测试通过。

func (c Triangle) Area() float64 {
    return 0
}
Enter fullscreen mode Exit fullscreen mode

最终代码编译通过,我们得到了错误信息。

shapes_test.go:31: got 0.00 want 36.00

编写足够的代码使其通过测试。

func (c Triangle) Area() float64 {
    return (c.Base * c.Height) * 0.5
}
Enter fullscreen mode Exit fullscreen mode

我们的测试通过了!

重构

再次强调,实现部分没问题,但我们的测试还有待改进。

当你扫描这个

{Rectangle{12, 6}, 72.0},
{Circle{10}, 314.1592653589793},
{Triangle{12, 6}, 36.0},
Enter fullscreen mode Exit fullscreen mode

这些数字代表什么含义并不显而易见,你应该力求让你的测试易于理解。

目前为止,我们只展示了一种创建结构体实例的语法MyStruct{val1, val2},但你也可以选择给字段命名。

我们来看看它长什么样。

{shape: Rectangle{Width: 12, Height: 6}, hasArea: 72.0},
{shape: Circle{Radius: 10}, hasArea: 314.1592653589793},
{shape: Triangle{Base: 12, Height: 6}, hasArea: 36.0,
Enter fullscreen mode Exit fullscreen mode

《测试驱动开发实例》一书中, Kent Beck 对一些测试进行了重构,并断言……

这项测试对我们而言更加清晰明了,仿佛它本身就是对真理的断言,而非一系列操作。

(重点为笔者所加)

现在我们的测试(至少是案例列表)对形状及其面积做出了真理断言。

请确保您的测试输出有用。

还记得之前我们实现的时候Triangle遇到的测试失败吗?它打印了以下内容shapes_test.go:31: got 0.00 want 36.00

我们知道这与某个问题有关Triangle,因为我们一直在使用它,但如果表格中的 20 个案例中有一个案例出现了 bug 呢?开发人员如何才能知道是哪个案例出错了?这对开发人员来说体验很差,他们必须手动检查所有案例才能找出实际出错的案例。

我们可以更改错误消息%#v got %.2f want %.2f%#v格式化字符串会将结构体及其字段中的值打印出来,以便开发人员可以一目了然地看到正在测试的属性。

最后一点关于表格驱动测试的技巧是使用t.Run

通过将每个测试用例包裹在 `<case>` 标签中,t.Run您可以在测试失败时获得更清晰的输出,因为它会打印出测试用例的名称。

-------- FAIL: TestArea (0.00s)
    --- FAIL: TestArea/Rectangle (0.00s)
        shapes_test.go:33: main.Rectangle{Width:12, Height:6} got 72.00 want 72.10
Enter fullscreen mode Exit fullscreen mode

您可以使用以下方式在表格中运行特定测试:go test -run TestArea/Rectangle

以下是我们最终的测试代码,它捕获了这个问题。

func TestArea(t *testing.T) {

    areaTests := []struct {
        name    string
        shape   Shape
        hasArea float64
    }{
        {name: "Rectangle", shape: Rectangle{Width: 12, Height: 6}, hasArea: 72.0},
        {name: "Circle", shape: Circle{Radius: 10}, hasArea: 314.1592653589793},
        {name: "Triangle", shape: Triangle{Base: 12, Height: 6}, hasArea: 36.0},
    }

    for _, tt := range areaTests {
        // using tt.name from the case to use it as the `t.Run` test name
        t.Run(tt.name, func(t *testing.T) {
            got := tt.shape.Area()
            if got != tt.hasArea {
                t.Errorf("%#v got %.2f want %.2f", tt.shape, got, tt.hasArea)
            }
        })

    }

}
Enter fullscreen mode Exit fullscreen mode

总结

这更多的是 TDD 实践,我们迭代解决基本数学问题,并根据测试结果学习新的语言特性。

  • 声明结构体可以创建自定义数据类型,从而将相关数据捆绑在一起,使代码意图更加清晰。
  • 声明接口以便定义可供不同类型使用的函数(参数多态性
  • 添加方法,以便您可以为数据类型添加功能,并实现接口。
  • 基于表格的测试可以使你的断言更清晰,并使你的测试套件更易于扩展和维护。

这是非常重要的一章,因为我们现在开始定义自己的类型。在像 Go 这样的静态类型语言中,能够设计自己的类型对于构建易于理解、易于组装和易于测试的软件至关重要。

接口是隐藏系统其他部分复杂性的绝佳工具。在我们的例子中,测试辅助代码不需要知道它要断言的确切形状,只需要知道如何“请求”获取其面积即可。

随着你对 Go 语言越来越熟悉,你会逐渐体会到接口和标准库的真正优势。你会了解到标准库中定义的接口被广泛使用,通过将它们应用到你自己的类型中,你可以快速地复用许多强大的功能。

文章来源:https://dev.to/quii/learn-go-by-writing-tests-structs-methods-interfaces--table-driven-tests-1p01