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

 ·  ☕ 5 

記錄一下,在強迫使用TDD模式開發的情境與心得。

比如我們需要一個下載器,這一個下載器會幫助我們從網路下載一些資料並且抽取出特定的資料。

因應這個情境我使用TDD開發模式強迫自己使用測試驅動的思維思考。

首先我們要需要一個下載器 Downloader ,所以我們會在 package 寫下 NewDownloader 的方法,接著讓 IDE 幫我補齊測試。

以最小步驟開發

比如我在download package 先定義一個 NewDownloader 的方法,透過 IDE 指引幫我們建立他的測試。

1
2
3
func NewDownloader() *Downloader {

}


IDE會幫我們建立 Test Function 如下圖所示。

這時候我們會受到IDE的提示,告訴我們 Downloader 這個類型沒有定義。
另外也告訴我們這裡有 TODO List 要我們加入一些 test cases 。
我們先完成最少測試所需要的物件 Downloader ,透過IDE快速地建立所需要的物件。

其實 IDE 也只是簡單幫你打幾個字而已,像是這是一個 interface 還是一個 struct 還是一個 function 還是其他物件這邊就需要自己定義。

我們需要的是一個 struct ,為什麼?

  1. 還記得我們前面定義的 function 是一個 NewDownloader 嗎?
    通常這邊會需要的是 struct,所以 code 應該會長這樣子。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type Downloader struct {

}

func TestNewDownloader(t *testing.T) {
	tests := []struct {
		name string
		want *Downloader
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := NewDownloader(); !reflect.DeepEqual(got, tt.want) {
				t.Errorf("NewDownloader() = %v, want %v", got, tt.want)
			}
		})
	}
}

還缺少什麼呢?
剛剛有提到的 Test Case ,接著補滿我們的 Test Case 。
這裡只要因為我們只是 New 一個 NewDownloader 出來而已所以 Test Case 很間單的建立一個物,這時候程式碼可能會長這樣子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func TestNewDownloader(t *testing.T) {
	tests := []struct {
		name string
		want *Downloader
	}{
		{
			name: "really init",
			want: &Downloader{},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := NewDownloader(); !reflect.DeepEqual(got, tt.want) {
				t.Errorf("NewDownloader() = %v, want %v", got, tt.want)
			}
		})
	}
}

可以看到 IDE 還有紅色的字表示有問題

於是我們到 download.go 去觀察有什麼問題。

原來這邊告訴我們沒有定義 Download 的型別

疑!奇怪,我們不是在 Test 已經定義過了嗎?
是這樣的,在 test 定義過的 Download struct 是沒有辦法再不是 test 的 package 所使用的。

所以我們需要把 Test 定義過的 Download struct 過移過來到這裡,看看還有什麼其他問題。


搬移過來了, Download 型別已經沒有出現紅字了,這時候 IDE 告訴我們 NewDownloader 這個 function 沒有寫 return 回傳值。那我們就寫一個給他吧。
於是我們的 code 現在變成這樣子

download.go

1
2
3
4
5
6
7
8
package download

type Downloader struct {
}

func NewDownloader() *Downloader {
	return &Downloader{}
}

download_test.go

 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
package download

import (
	"reflect"
	"testing"
)


func TestNewDownloader(t *testing.T) {
	tests := []struct {
		name string
		want *Downloader
	}{
		{
			name: "really init",
			want: &Downloader{},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := NewDownloader(); !reflect.DeepEqual(got, tt.want) {
				t.Errorf("NewDownloader() = %v, want %v", got, tt.want)
			}
		})
	}
}

這時候跑test 應該會看到一個完美的 Test 成功

go test .                                                         
ok      kata-go-example/download        0.239s

持續迭代

雖然你可能會覺得很煩,為什麼要寫這些有的沒的,這些步驟 IDE 可以幫你大量處理,不過當我們習慣之後 IDE 會變成輔助你哪裡忘記寫,記住一個要點 小步驟的迭代

好的有 Downloader 物件之後我們沒有一個真的下載的方法,所以我們需要一個 Download 的方法,但是 因為很重要所以要說很多次 但是 但是 但是 但是 ,我們通常會希望這個 Download 的方法 不是寫死的,保留一些抽象空間可以替換。

這邊我提供幾種方法

第一種 在結構注入 function type

現定義一個最簡單的download function ,我們預期輸入一串網址,下載器會幫我處理,這串網址對應的IP位置,最簡單function 可能會是長這樣子。

1
2
3
func (d *Downloader) Download(url string) string {
	
}

定義完成之後透過 IDE 幫忙補齊 Test function

IDE 建立完成的 Code 大概會長以下這樣子,也會期待你幫他補完test case 。

到這裡不知道大家有沒有發現還是還沒實作 download 的 function, test code 看起來除了要我們加入一些 test case 之外好像就沒有別的錯誤了。

