假设有一个需求,给定宽height和高width,计算矩形的周长。我们可以写一个函数Perimeter(width float64, height float64),其中float64表示浮点数,例如123.45。
现在你对TDD方法应该很熟悉了。
func TestPerimeter(t *testing.T) {
got := Perimeter(10.0, 10.0)
expected := 40.0
if got != expected {
t.Errorf("got %.2f expected %.2f", got, expected)
}
}注意新的字符串格式化占位符,f是浮点数占位符,.2表示打印两位小数。
func Perimeter(width float64, height float64) float64 {
return 2 * (width + height)
}很简单,对吧。现在我们再创建一个函数Area(width, height float64),它可以返回矩形的面积。
你可以尝试先自己来实现,记得遵循TDD方法。
测试代码类似如下:
func TestPerimeter(t *testing.T) {
got := Perimeter(10.0, 10.0)
expected := 40.0
if got != expected {
t.Errorf("got %.2f expected %.2f", got, expected)
}
}
func TestArea(t *testing.T) {
got := Area(12.0, 6.0)
expected := 72.0
if got != expected {
t.Errorf("got %.2f expected %.2f", got, expected)
}
}实现代码如下:
func Perimeter(width float64, height float64) float64 {
return 2 * (width + height)
}
func Area(width float64, height float64) float64 {
return width * height
}上面的代码可以实现功能,但是代码本身和矩形没有直接关联。一个粗心的程序员可能会误传入一个三角形的宽度和高度,却没有意识到结果是错误的。
我们可以给函数更明确的名称,例如RectangleArea。一种更合理的做法是定义我们自己的Rectangle类型,通过这个类型封装矩形这个概念。
我们可以用struct来创建一个简单类型。struct,简单理解,就是包含一组字段的一个结构体,可以用来存数据。
声明一个Rectangle结构体:
type Rectangle struct {
Width float64
Height float64
}我们使用Rectangle来重构测试:
func TestPerimeter(t *testing.T) {
rectangle := Rectangle{10.0, 10.0}
got := Perimeter(rectangle)
expected := 40.0
if got != expected {
t.Errorf("got %.2f expected %.2f", got, expected)
}
}
func TestArea(t *testing.T) {
rectangle := Rectangle{12.0, 6.0}
got := Area(rectangle)
expected := 72.0
if got != expected {
t.Errorf("got %.2f expected %.2f", got, expected)
}
}修改程序代码shapes.go
func Perimeter(rectangle Rectangle) float64 {
return 2 * (rectangle.Width + rectangle.Height)
}
func Area(rectangle Rectangle) float64 {
return rectangle.Width * rectangle.Height
}可以通过 myStruct.field 语法来访问结构体的字段。
通过给函数传一个Rectangle类型的参数,这个函数的作用会更加明确。但实际上还有更合理的做法,可以直接使用结构体struct来实现,我们后面会展开。
下一个需求是给圆形写一个Area函数。
func TestArea(t *testing.T) {
t.Run("rectangles", func(t *testing.T) {
rectangle := Rectangle{12, 6}
got := Area(rectangle)
expected := 72.0
if got != expected {
t.Errorf("got %g expected %g", got, expected)
}
})
t.Run("circles", func(t *testing.T) {
circle := Circle{10}
got := Area(circle)
expected := 314.1592653589793
if got != expected {
t.Errorf("got %g expected %g", got, expected)
}
})
}可以看到,格式化占位符f可以用g替代,用f的话难以知道确切的小数位,而g可以在错误消息中显示完整的小数位(参考fmt选项)。
我们先定义Circle类型结构体:
type Circle struct {
Radius float64
}然后我们实现计算圆形面积的函数,你可以尝试添加Area(rectangle Rectangle)函数:
func Area(circle Circle) float64 { ... }
func Area(rectangle Rectangle) float64 { ... }但是编译不通过,Go语言不允许你在同一块中重复声明Area函数:
./shapes.go:20:32: Area redeclared in this block
有两个办法解决这个问题:
- 我们可以将同名的函数声明在不同的包package中,但是这样做有点把事情搞复杂了。
- 我们也可以利用struct类型来定义方法method。
虽然到目前为止我们只写过函数(function),但其实我们已经用过一些方法(method)。之前我们调用t.Errorf,其实我们是在调用实例t(类型为testing.T)上的方法Errorf。
所谓方法,是带接收者(receiver)的一个函数。方法声明将方法名和方法体绑定起来,并且将这个方法关联到接收者的基础类型上。
方法和函数非常像,但调用方式不同,方法是通过对应实例调用的。你可以在任意地方调用函数,例如Area(rectangle),但你只能在某个"事物"上调用方法。
来看具体例子,我们先修改测试,改为调用方法,后面我们再修改程序代码。
func TestArea(t *testing.T) {
t.Run("rectangles", func(t *testing.T) {
rectangle := Rectangle{12, 6}
got := rectangle.Area()
expected := 72.0
if got != expected {
t.Errorf("got %g expected %g", got, expected)
}
})
t.Run("circles", func(t *testing.T) {
circle := Circle{10}
got := circle.Area()
expected := 314.1592653589793
if got != expected {
t.Errorf("got %g expected %g", got, expected)
}
})
}type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func Perimeter(rectangle Rectangle) float64 {
return 2 * (rectangle.Width + rectangle.Height)
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}方法声明的语法和函数很像,主要区别在方法接收者的语法: func (receiverName RecieverType) MethodName(args)。
当你调用某种类型的方法,可以通过receiverName这个变量获取对当前实例的引用。在很多其它语言(比如Java)中,是通过this这个接收者来获取当前实例的引用的。
在Go语言中,接收者变量的命名惯例是使用类型的第一个字母,并且小写。
r Rectangle注意,在Circle的Area函数中,我们引用了math包中的PI常量,记得要导入math包。
现在运行测试,确保测试通过。
目前测试代码里头有重复,我们的两个测试方法的流程都类似: 创建一个形状实例,然后调用Area()方法计算面积,最后比对面积。
我们可以抽取公共测试逻辑checkArea,它接收一个形状(Shape),这个形状可以是Rectangle,也可以是Circle,只要满足支持计算Area()即可。
在Go语言中,这个Shape可以用接口interface来实现。
在类似Go这样的静态类型语言中,接口Interfaces是一种非常强大的概念,它允许我们创建一种类似具有范型能力的函数~这类函数可以接收不同的类型作为参数,它让我们可以创建高度解耦的代码,同时继续保持类型安全。
为了引入接口,我们先重构测试:
func TestArea(t *testing.T) {
checkArea := func(t *testing.T, shape Shape, expected float64) {
t.Helper()
got := shape.Area()
if got != expected {
t.Errorf("got %g expected %g", got, expected)
}
}
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)
})
}checkArea是我们抽取出来的一个公共测试函数,它要求传入一个Shape,如果我们传入的不是一个Shape,那么编译器就会报错。
这个Shape到底长啥样?在Go语言中,我们只需要定义一个接口声明:
type Shape interface {
Area() float64
}就像我们之前创建Rectangle和Circle一样,我们再创建了一个新类型Shape,只不过这次我们用的是interface,而不是之前的struct。
现在运行测试,可以通过。
Go语言中的接口和其它语言中的接口很不一样。在其它语言中,你的类型必须显式地实现接口,就像My type Foo implements interface Bar这样。
但在我们的案例中:
Rectangle有一个称为Area的方法,它返回一个float64类型的返回值,所以它满足Shape接口规范Circle也有一个称为Area的方法,它也返回一个float64类型的返回值,所以它也满足Shape接口规范string没有称为Area的方法,所以它不满足Shape接口规范- 等等
在Go语言中,接口解析是隐式的。只要你传入的类型满足接口类型规范(具有接口要求的方法),编译就会通过,它不要求显示声明。
注意,我们的测试公共函数checkArea并不关心传入的是一个Rectangle or Circle or Triangle。通过声明一个接口,这个函数就和具体的类型解耦了,它只需关心具体的操作逻辑。
接口规范声明支持哪些方法,具体类型只要具备同名方法就满足接口,这种方式在软件设计中非常重要,后续章节我们会讲解更多细节。
既然我们对struct已经有所理解,我们可以引入"表驱动测试"。
表驱动测试(Table Driven Tests)是一种测试方法,在对一组测试用例进行相同测试的时候,表驱动测试比较有用。
func TestArea(t *testing.T) {
areaTests := []struct {
shape Shape
expected float64
}{
{Rectangle{12, 6}, 72.0},
{Circle{10}, 314.1592653589793},
}
for _, tt := range areaTests {
got := tt.shape.Area()
if got != tt.expected {
t.Errorf("got %g expected %g", got, tt.expected)
}
}
}在上面的测试中,我们声明了一个结构体切片(a slice of structs),这个结构体是一个匿名结构体,具有两个字段,shape和expected,然后我们创建一个Rectangle和一个Circle,作为测试用例填充到切片中,最后将切片赋值给areaTests变量。
然后我们对areaTests切片进行迭代,使用结构体上的字段运行测试。
采用这种做法,开发人员只需要添加一个新的Shape结构体类型,实现Area方法,然后创建对应实例并添加到测试切片列表中,就可以进行测试。
表驱动测试是一种有用的测试方法,但是开发起开需要一些额外的投入。如果你需要对某个接口的不同实现进行测试,那么表驱动测试是一种比较合适的方法。
我们再添加一个形状~三角形(triangle),来演示表驱动测试。
为我们的新形状添加一个测试很简单,只需在测试列表中添加 {Triangle{12, 6}, 36.0},。
func TestArea(t *testing.T) {
areaTests := []struct {
shape Shape
expected 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.expected {
t.Errorf("got %g expected %g", got, tt.expected)
}
}
}新建三角形Triangle结构体:
type Triangle struct {
Base float64
Height float64
}
func (t Triangle) Area() float64 {
return (t.Base * t.Height) * 0.5
}注意,Triangle结构体必须具备Area()方法,并且返回值类型是float64,这样才能满足Shape接口规范要求,否则编译通不过。
运行测试,校验通过。
到目前为止,我们的代码实现是可以的,但是测试方面还可以再提升。
看一下下面的代码:
{Rectangle{12, 6}, 72.0},
{Circle{10}, 314.1592653589793},
{Triangle{12, 6}, 36.0},这几行代码的可读性不佳,含义并不明显,或者说不太容易理解。
之前我们通过MyStruct{val1, val2}方式创建实例,但实际上你还可以命名字段。
再看下面的重构后的代码:
{shape: Rectangle{Width: 12, Height: 6}, expected: 72.0},
{shape: Circle{Radius: 10}, expected: 314.1592653589793},
{shape: Triangle{Base: 12, Height: 6}, expected: 36.0},In Test-Driven Development by Example Kent Beck refactors some tests to a point and asserts:
在Test-Driven Development by Example这本书中,Kent Beck在重构完一些测试代码后指出:
The test speaks to us more clearly, as if it were an assertion of truth, not a sequence of operations
测试应当浅显易懂,看上去就是断言一些易懂的事实,而不是系列难理解的操作
显然,重构后的代码更浅显易懂。
之前对Triangle的测试,如果Area函数逻辑不正确,那么错误输出可能类似如下:
shapes_test.go:31: got 0.00 expected 36.00.
我们知道这个错误和Triangle有关,那是因为我们正好在测它。但是如果我们的测试用例很多(比如超过20个),然后其中一个有bug,如果错误输出不够明确的话,开发人员如何知道具体是哪个用例失败了呢?这个也是开发者体验问题,他们可能需要反复翻看代码才能具体定位哪个用例出错了。
我们可以把错误消息格式化字符串改为%#v got %.2f expected %.2f。%#v格式化字符串会把相关结构体及其字段都打印出来,这样开发人员就比较容易查看和定位问题。
为了进一步提升测试代码的可读性,我们可以把expected字段命名为更具描述性的字段如hasArea。
关于表驱动测试的最后一个技巧是使用 t.Run,并给测试用例命名。
通过将每个用例包裹在t.Run方法中,那么测试失败时会输出更清晰的错误消息,因为它会打印出用例名称,例如:
--- FAIL: TestArea (0.00s)
--- FAIL: TestArea/Rectangle (0.00s)
shapes_test.go:33: main.Rectangle{Width:12, Height:6} got 72.00 expected 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 %g expected %g", tt.shape, got, tt.hasArea)
}
})
}
}我们接触了更多的TDD实践,通过对基本的几何图形计算的改进,我们逐步了学习新的语言功能:
- 通过结构体struct来创建你自己的数据类型,它可以把一组相关数据包装起来,让你的代码意图更清晰
- 通过接口interfact,可以让函数接受遵循同一接口规范的不同类型作为输入(参考参数多态化parametric polymorphism)
- 在数据类型上,可以添加方法来为类型添加功能,这些方法可以遵循某个接口规范
- 表驱动测试让你的测试断言更清晰,也让你的测试族更易于扩展和维护
本章比较重要,因为我们开始定义自己的类型了。在像Go这样的静态语言中,能够定制自己的类型是非常重要的,它让我们能够创建更大更复杂的软件系统,并且代码易于理解,模块化和测试。
接口是一种强大的解耦机制,让我们可以隔离和隐藏复杂性。在我们之前的公共测试函数中,测试代码并不需要确切知道传入的具体是哪种Shape类型,只需要能够调用实例的Area方法就可以了。
随着你对Go语言越来越熟悉,你会逐渐体会Go语言接口和标准库的强大能力。你会看到,标准库中大量定义和使用接口,通过让你的类型也实现标准库的接口,你就可以很快重用标准库的大量功能。