單元測試
如果把開發(fā)程序比作蓋房子泻拦,那么我們必須確保所有的用料都是合格的,否則蓋起來的房子就會存在問題。對于程序而言窒朋,我們可以將蓋房子的磚頭搀罢、鋼筋、水泥等當做一個個功能單元侥猩,如果每個單元是合格的榔至,我們將有信心認為程序是健壯的。單元測試(Unit Test,UT)就是檢驗功能單元是否合格的工具欺劳。
一個沒有UT的項目唧取,它的代碼質(zhì)量與工程保證是堪憂的。但在實際開發(fā)工作中划提,很多程序員往往并不寫測試代碼枫弟,他們的開發(fā)周期可能如下圖所示。
而做了充分UT的程序員鹏往,他們的項目開發(fā)周期更大概率如下淡诗。
項目開發(fā)中,不寫UT也許能使代碼交付更快伊履,但是我們無法保證寫出來的代碼真的能夠正確地執(zhí)行韩容。寫UT可以減少后期解決bug的時間,也能讓我們放心地使用自己寫出來的代碼唐瀑。從長遠來看宙攻,后者更能有效地節(jié)省開發(fā)時間。
既然UT這么重要介褥,是什么原因在阻止開發(fā)人員寫UT呢座掘?這是因為除了開發(fā)人員的惰性習慣之外,編寫UT代碼同樣存在難點柔滔。
代碼耦合度高溢陪,缺少必要的抽象與拆分,以至于不知道如何寫UT睛廊。
存在第三方依賴形真,例如依賴數(shù)據(jù)庫連接、HTTP請求超全、數(shù)據(jù)緩存等咆霜。
可見,編寫可測試代碼的難點就在于解耦與依賴嘶朱。
接口與Mock
對于難點1蛾坯,我們需要面向接口編程。在《接口Interface——塑造健壯與可擴展的Go應用程序》一文中疏遏,我們討論了使用接口給代碼帶來的靈活解耦與高擴展特性脉课。接口是對一類對象的抽象性描述救军,表明該類對象能提供什么樣的服務,它最主要的作用就是解耦調(diào)用者和實現(xiàn)者倘零,這成為了可測試代碼的關鍵唱遭。
對于難點2,我們可以通過Mock測試來解決呈驶。Mock測試就是在測試過程中拷泽,對于某些不容易構(gòu)造或者不容易獲取的對象,用一個虛擬的對象來創(chuàng)建以便測試的測試方法袖瞻。
如果我們的代碼都是面向接口編程跌穗,調(diào)用方與服務方將是松耦合的依賴關系。在測試代碼中虏辫,我們就可以Mock 出另一種接口的實現(xiàn)蚌吸,從而很容易地替換掉第三方的依賴。
測試工具
1. 自帶測試庫:testing
在介紹Mock測試之前砌庄,先看一下Go中最簡單的測試單元應該如何寫羹唠。假設我們在math.go
文件下有以下兩個函數(shù),現(xiàn)在我們需要對它們寫測試案例娄昆。
// math.go
package math
func Add(x, y int) int {
return x + y
}
func Multi(x, y int) int {
return x * y
}
如果我們的IDE是Goland佩微,它有一個非常好用的一鍵測試代碼生成功能。
如上圖所示萌焰,光標置于函數(shù)名之上哺眯,右鍵選擇 Generate,我們可以選擇生成整個package扒俯、當前file或者當前選中函數(shù)的測試代碼奶卓。以 Tests for selection 為例,Goland 會自動在當前 math.go
同級目錄新建測試文件math_test.go
撼玄,內(nèi)容如下夺姑。
// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
type args struct {
x int
y int
}
tests := []struct {
name string
args args
want int
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.args.x, tt.args.y); got != tt.want {
t.Errorf("Add() = %v, want %v", got, tt.want)
}
})
}
}
可以看到,在Go測試慣例中掌猛,單元測試的默認組織方式就是寫在以 _test.go
結(jié)尾的文件中盏浙,所有的測試方法也都是以 Test
開頭并且只接受一個 testing.T
類型的參數(shù)。同時荔茬,如果我們要給函數(shù)名為 Add
的方法寫單元測試废膘,那么對應的測試方法一般會被寫成 TestAdd
。
當測試模板生成之后慕蔚,我們只需將測試案例添加至 TODO
即可丐黄。
{
"negative + negative",
args{-1, -1},
-2,
},
{
"negative + positive",
args{-1, 1},
0,
},
{
"positive + positive",
args{1, 1},
2,
},
此時,運行測試文件坊萝,可以發(fā)現(xiàn)所有測試案例孵稽,均成功通過许起。
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestAdd/negative_+_negative
--- PASS: TestAdd/negative_+_negative (0.00s)
=== RUN TestAdd/negative_+_positive
--- PASS: TestAdd/negative_+_positive (0.00s)
=== RUN TestAdd/positive_+_positive
--- PASS: TestAdd/positive_+_positive (0.00s)
PASS
2. 斷言庫:testify
簡單了解了Go內(nèi)置 testing 庫的測試寫法后十偶,推薦一個好用的斷言測試庫:testify菩鲜。testify具有常見斷言和mock的工具鏈,最重要的是惦积,它能夠與內(nèi)置庫 testing 很好地配合使用接校,其項目地址位于https://github.com/stretchr/testify。
如果采用testify庫狮崩,需要引入"github.com/stretchr/testify/assert"
蛛勉。之外,上述測試代碼中以下部分
if got := Add(tt.args.x, tt.args.y); got != tt.want {
t.Errorf("Add() = %v, want %v", got, tt.want)
}
更改為如下斷言形式
assert.Equal(t, Add(tt.args.x, tt.args.y), tt.want, tt.name)
testify 提供的斷言方法幫助我們快速地對函數(shù)的返回值進行測試睦柴,從而減少測試代碼工作量诽凌。它可斷言的類型非常豐富,例如斷言Equal坦敌、斷言NIl侣诵、斷言Type、斷言兩個指針是否指向同一對象狱窘、斷言包含杜顺、斷言子集等。
不要小瞧這一行代碼蘸炸,如果我們在測試案例中躬络,將"positive + positive"的期望值改為3,那么測試結(jié)果中會自動提供報錯信息搭儒。
...
=== RUN TestAdd/positive_+_positive
math_test.go:36:
Error Trace: math_test.go:36
Error: Not equal:
expected: 2
actual : 3
Test: TestAdd/positive_+_positive
Messages: positive + positive
--- FAIL: TestAdd/positive_+_positive (0.00s)
Expected :2
Actual :3
...
3. 接口mock框架:gomock
介紹完基本的測試方法的寫法后穷当,我們需要討論基于接口的 Mock 方法。在Go語言中淹禾,最通用的 Mock 手段是通過Go官方的 gomock 框架來自動生成其 Mock 方法膘滨。該項目地址位于https://github.com/golang/mock。
為了方便讀者理解稀拐,本文舉一個小明玩手機的例子火邓。小明喜歡玩手機,他每天都需要通過手機聊微信德撬、玩王者铲咨、逛知乎,如果某天沒有干這些事情蜓洪,小明就沒辦法睡覺纤勒。在該情景中,我們可以將手機抽象成接口如下隆檀。
// mockDemo/equipment/phone.go
type Phone interface {
WeiXin() bool
WangZhe() bool
ZhiHu() bool
}
小明手上有一部非常老的IPhone6s摇天,我們?yōu)樵撌謾C對象實現(xiàn)Phone接口粹湃。
// mockDemo/equipment/phone6s.go
type Iphone6s struct {
}
func NewIphone6s() *Iphone6s {
return &Iphone6s{}
}
func (p *Iphone6s) WeiXin() bool {
fmt.Println("Iphone6s chat wei xin!")
return true
}
func (p *Iphone6s) WangZhe() bool {
fmt.Println("Iphone6s play wang zhe!")
return true
}
func (p *Iphone6s) ZhiHu() bool {
fmt.Println("Iphone6s read zhi hu!")
return true
}
接著,我們定義Person
對象用來表示小明泉坐,并定義Person
對象的生活函數(shù)dayLife
和入睡函數(shù)goSleep
为鳄。
// mockDemo/person.go
type Person struct {
name string
phone equipment.Phone
}
func NewPerson(name string, phone equipment.Phone) *Person {
return &Person{
name: name,
phone: phone,
}
}
func (x *Person) goSleep() {
fmt.Printf("%s go to sleep!", x.name)
}
func (x *Person) dayLife() bool {
fmt.Printf("%s's daily life:\n", x.name)
if x.phone.WeiXin() && x.phone.WangZhe() && x.phone.ZhiHu() {
x.goSleep()
return true
}
return false
}
最后,我們把小明和iphone6s對象實例化出來腕让,并開啟他一天的生活孤钦。
//mockDemo/main.go
func main() {
phone := equipment.NewIphone6s()
xiaoMing := NewPerson("xiaoMing", phone)
xiaoMing.dayLife()
}
// output
xiaoMing's daily life:
Iphone6s chat wei xin!
Iphone6s play wang zhe!
Iphone6s read zhi hu!
xiaoMing go to sleep!
由于小明每天必須刷完手機才能睡覺,即Person.goSleep
纯丸,那么小明能否睡覺依賴于手機偏形。
按照當前代碼,如果小明的手機壞了觉鼻,或者小明換了一個手機俊扭,那他就沒辦法睡覺了,這肯定是萬萬不行的坠陈。因此我們需要把小明對某特定手機的依賴Mock掉萨惑,這個時候 gomock 框架排上了用場。
如果沒有下載gomock庫畅姊,則執(zhí)行以下命令獲取
GO111MODULE=on go get github.com/golang/mock/mockgen
通過執(zhí)行以下命令對phone.go中的Phone接口Mock
mockgen -destination equipment/mock_iphone.go -package equipment -source equipment/phone.go
在執(zhí)行該命令前咒钟,當前項目的組織結(jié)構(gòu)如下
.
├── equipment
│ ├── iphone6s.go
│ └── phone.go
├── go.mod
├── go.sum
├── main.go
└── person.go
執(zhí)行mockgen
命令之后,在equipment/phone.go
的同級目錄若未,新生成了測試文件 mock_iphone.go
(它的代碼自動生成功能朱嘴,是通過Go自帶generate工具完成的,感興趣的讀者可以閱讀《Go工具之generate》一文)粗合,其部分內(nèi)容如下
...
// MockPhone is a mock of Phone interface
type MockPhone struct {
ctrl *gomock.Controller
recorder *MockPhoneMockRecorder
}
// MockPhoneMockRecorder is the mock recorder for MockPhone
type MockPhoneMockRecorder struct {
mock *MockPhone
}
// NewMockPhone creates a new mock instance
func NewMockPhone(ctrl *gomock.Controller) *MockPhone {
mock := &MockPhone{ctrl: ctrl}
mock.recorder = &MockPhoneMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockPhone) EXPECT() *MockPhoneMockRecorder {
return m.recorder
}
// WeiXin mocks base method
func (m *MockPhone) WeiXin() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WeiXin")
ret0, _ := ret[0].(bool)
return ret0
}
// WeiXin indicates an expected call of WeiXin
func (mr *MockPhoneMockRecorder) WeiXin() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WeiXin", reflect.TypeOf((*MockPhone)(nil).WeiXin))
}
...
此時萍嬉,我們的person.go
中的 Person.dayLife
方法就可以測試了。
func TestPerson_dayLife(t *testing.T) {
type fields struct {
name string
phone equipment.Phone
}
// 生成mockPhone對象
mockCtl := gomock.NewController(t)
mockPhone := equipment.NewMockPhone(mockCtl)
// 設置mockPhone對象的接口方法返回值
mockPhone.EXPECT().ZhiHu().Return(true)
mockPhone.EXPECT().WeiXin().Return(true)
mockPhone.EXPECT().WangZhe().Return(true)
tests := []struct {
name string
fields fields
want bool
}{
{"case1", fields{"iphone6s", equipment.NewIphone6s()}, true},
{"case2", fields{"mocked phone", mockPhone}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
x := &Person{
name: tt.fields.name,
phone: tt.fields.phone,
}
assert.Equal(t, tt.want, x.dayLife())
})
}
}
對接口進行Mock隙疚,可以讓我們在未實現(xiàn)具體對象的接口功能前壤追,或者該接口的調(diào)用代價非常高時,也能對業(yè)務代碼進行測試供屉。而且在開發(fā)過程中行冰,我們同樣可以利用Mock對象,不用因為等待接口實現(xiàn)方實現(xiàn)相關功能伶丐,從而停滯后續(xù)的開發(fā)悼做。
在這里我們能夠體會到在Go程序中接口對于測試的重要性。沒有接口的Go代碼哗魂,單元測試會非常難寫肛走。所以,如果一個稍大型的項目中录别,沒有任何接口朽色,那么該項目的質(zhì)量一定是堪憂的邻吞。
4. 常見三方mock依賴庫
在上文中提到,因為存在某些存在第三方依賴葫男,會讓我們的代碼難以測試抱冷。但其實已經(jīng)有一些比較成熟的mock依賴庫可供我們使用。由于篇幅原因腾誉,以下列出的一些mock庫將不再貼出示例代碼徘层,詳細信息可通過對應的項目地址進行了解峻呕。
- go-sqlmock
這是Go語言中用以測試數(shù)據(jù)庫交互的SQL模擬驅(qū)動庫利职,其項目地址為 https://github.com/DATA-DOG/go-sqlmock。它而無需真正地數(shù)據(jù)庫連接瘦癌,就能夠在測試中模擬sql驅(qū)動程序行為猪贪,非常有助于維護測試驅(qū)動開發(fā)(TDD)的工作流程。
- httpmock
用于模擬外部資源的http響應讯私,它使用模式匹配的方式匹配 HTTP 請求的 URL热押,在匹配到特定的請求時就會返回預先設置好的響應。其項目地址為 https://github.com/jarcoal/httpmock 斤寇。
- gripmock
它用于模擬gRPC服務的服務器桶癣,通過使用.proto文件生成對gRPC服務的實現(xiàn),其項目地址為 https://github.com/tokopedia/gripmock娘锁。
- redismock
用于測試與Redis服務器的交互牙寞,其項目地址位于 https://github.com/elliotchance/redismock。
5. 猴子補赌选:monkey patch
如果上述的方案都不能很好的寫出測試代碼间雀,這時可以考慮使用猴子補丁。猴子補丁簡單而言就是屬性在運行時的動態(tài)替換镊屎,它在理論上可以替換運行時中的一切函數(shù)惹挟。這種測試方式在動態(tài)語言例如Python中比較合適。在Go中缝驳,monkey庫通過在運行時重寫正在運行的可執(zhí)行文件并插入跳轉(zhuǎn)到您要調(diào)用的函數(shù)來實現(xiàn)Monkey patching连锯。項目作者寫道:這個操作很不安全,不建議任何人在測試環(huán)境之外進行使用用狱。其項目地址為https://github.com/bouk/monkey运怖。
monkey庫的API比較簡單,例如可以通過調(diào)用 monkey.Patch(<target function>, <replacement function>)
來實現(xiàn)對函數(shù)的替換齿拂,以下是操作示例驳规。
package main
import (
"fmt"
"os"
"strings"
"bou.ke/monkey"
)
func main() {
monkey.Patch(fmt.Println, func(a ...interface{}) (n int, err error) {
s := make([]interface{}, len(a))
for i, v := range a {
s[i] = strings.Replace(fmt.Sprint(v), "hell", "*bleep*", -1)
}
return fmt.Fprintln(os.Stdout, s...)
})
fmt.Println("what the hell?") // what the *bleep*?
}
需要注意的是,如果啟用了內(nèi)聯(lián)署海,則monkey有時無法進行patching吗购,因此医男,我們需要嘗試在禁用內(nèi)聯(lián)的情況下運行測試。例如以上例子捻勉,我們需要通過以下命令執(zhí)行镀梭。
$ go build -o main -gcflags=-l main.go;./main
what the *bleep*?
總結(jié)
在項目開發(fā)中,單元測試是重要且必須的踱启。對于單元測試的兩大難點:解耦與依賴报账,我們的代碼可以采用 **面向接口+mock依賴 **的方式進行組織,將依賴都做成可插拔的埠偿,那在單元測試里面隔離依賴就是一件水到渠成的事情透罢。
另外,本文討論了一些實用的測試工具冠蒋,包括自帶測試庫testing的快速生成測試代碼羽圃,斷言庫testify的斷言使用,接口mock框架gomock如何mock接口方法和一些常見的三方依賴mock庫推薦抖剿,最后再介紹了測試大殺器猴子補丁朽寞,當然,不到萬不得已斩郎,不要使用猴子補丁脑融。
最后,在這些測試工具的使用上缩宜,本文的內(nèi)容也只是一些淺嘗輒止的介紹肘迎,希望讀者能夠在實際項目中多寫寫單元測試,深入體會TDD的開發(fā)思想脓恕。