This page looks best with JavaScript enabled

強迫使用 TDD 開發模式以Go為例

 ·  ☕ 6 min read

先寫測試

假設今天我們要寫一下計算長方形週長的函數,那我們可能會定義一個 Perimeter 的 function ,這個 function 會預期攜帶兩個參數一個是長度 length 一個是寬度 width ,以及他們的型態是浮點數的型態 float64

我們這測試函數可能是長這樣子

測試長什麼樣子

Go 在撰寫測試的時候習慣使用以下這種風格( table-drive),這種測試模式相當的方便。

我會先寫出測試的目標所需點三個數值並且包裝成一個結構體。

  1. 情境名稱
  2. 情境需要的參數
  3. 情境預期的答案

在情境所需要的參數部分,在計算週長的情境之下我們需要長寬,故我們也將包裝成一個結構體。

所以本次測試的程式碼大概如下所示。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func TestPerimeter(t *testing.T) {
	type args struct {
		width  float64
		height float64
	}
	tests := []struct {
		name string
		args args
		want float64
	}{
		{
			name: "rectangles",
			args: args{
				width:  4.0,
				height: 4.0,
			},
			want: 16.00,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Perimeter(tt.args.width, tt.args.height); got != tt.want {
				t.Errorf("Perimeter() = %.2f, want %.2f", got, tt.want)
			}
		})
	}
}

按照 TDD 的教戰手冊,就是撰寫最少的程式碼達到用測試推導出真正的函數要怎麼定義。

這時候我們可跑一下測試看看,站在測試的角度我們真正的函數還缺少了什麼東西。

1
2
3
4
go test                       
# go-tdd-example/perimeter [go-tdd-example/perimeter.test]
./main_test.go:27:14: undefined: Perimeter
FAIL    go-tdd-example/perimeter [build failed]

可以看到在測試的過程中我們看到 Error ,這個 Error 是告訴我們 undefined: Perimeter ,原因是我們在 main package 裡面還沒有定義 Perimeter function ,那就來定義吧!

補充main package

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

這時後再執行一次 go test 看看結果。

1
2
3
go test
PASS
ok      go-tdd-example/perimeter        0.006s

重構!

測試寫完了也正確執行,我們可以思考一下 Perimeter 這個函數所代表的意思,應該是計算週長吧?

那會不會有其他開發者,把計算長方行週長的函數拿來計算三角形的週長,我想這是有可能的吧xD

所以我們需要把計算長方形的周長函數進行封裝,希望這個函數只能計算長方形週長。

那我們會先定義長方形的結構體,希望計算週長的函數 Perimeter 只接受長方形的結構體,並且計算他的週長。

1
2
3
4
type Rectangle struct {
    Width float64
    Height float64
}

測試長什麼樣子

按照 TDD 的教戰手冊,就是撰寫最少的程式碼達到用測試推導出真正的函數要怎麼定義,簡單的說就是讓編譯器幫助我們建構正確的程式碼。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func TestPerimeter(t *testing.T) {
	type args struct {
		Rectangle Rectangle
	}
	tests := []struct {
		name string
		args args
		want float64
	}{
		{
			name: "rectangles",
			args: args{
				&Rectangle{
					Width:  4.0,
					Height: 4.0,
				},
			},
			want: 16.00,
		},
        
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Perimeter(tt.args.Rectangle); got != tt.want {
				t.Errorf("Perimeter() = %.2f, want %.2f", got, tt.want)
			}
		})
	}
}

這時候我們可跑一下測試看看,站在測試的角度我們真正的函數還缺少了什麼東西。

1
2
3
4
5
6
go test                       
# go-tdd-example/perimeter [go-tdd-example/perimeter.test]
./main_test.go:28:23: not enough arguments in call to Perimeter
        have (Rectangle)
        want (float64, float64)
FAIL    go-tdd-example/perimeter [build failed]

可以看到在測試的過程中我們看到 Error ,這個 Error 是告訴我們

not enough arguments in call to Perimeter
        have (Rectangle)
        want (float64, float64)
FAIL    go-tdd-example/perimeter [build failed]

補充main package

原因是我們 Perimeter function ,定義的是 (width, height float64) 現在需要改成接收(rectangle Rectangle)

那我們的 Perimeter function 就會修改成以下形式。

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

這時後再執行一次 go test 看看結果。

1
2
3
go test  
PASS
ok      go-tdd-example/perimeter        0.006s

不停的重構

做一個合格的工程師一直收到新的需求也是很正常的,今天PM告訴我客戶想要計算圓形的週長,對於工程師來說能盡量少改code就少改code xD,這時候遵循 TDD 的原則先寫 test !

對於圓形來說,他有的結構體應該只有半徑,那我們先定義出他的結構體吧!

1
2
3
type Cycle struct {
	Radius float64
}

測試的 code 可能會長這樣子,應算很直覺的 test code ,因為我們現在 Perimeter 的計算函數,需要計算長方形跟圓形。

  1. 情境需要的參數自然就變成 Rectangle 加 Cycle
  2. 在 for 迴圈裡面 Perimeter function 有輸入 Rectangle 也有輸入 Cycle 看起來一切都很合理
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func TestPerimeter(t *testing.T) {
	type args struct {
		Rectangle Rectangle
		Cycle     Cycle
	}
	tests := []struct {
		name string
		args args
		want float64
	}{
		{
			name: "rectangles",
			args: args{
				&Rectangle{
					Width:  4.0,
					Height: 4.0,
				},
				Cycle{
					Radius: 16.0,
				},
			},
			want: 7.85,
		},
        {
			name: "rectangles",
			args: args{
				&Cycle{
					Radius: 1,
				},
			},
			want: 1.57,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Perimeter(tt.args.Rectangle); got != tt.want {
				t.Errorf("Perimeter() = %.2f, want %.2f", got, tt.want)
			}
			if got := Perimeter(tt.args.Cycle); got != tt.want {
				t.Errorf("Perimeter() = %.2f, want %.2f", got, tt.want)
			}
		})
	}
}

