通过编写测试来学习 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)
}
}
注意新的格式字符串吗?这f是为了我们的格式float64,而.2表示打印两位小数。
尝试运行测试
./shapes_test.go:6:9: undefined: Perimeter
编写最少的代码来运行测试,并检查失败的测试输出。
func Perimeter(width float64, height float64) float64 {
return 0
}
结果shapes_test.go:10: got 0 want 40
编写足够的代码使其通过测试。
func Perimeter(width float64, height float64) float64 {
return 2 * (width + height)
}
到目前为止,一切都很简单。现在我们来创建一个名为 `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)
}
}
以及类似这样的代码
func Perimeter(width float64, height float64) float64 {
return 2 * (width + height)
}
func Area(width float64, height float64) float64 {
return width * height
}
重构
我们的代码虽然能实现功能,但其中并没有明确说明矩形的特征。粗心的开发者可能会尝试将三角形的宽度和高度传递给这些函数,却没意识到它们会返回错误的结果。
我们可以给这些函数起更具体的名字,比如RectangleArea`.`。更简洁的办法是定义我们自己的类型` Rectangle,它封装了这个概念。
我们可以使用结构体创建一个简单的类型。结构体就是一个命名的字段集合,你可以在其中存储数据。
像这样声明一个结构体
type Rectangle struct {
Width float64
Height float64
}
现在让我们重构测试,使用 `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)
}
}
请记住在尝试修复之前运行测试,您应该会收到类似这样的有用错误信息。
./shapes_test.go:7:18: not enough arguments in call to Perimeter
have (Rectangle)
want (float64, float64)
您可以使用以下语法访问结构体的字段myStruct.field。
修改这两个函数以修复测试。
func Perimeter(rectangle Rectangle) float64 {
return 2 * (rectangle.Width + rectangle.Height)
}
func Area(rectangle Rectangle) float64 {
return rectangle.Width * rectangle.Height
}
我希望您同意,将 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)
}
})
}
尝试运行测试
./shapes_test.go:28:13: undefined: Circle
编写最少的代码来运行测试,并检查失败的测试输出。
我们需要定义我们的Circle类型。
type Circle struct {
Radius float64
}
现在再尝试运行测试。
./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 { ... }
但在围棋中却不行。
./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)
}
})
}
如果我们尝试运行测试,我们会得到
./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)
类型“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
}
声明方法的语法几乎与声明函数的语法相同,因为它们非常相似。唯一的区别在于方法接收器的语法func (receiverName RecieverType) MethodName(args)。
当你的方法被调用到该类型的变量上时,你可以通过该变量获取对其数据的引用receiverName。在许多其他编程语言中,这是隐式完成的,你可以通过访问接收器来获取数据this。
在 Go 语言中,接收变量的命名约定是使用类型名称的首字母。
如果你尝试重新运行测试,它们现在应该可以编译并给出一些失败的输出。
编写足够的代码使其通过测试。
现在让我们通过修复新方法来使矩形测试通过。
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
如果重新运行测试,矩形测试应该通过,但圆形测试应该仍然失败。
为了使 circleArea函数通过,我们将借用包Pi中的常量math(记得导入它)。
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
重构
我们的测试中存在一些重复项。
我们只想获取一组形状,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)
})
}
我们要创建一个类似其他练习中的辅助函数,但这次我们需要Shape传入一个形状参数。如果我们尝试用一个非形状参数调用这个函数,它将无法编译。
一个物体如何变成一个形状?我们只需Shape使用接口声明告诉 Go a 是什么即可。
type Shape interface {
Area() float64
}
我们正在创建一个新的,type就像我们之前创建和一样Rectangle,Circle但这次它是一个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)
}
}
}
[]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)
}
}
}
尝试运行测试
记住,要不断尝试运行测试,让编译器引导你找到解决方案。
编写最少的代码来运行测试,并检查失败的测试输出。
./shapes_test.go:25:4: undefined: Triangle
我们尚未定义三角形。
type Triangle struct {
Base float64
Height float64
}
再试一次
./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)
它提示我们不能使用三角形作为形状,因为它没有Area()方法,所以需要添加一个空的实现来让测试通过。
func (c Triangle) Area() float64 {
return 0
}
最终代码编译通过,我们得到了错误信息。
shapes_test.go:31: got 0.00 want 36.00
编写足够的代码使其通过测试。
func (c Triangle) Area() float64 {
return (c.Base * c.Height) * 0.5
}
我们的测试通过了!
重构
再次强调,实现部分没问题,但我们的测试还有待改进。
当你扫描这个
{Rectangle{12, 6}, 72.0},
{Circle{10}, 314.1592653589793},
{Triangle{12, 6}, 36.0},
这些数字代表什么含义并不显而易见,你应该力求让你的测试易于理解。
目前为止,我们只展示了一种创建结构体实例的语法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,
在《测试驱动开发实例》一书中, 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
您可以使用以下方式在表格中运行特定测试: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)
}
})
}
}
总结
这更多的是 TDD 实践,我们迭代解决基本数学问题,并根据测试结果学习新的语言特性。
- 声明结构体可以创建自定义数据类型,从而将相关数据捆绑在一起,使代码意图更加清晰。
- 声明接口以便定义可供不同类型使用的函数(参数多态性)
- 添加方法,以便您可以为数据类型添加功能,并实现接口。
- 基于表格的测试可以使你的断言更清晰,并使你的测试套件更易于扩展和维护。
这是非常重要的一章,因为我们现在开始定义自己的类型。在像 Go 这样的静态类型语言中,能够设计自己的类型对于构建易于理解、易于组装和易于测试的软件至关重要。
接口是隐藏系统其他部分复杂性的绝佳工具。在我们的例子中,测试辅助代码不需要知道它要断言的确切形状,只需要知道如何“请求”获取其面积即可。
随着你对 Go 语言越来越熟悉,你会逐渐体会到接口和标准库的真正优势。你会了解到标准库中定义的接口被广泛使用,通过将它们应用到你自己的类型中,你可以快速地复用许多强大的功能。
文章来源:https://dev.to/quii/learn-go-by-writing-tests-structs-methods-interfaces--table-driven-tests-1p01