這邊需要使用到一些小技巧例如 function type ,我們可以定義一個 Download 的function type 如下所示

1
type DownloadFunc func(url string) string

定義了 DownloadFunc 我們可能會希望這個 Function 在 Downloader 的struct 裡面讓 Downloader 的結構擁有外部的傳進來的下載方法,如此一來就不會強依賴 Downloader 裡面寫什麼。

既然我改了 NewDownloader 勢必會導致 NewDownloader 的測試方法不會通過

這邊IDE就提示我們參數不正確,我們這邊就簡單地補上一個 Download function 就行,要訣修改到之前的 test code 需要先保證舊的 test code 是可以正確執行的!

好,那就繼續回到 Download function 測試這個部分,我們在 struct 中新增了 DownloadFunc 就可以讓 Downloader 直接使用,code 可能會像是以下範例。

1
2
3
func (d *Downloader) Download(url string) string {
	return d.downloadFunc(url)
}

那麼 test code 會變成怎樣樣子呢?

原本是長這樣子的test code

但我們新增了在 struct 裡新增了 DownloadFunc 讓 Download 的邏輯被抽離了,這邊我們只要簡單的驗證一下 DownloadFunc 是否正確被使用即可,記得最後還需要補上test case 呦!

最後我們的Download Function 的test code 應該會長這樣子

 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
func TestDownloader_Download(t *testing.T) {
	type args struct {
		url string
	}
	fakeWant := "this is fake function"
	tests := []struct {
		name string
		args args
		want string
	}{
		{
			name: "Fake Download Func",
			args: args{
				"google.com",
			},
			want: fakeWant,
		},
	}
	fakeDownloadFunc := func(url string) string {
		return fakeWant
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {

			d := NewDownloader(fakeDownloadFunc)
			if got := d.Download(tt.args.url); got != tt.want {
				t.Errorf("Download() = %v, want %v", got, tt.want)
			}
		})
	}
}

而我們 package 裡面的 code 應該會是長這樣子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type DownloadFunc func(url string) string

type Downloader struct {
	downloadFunc DownloadFunc
}

func NewDownloader(downloadFunc DownloadFunc) *Downloader {
	return &Downloader{downloadFunc}
}

func (d *Downloader) Download(url string) string {
	return d.downloadFunc(url)
}

第二種 在方法注入 function type

第二種方法一樣是注入function type 不過跟第一種不一樣的地方在於我們這一次是在functnio 的參數注入。

一樣定義一個 function type

1
type DownloadFunc func(url string) string

這一次我們不在 Struct 裡面接收這個 function ,改用在functnion 裡面接收這個function ,例如說我們會定義一個 function 他要求攜帶一個 function 進來。

1
2
3
func (d *Downloader) DownloadWithFunc(url string, downloadFunc DownloadFunc) string {

}

這時候我們一樣用 IDE 幫我們撰寫 test code

長出來的code 大概是會如下方圖片所示,一樣要求你加入一些test case 。

這時候我們先來修正 package ,比如說我希望外面function 處理完成後之後我要加入一點東西,範例如下所示。

1
2
3
func (d *Downloader) DownloadWithFunc(url string, downloadFunc DownloadFunc) string {
	return downloadFunc(url)+"WithFunc"
}

改完了,那 test code 要怎麼寫?
這時候我們跟 方法一 一樣依外部的function ,所以我們一樣要訂 fakeDownloadFunc。
大致上的test code會像是下面範例的樣子。

 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
func TestDownloader_Download(t *testing.T) {
	type args struct {
		url string
	}
	fakeWant := "this is fake function"
	tests := []struct {
		name string
		args args
		want string
	}{
		{
			name: "Fake Download Func",
			args: args{
				"google.com",
			},
			want: fakeWant,
		},
	}
	fakeDownloadFunc := func(url string) string {
		return fakeWant
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {

			d := NewDownloader(fakeDownloadFunc)
			if got := d.Download(tt.args.url); got != tt.want {
				t.Errorf("Download() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestDownloader_DownloadWithFunc(t *testing.T) {
	type args struct {
		url          string
		downloadFunc DownloadFunc
	}
	fakeWant := "this is fake function"
	fakeDownloadFunc := func(url string) string {
		return fakeWant
	}
	tests := []struct {
		name string
		args args
		want string
	}{
		{
			name: "Fake Download Func",

			args: args{
				downloadFunc: fakeDownloadFunc,
				url:          "google.com",
			},
			want: fakeWant + "WithFunc",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			d := &Downloader{}
			if got := d.DownloadWithFunc(tt.args.url, tt.args.downloadFunc); got != tt.want {
				t.Errorf("DownloadWithFunc() = %v, want %v", got, tt.want)
			}
		})
	}
}

Meng Ze Li
Meng Ze Li
Kubernetes / DevOps / Backend