如何有效地測(cè)試Go代碼
單元測(cè)試
如果把開發(fā)程序比作蓋房子,那么我們必須確保所有的用料都是合格的,否則蓋起來的房子就會(huì)存在問題。對(duì)于程序而言,我們可以將蓋房子的磚頭、鋼筋、水泥等當(dāng)做一個(gè)個(gè)功能單元,如果每個(gè)單元是合格的,我們將有信心認(rèn)為程序是健壯的。單元測(cè)試(Unit Test,UT)就是檢驗(yàn)功能單元是否合格的工具。
一個(gè)沒有UT的項(xiàng)目,它的代碼質(zhì)量與工程保證是堪憂的。但在實(shí)際開發(fā)工作中,很多程序員往往并不寫測(cè)試代碼,他們的開發(fā)周期可能如下圖所示。

而做了充分UT的程序員,他們的項(xiàng)目開發(fā)周期更大概率如下。

項(xiàng)目開發(fā)中,不寫UT也許能使代碼交付更快,但是我們無法保證寫出來的代碼真的能夠正確地執(zhí)行。寫UT可以減少后期解決bug的時(shí)間,也能讓我們放心地使用自己寫出來的代碼。從長(zhǎng)遠(yuǎn)來看,后者更能有效地節(jié)省開發(fā)時(shí)間。
既然UT這么重要,是什么原因在阻止開發(fā)人員寫UT呢?這是因?yàn)槌碎_發(fā)人員的惰性習(xí)慣之外,編寫UT代碼同樣存在難點(diǎn)。
代碼耦合度高,缺少必要的抽象與拆分,以至于不知道如何寫UT。
存在第三方依賴,例如依賴數(shù)據(jù)庫連接、HTTP請(qǐng)求、數(shù)據(jù)緩存等。
可見,編寫可測(cè)試代碼的難點(diǎn)就在于解耦與依賴。
接口與Mock
對(duì)于難點(diǎn)1,我們需要面向接口編程。在《接口Interface——塑造健壯與可擴(kuò)展的Go應(yīng)用程序》一文中,我們討論了使用接口給代碼帶來的靈活解耦與高擴(kuò)展特性。接口是對(duì)一類對(duì)象的抽象性描述,表明該類對(duì)象能提供什么樣的服務(wù),它最主要的作用就是解耦調(diào)用者和實(shí)現(xiàn)者,這成為了可測(cè)試代碼的關(guān)鍵。
對(duì)于難點(diǎn)2,我們可以通過Mock測(cè)試來解決。Mock測(cè)試就是在測(cè)試過程中,對(duì)于某些不容易構(gòu)造或者不容易獲取的對(duì)象,用一個(gè)虛擬的對(duì)象來創(chuàng)建以便測(cè)試的測(cè)試方法。
如果我們的代碼都是面向接口編程,調(diào)用方與服務(wù)方將是松耦合的依賴關(guān)系。在測(cè)試代碼中,我們就可以Mock 出另一種接口的實(shí)現(xiàn),從而很容易地替換掉第三方的依賴。

測(cè)試工具
1. 自帶測(cè)試庫:testing
在介紹Mock測(cè)試之前,先看一下Go中最簡(jiǎn)單的測(cè)試單元應(yīng)該如何寫。假設(shè)我們?cè)?/span>math.go文件下有以下兩個(gè)函數(shù),現(xiàn)在我們需要對(duì)它們寫測(cè)試案例。
1package math
2
3func Add(x, y int) int {
4 return x + y
5}
6
7func Multi(x, y int) int {
8 return x * y
9}
如果我們的IDE是Goland,它有一個(gè)非常好用的一鍵測(cè)試代碼生成功能。