這時候我們可跑一下測試看看,站在測試的角度我們真正的函數還缺少了什麼東西。

1
2
3
4
go test                                                             
# go-tdd-example/perimeter [go-tdd-example/perimeter.test]
./main_test.go:34:31: cannot use tt.args.Cycle (type Cycle) as type Rectangle in argument to Perimeter
FAIL    go-tdd-example/perimeter [build failed]

可以看到在測試的過程中我們看到 Error ,這個 Error 是告訴我們

cannot use tt.args.Cycle (type Cycle) as type Rectangle in argument to Perimeter

這個意思大概是 Perimeter function 要求輸入一個 Rectangle 的類別,但是我們輸入 Cycle 類別。

明明 長方形(Rectangle) 也要計算周長, 圓形(Cycle)也要計算週長啊!我不服氣!xD

找到共同點

從上面的問題我們找到 長方形 跟 圓形 的共同點,都需要計算週長!那我們需要借助 go 的 interface 來定義共同的方法,在本篇的例子就是 計算週長 Perimeter function

1
2
3
type Shape interface {
    Perimeter() float64
}

現在測試變成怎麼樣了?

還記得前面提到的測試結構嗎?我會先寫出測試的目標所需點三個數值並且包裝成一個結構體。

  1. 情境名稱
  2. 情境需要的參數
  3. 情境預期的答案

這時候情境所需要的參數需要做變動,現在情境需要的是有計算週長能力的東西也就是interface Shape

簡單的說現在情需要的參數是一個有能力計算周長的物件,在這個情境下我會把物件拿去計算看是否能得到我預期的答案。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func TestPerimeter(t *testing.T) {
	type args struct {
		shape Shape
	}
	tests := []struct {
		name string
		args args
		want float64
	}{
		{
			name: "rectangles",
			args: args{
				Rectangle{
					Width:  4.0,
					Height: 4.0,
				},
				Cycle{
					Radius: 1,
				},
			},
			want: 1.57,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := tt.args.shape.Perimeter(); got != tt.want {
				t.Errorf("Perimeter() = %.2f, want %.2f", got, tt.want)
			}
		})
	}
}

這時候我們可跑一下測試看看,站在測試的角度我們真正的函數還缺少了什麼東西。

1
2
3
4
5
6
7
8
9
go test                                                             
# go-tdd-example/perimeter [go-tdd-example/perimeter.test]
./main_test.go:17:15: cannot use &Rectangle literal (type *Rectangle) as type Shape in field value:
        *Rectangle does not implement Shape (missing Perimeter method)
./main_test.go:27:11: cannot use &Cycle literal (type *Cycle) as type Shape in field value:
        *Cycle does not implement Shape (missing Perimeter method)


FAIL    go-tdd-example/perimeter [build failed]

可以看到在測試的過程中我們看到 Error ,這個 Error 是告訴我們

./main_test.go:17:15: cannot use &Rectangle literal (type *Rectangle) as type Shape in field value:
        *Rectangle does not implement Shape (missing Perimeter method)
./main_test.go:27:11: cannot use &Cycle literal (type *Cycle) as type Shape in field value:
        *Cycle does not implement Shape (missing Perimeter method)


FAIL    go-tdd-example/perimeter [build failed]

會看到這兩個錯誤,第一個錯誤是在說 *Rectangle 沒有實作 Shape interface ,因為他卻少了 Perimeter function 。

第二個錯是在說 *Cycle 沒有實作 Shape interface ,因為他卻少了 Perimeter function 。

補充main package

因為在測試的時候編譯器告訴我們 Rectangle 沒有實作 Shape interface ,因為他卻少了 Perimeter function ,那我們就識做一個吧,這時候我們需要用到 go 的function reciver 來幫 Rectangle 新增一個 Perimeter function

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type Shape interface {
	Perimeter() float64
}

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

func (r *Cycle) Perimeter() float64 {
	return 3.14 * r.Radius / 2
}

這時後再執行一次 go test 看看結果。

1
2
3
go test  
PASS
ok      go-tdd-example/perimeter        0.006s

我們成功了!

小結

本篇是一個 TDD 基本的範例,不斷嘗試執行這些 test code 並讓編譯器 指引 我們找到正確的方案。

用 Go 語言實作了一個週長的計算函數,在最初的需求我們只需要一個 長方形的 計算方法,非常簡單就可以實作出來。 透過 go test 提示我們 真正的函數長什麼樣子,藉著我們去補齊內部的邏輯,當補齊內部邏輯後再次執行 test code 直到測試通過為止。

當有新的需求(圓形周長計算),我們透過 test code 與軟體工程的概念抽離周長計算方法,使用 go 的 interface 與 function reciver 協助我們透過介面的方式進行測試與重構。

TDD 在現今的需求之後變得非常的重要,我們可以透過 test 完成重構讓程式碼變得可以測試與能夠預期城市的行為。


Meng Ze Li
WRITTEN BY
Meng Ze Li
Kubernetes / DevOps / Backend

What's on this Page