如上圖所示,光標(biāo)置于函數(shù)名之上,右鍵選擇 Generate,我們可以選擇生成整個(gè)package、當(dāng)前file或者當(dāng)前選中函數(shù)的測(cè)試代碼。以 Tests for selection 為例,Goland 會(huì)自動(dòng)在當(dāng)前 math.go 同級(jí)目錄新建測(cè)試文件math_test.go,內(nèi)容如下。
1package math
2
3import "testing"
4
5func TestAdd(t *testing.T) {
6 type args struct {
7 x int
8 y int
9 }
10 tests := []struct {
11 name string
12 args args
13 want int
14 }{
15 // TODO: Add test cases.
16 }
17 for _, tt := range tests {
18 t.Run(tt.name, func(t *testing.T) {
19 if got := Add(tt.args.x, tt.args.y); got != tt.want {
20 t.Errorf("Add() = %v, want %v", got, tt.want)
21 }
22 })
23 }
24}
可以看到,在Go測(cè)試慣例中,單元測(cè)試的默認(rèn)組織方式就是寫在以 _test.go 結(jié)尾的文件中,所有的測(cè)試方法也都是以 Test 開頭并且只接受一個(gè) testing.T 類型的參數(shù)。同時(shí),如果我們要給函數(shù)名為 Add 的方法寫單元測(cè)試,那么對(duì)應(yīng)的測(cè)試方法一般會(huì)被寫成 TestAdd 。
當(dāng)測(cè)試模板生成之后,我們只需將測(cè)試案例添加至 TODO 即可。
1 {
2 "negative + negative",
3 args{-1, -1},
4 -2,
5 },
6 {
7 "negative + positive",
8 args{-1, 1},
9 0,
10 },
11 {
12 "positive + positive",
13 args{1, 1},
14 2,
15 },
此時(shí),運(yùn)行測(cè)試文件,可以發(fā)現(xiàn)所有測(cè)試案例,均成功通過。
1=== RUN TestAdd
2--- PASS: TestAdd (0.00s)
3=== RUN TestAdd/negative_+_negative
4 --- PASS: TestAdd/negative_+_negative (0.00s)
5=== RUN TestAdd/negative_+_positive
6 --- PASS: TestAdd/negative_+_positive (0.00s)
7=== RUN TestAdd/positive_+_positive
8 --- PASS: TestAdd/positive_+_positive (0.00s)
9PASS
2. 斷言庫:testify
簡(jiǎn)單了解了Go內(nèi)置 testing 庫的測(cè)試寫法后,推薦一個(gè)好用的斷言測(cè)試庫:testify。testify具有常見斷言和mock的工具鏈,最重要的是,它能夠與內(nèi)置庫 testing 很好地配合使用,其項(xiàng)目地址位于https://github.com/stretchr/testify。
如果采用testify庫,需要引入"github.com/stretchr/testify/assert"。之外,上述測(cè)試代碼中以下部分
1 if got := Add(tt.args.x, tt.args.y); got != tt.want {
2 t.Errorf("Add() = %v, want %v", got, tt.want)
3 }
更改為如下斷言形式
1assert.Equal(t, Add(tt.args.x, tt.args.y), tt.want, tt.name)
testify 提供的斷言方法幫助我們快速地對(duì)函數(shù)的返回值進(jìn)行測(cè)試,從而減少測(cè)試代碼工作量。它可斷言的類型非常豐富,例如斷言Equal、斷言NIl、斷言Type、斷言兩個(gè)指針是否指向同一對(duì)象、斷言包含、斷言子集等。
不要小瞧這一行代碼,如果我們?cè)跍y(cè)試案例中,將"positive + positive"的期望值改為3,那么測(cè)試結(jié)果中會(huì)自動(dòng)提供報(bào)錯(cuò)信息。
1...
2=== RUN TestAdd/positive_+_positive
3 math_test.go:36:
4 Error Trace: math_test.go:36
5 Error: Not equal:
6 expected: 2
7 actual : 3
8 Test: TestAdd/positive_+_positive
9 Messages: positive + positive
10 --- FAIL: TestAdd/positive_+_positive (0.00s)
11
12
13Expected :2
14Actual :3
15...
3. 接口mock框架:gomock
介紹完基本的測(cè)試方法的寫法后,我們需要討論基于接口的 Mock 方法。在Go語言中,最通用的 Mock 手段是通過Go官方的 gomock 框架來自動(dòng)生成其 Mock 方法。該項(xiàng)目地址位于https://github.com/golang/mock。
為了方便讀者理解,本文舉一個(gè)小明玩手機(jī)的例子。小明喜歡玩手機(jī),他每天都需要通過手機(jī)聊微信、玩王者、逛知乎,如果某天沒有干這些事情,小明就沒辦法睡覺。在該情景中,我們可以將手機(jī)抽象成接口如下。
1// mockDemo/equipment/phone.go
2type Phone interface {
3 WeiXin() bool
4 WangZhe() bool
5 ZhiHu() bool
6}
小明手上有一部非常老的IPhone6s,我們?yōu)樵撌謾C(jī)對(duì)象實(shí)現(xiàn)Phone接口。
1// mockDemo/equipment/phone6s.go
2type Iphone6s struct {
3}
4
5func NewIphone6s() *Iphone6s {
6 return &Iphone6s{}
7}
8
9func (p *Iphone6s) WeiXin() bool {
10 fmt.Println("Iphone6s chat wei xin!")
11 return true
12}
13
14func (p *Iphone6s) WangZhe() bool {
15 fmt.Println("Iphone6s play wang zhe!")
16 return true
17}
18
19func (p *Iphone6s) ZhiHu() bool {
20 fmt.Println("Iphone6s read zhi hu!")
21 return true
22}
接著,我們定義Person對(duì)象用來表示小明,并定義Person對(duì)象的生活函數(shù)dayLife和入睡函數(shù)goSleep。
1// mockDemo/person.go
2type Person struct {
3 name string
4 phone equipment.Phone
5}
6
7func NewPerson(name string, phone equipment.Phone) *Person {
8 return &Person{
9 name: name,
10 phone: phone,
11 }
12}
13
14func (x *Person) goSleep() {
15 fmt.Printf("%s go to sleep!", x.name)
16}
17
18func (x *Person) dayLife() bool {
19 fmt.Printf("%s's daily life:\n", x.name)
20 if x.phone.WeiXin() && x.phone.WangZhe() && x.phone.ZhiHu() {
21 x.goSleep()
22 return true
23 }
24 return false
25}
最后,我們把小明和iphone6s對(duì)象實(shí)例化出來,并開啟他一天的生活。
1//mockDemo/main.go
2func main() {
3 phone := equipment.NewIphone6s()
4 xiaoMing := NewPerson("xiaoMing", phone)
5 xiaoMing.dayLife()
6}
7
8// output
9xiaoMing's daily life:
10Iphone6s chat wei xin!
11Iphone6s play wang zhe!
12Iphone6s read zhi hu!
13xiaoMing go to sleep!
14
由于小明每天必須刷完手機(jī)才能睡覺,即Person.goSleep,那么小明能否睡覺依賴于手機(jī)。

按照當(dāng)前代碼,如果小明的手機(jī)壞了,或者小明換了一個(gè)手機(jī),那他就沒辦法睡覺了,這肯定是萬萬不行的。因此我們需要把小明對(duì)某特定手機(jī)的依賴Mock掉,這個(gè)時(shí)候 gomock 框架排上了用場(chǎng)。
如果沒有下載gomock庫,則執(zhí)行以下命令獲取
1GO111MODULE=on go get github.com/golang/mock/mockgen
通過執(zhí)行以下命令對(duì)phone.go中的Phone接口Mock
1mockgen -destination equipment/mock_iphone.go -package equipment -source equipment/phone.go
在執(zhí)行該命令前,當(dāng)前項(xiàng)目的組織結(jié)構(gòu)如下
1.
2├── equipment
3│ ├── iphone6s.go
4│ └── phone.go
5├── go.mod
6├── go.sum
7├── main.go
8└── person.go
執(zhí)行mockgen命令之后,在equipment/phone.go的同級(jí)目錄,新生成了測(cè)試文件 mock_iphone.go(它的代碼自動(dòng)生成功能,是通過Go自帶generate工具完成的,感興趣的讀者可以閱讀《Go工具之generate》一文),其部分內(nèi)容如下
1...
2// MockPhone is a mock of Phone interface
3type MockPhone struct {
4 ctrl *gomock.Controller
5 recorder *MockPhoneMockRecorder
6}
7
8// MockPhoneMockRecorder is the mock recorder for MockPhone
9type MockPhoneMockRecorder struct {
10 mock *MockPhone
11}
12
13// NewMockPhone creates a new mock instance
14func NewMockPhone(ctrl *gomock.Controller) *MockPhone {
15 mock := &MockPhone{ctrl: ctrl}
16 mock.recorder = &MockPhoneMockRecorder{mock}
17 return mock
18}
19
20// EXPECT returns an object that allows the caller to indicate expected use
21func (m *MockPhone) EXPECT() *MockPhoneMockRecorder {
22 return m.recorder
23}
24
25// WeiXin mocks base method
26func (m *MockPhone) WeiXin() bool {
27 m.ctrl.T.Helper()
28 ret := m.ctrl.Call(m, "WeiXin")
29 ret0, _ := ret[0].(bool)
30 return ret0
31}
32
33// WeiXin indicates an expected call of WeiXin
34func (mr *MockPhoneMockRecorder) WeiXin() *gomock.Call {
35 mr.mock.ctrl.T.Helper()
36 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WeiXin", reflect.TypeOf((*MockPhone)(nil).WeiXin))
37}
38...
此時(shí),我們的person.go中的 Person.dayLife 方法就可以測(cè)試了。
1func TestPerson_dayLife(t *testing.T) {
2 type fields struct {
3 name string
4 phone equipment.Phone
5 }
6
7 // 生成mockPhone對(duì)象
8 mockCtl := gomock.NewController(t)
9 mockPhone := equipment.NewMockPhone(mockCtl)
10 // 設(shè)置mockPhone對(duì)象的接口方法返回值
11 mockPhone.EXPECT().ZhiHu().Return(true)
12 mockPhone.EXPECT().WeiXin().Return(true)
13 mockPhone.EXPECT().WangZhe().Return(true)
14
15 tests := []struct {
16 name string
17 fields fields
18 want bool
19 }{
20 {"case1", fields{"iphone6s", equipment.NewIphone6s()}, true},
21 {"case2", fields{"mocked phone", mockPhone}, true},
22 }
23 for _, tt := range tests {
24 t.Run(tt.name, func(t *testing.T) {
25 x := &Person{
26 name: tt.fields.name,
27 phone: tt.fields.phone,
28 }
29 assert.Equal(t, tt.want, x.dayLife())
30 })
31 }
32}
對(duì)接口進(jìn)行Mock,可以讓我們?cè)谖磳?shí)現(xiàn)具體對(duì)象的接口功能前,或者該接口調(diào)用代價(jià)非常高時(shí),也能對(duì)業(yè)務(wù)代碼進(jìn)行測(cè)試。而且在開發(fā)過程中,我們同樣可以利用Mock對(duì)象,不用因?yàn)榈却涌趯?shí)現(xiàn)方實(shí)現(xiàn)相關(guān)功能,從而停滯后續(xù)的開發(fā)。
在這里我們能夠體會(huì)到在Go程序中接口對(duì)于測(cè)試的重要性。沒有接口的Go代碼,單元測(cè)試會(huì)非常難寫。所以,如果一個(gè)稍大型的項(xiàng)目中,沒有任何接口,那么該項(xiàng)目的質(zhì)量一定是堪憂的。
4. 常見三方mock依賴庫
在上文中提到,因?yàn)榇嬖谀承┐嬖诘谌揭蕾嚕瑫?huì)讓我們的代碼難以測(cè)試。但其實(shí)已經(jīng)有一些比較成熟的mock依賴庫可供我們使用。由于篇幅原因,以下列出的一些mock庫將不再貼出示例代碼,詳細(xì)信息可通過對(duì)應(yīng)的項(xiàng)目地址進(jìn)行了解。
go-sqlmock
這是Go語言中用以測(cè)試數(shù)據(jù)庫交互的SQL模擬驅(qū)動(dòng)庫,其項(xiàng)目地址為 https://github.com/DATA-DOG/go-sqlmock。它而無需真正地?cái)?shù)據(jù)庫連接,就能夠在測(cè)試中模擬sql驅(qū)動(dòng)程序行為,非常有助于維護(hù)測(cè)試驅(qū)動(dòng)開發(fā)(TDD)的工作流程。
httpmock
用于模擬外部資源的http響應(yīng),它使用模式匹配的方式匹配 HTTP 請(qǐng)求的 URL,在匹配到特定的請(qǐng)求時(shí)就會(huì)返回預(yù)先設(shè)置好的響應(yīng)。其項(xiàng)目地址為 https://github.com/jarcoal/httpmock 。
gripmock
它用于模擬gRPC服務(wù)的服務(wù)器,通過使用.proto文件生成對(duì)gRPC服務(wù)的實(shí)現(xiàn),其項(xiàng)目地址為 https://github.com/tokopedia/gripmock。
redismock
用于測(cè)試與Redis服務(wù)器的交互,其項(xiàng)目地址位于 https://github.com/elliotchance/redismock。
5. 猴子補(bǔ)?。簃onkey patch
如果上述的方案都不能很好的寫出測(cè)試代碼,這時(shí)可以考慮使用猴子補(bǔ)丁。猴子補(bǔ)丁簡(jiǎn)單而言就是屬性在運(yùn)行時(shí)的動(dòng)態(tài)替換,它在理論上可以替換運(yùn)行時(shí)中的一切函數(shù)。這種測(cè)試方式在動(dòng)態(tài)語言例如Python中比較合適。在Go中,monkey庫通過在運(yùn)行時(shí)重寫正在運(yùn)行的可執(zhí)行文件并插入跳轉(zhuǎn)到您要調(diào)用的函數(shù)來實(shí)現(xiàn)Monkey patching。項(xiàng)目作者寫道:這個(gè)操作很不安全,不建議任何人在測(cè)試環(huán)境之外進(jìn)行使用。其項(xiàng)目地址為https://github.com/bouk/monkey。
monkey庫的API比較簡(jiǎn)單,例如可以通過調(diào)用 monkey.Patch(<target function>, <replacement function>)來實(shí)現(xiàn)對(duì)函數(shù)的替換,以下是操作示例。
1package main
2
3import (
4 "fmt"
5 "os"
6 "strings"
7
8 "bou.ke/monkey"
9)
10
11func main() {
12 monkey.Patch(fmt.Println, func(a ...interface{}) (n int, err error) {
13 s := make([]interface{}, len(a))
14 for i, v := range a {
15 s[i] = strings.Replace(fmt.Sprint(v), "hell", "*bleep*", -1)
16 }
17 return fmt.Fprintln(os.Stdout, s...)
18 })
19 fmt.Println("what the hell?") // what the *bleep*?
20}
需要注意的是,如果啟用了內(nèi)聯(lián),則monkey有時(shí)無法進(jìn)行patching,因此,我們需要嘗試在禁用內(nèi)聯(lián)的情況下運(yùn)行測(cè)試。例如以上例子,我們需要通過以下命令執(zhí)行。
1$ go build -o main -gcflags=-l main.go;./main
2what the *bleep*?
總結(jié)
在項(xiàng)目開發(fā)中,單元測(cè)試是重要且必須的。對(duì)于單元測(cè)試的兩大難點(diǎn):解耦與依賴,我們的代碼可以采用 面向接口+mock依賴 的方式進(jìn)行組織,將依賴都做成可插拔的,那在單元測(cè)試?yán)锩娓綦x依賴就是一件水到渠成的事情。
另外,本文討論了一些實(shí)用的測(cè)試工具,包括自帶測(cè)試庫testing的快速生成測(cè)試代碼,斷言庫testify的斷言使用,接口mock框架gomock如何mock接口方法和一些常見的三方依賴mock庫推薦,最后再介紹了測(cè)試大殺器猴子補(bǔ)丁,當(dāng)然,不到萬不得已,不要使用猴子補(bǔ)丁。
最后,在這些測(cè)試工具的使用上,本文的內(nèi)容也只是一些淺嘗輒止的介紹,希望讀者能夠在實(shí)際項(xiàng)目中多寫寫單元測(cè)試,深入體會(huì)TDD的開發(fā)思想。
往期推薦
機(jī)器鈴砍菜刀
歡迎添加小菜刀微信
加入Golang分享群學(xué)習(xí)交流!
感謝你的點(diǎn)贊和在看哦~

