Go的單元測試技巧

單元測試(Unit Test)

Go語言原生支持測試工具go test,省去了各類各樣測試框架的學習成本。說來也慚愧,寫代碼這麼些年,也歷來沒有給本身的代碼寫過單元測試,代碼質量的確堪憂。遂花時間學習整理了一下單元測試的基本方法,以及在Go中的實踐技巧。html


單元測試的難點

如下是我在嘗試進行單元測試的過程當中遇到的一些難點,在下文中會介紹相應的一些應對方案。程序員

1.掌握單元測試粒度

單元測試粒度是讓人十分頭疼的問題,特別是對於初嘗單元測試的程序員(好比我)。測試粒度作的太細,會耗費大量的開發以及維護時間,每改一個方法,都要改動其對應的測試方法。當發生代碼重構的時候那簡直就是噩夢(由於你全部的單元測試又都要寫一遍了…)。 如單元測試粒度太粗,一個測試方法測試了n多方法,那麼單元測試將顯的很是臃腫,脫離了單元測試的本意,容易把單元測試寫成__集成測試__。算法

2. 破除外部依賴(mock,stub 技術)

單元測試中是不容許有任何外部依賴的,也就是說這些外部依賴都須要被模擬(mock)。外部依賴越多,mock越複雜。如何用模擬的依賴來測試真實依賴的行爲?mock寫的太簡單,達不到測試的目的。mock太複雜, 不只成本增長,並且又如何確保mock的正確性呢?shell

有的時候模擬是有效的方便的。可是其餘一些時候,過多的模擬對象,Stub對象,假對象,致使單元測試主要在測模擬對象而不是實際的系統。數據庫

Costs and Benefits

在受益於單元測試的好處的同時,也必然增長了代碼量以及維護成本(單元測試代碼也是要維護的)。下面這張成本/價值象限圖很清晰的闡述了在不一樣性質的系統中單元測試__成本__和__價值__之間的關係。api

1.依賴不多的簡單的代碼(左下)緩存

對於外部依賴少,代碼又簡單的代碼。天然其成本和價值都是比較低的。舉Go官方庫裏errors包爲例,整個包就兩個方法New()Error(),沒有任何外部依賴,代碼也很簡單,因此其單元測試起來也是至關方便。服務器

2. 依賴較多可是很簡單的代碼(右下)

依賴一多,mock和stub就必然增多,單元測試的成本也就隨之增長。但代碼又如此簡單(好比上述errors包的例子),這個時候寫單元測試的成本已經大於其價值,還不如不寫單元測試網絡

3. 依賴不多的複雜代碼 (左上)

像這一類代碼,是最有價值寫單元測試的。好比一些獨立的複雜算法(銀行利息計算,保險費率計算,TCP協議解析等),像這一類代碼外部依賴不多,但卻很容易出錯,若是沒有單元測試,幾乎不能保證代碼質量。app

4.依賴不少又很複雜(右上)

這種代碼顯然是單元測試的噩夢。寫單元測試吧,代價高昂;不寫單元測試吧,風險過高。像這種代碼咱們儘可能在設計上將其分爲兩部分:1.處理複雜的邏輯部分 2.處理依賴部分
而後1部分進行單元測試

原文參考:http://blog.stevensanderson.com/2009/11/04/selective-unit-testing-costs-and-benefits/


邁出單元測試第一步

1. 識別依賴,抽象成接口

識別系統中的外部依賴,廣泛來講,咱們遇到最多見的依賴無非下面幾種:

  1. 網絡依賴——函數執行依賴於網絡請求,好比第三方http-api,rpc服務,消息隊列等等

  2. 數據庫依賴

  3. I/O依賴(文件)

固然,還有多是依賴還未開發完成的功能模塊。可是處理方法都是大同小異的——抽象成接口,經過mock和stub進行模擬測試。

2. 明確須要測什麼

當咱們開始敲產品代碼的時候,咱們必然已通過初步的設計,已經瞭解系統中的外部依賴以及業務複雜的部分,這些部分是要優先考慮寫單元測試的。在寫每個方法/結構體的時候同時思考這個方法/結構體需不須要測試?如何測試?對於什麼樣的方法/結構體須要測試,什麼樣的能夠不作,除了能夠從上面的成本/價值象限圖中得到答案外,還能夠參考如下關於單元測試粒度要作多細問題的回答:

老闆爲個人代碼付報酬,而不是測試,因此,我對此的價值觀是——測試越少越好,少到你對你的代碼質量達到了某種自信(我以爲這種的自信標準應該要高於業內的標準,固然,這種自信也多是種自大)。若是個人編碼生涯中不會犯這種典型的錯誤(如:在構造函數中設了個錯誤的值),那我就不會測試它。我傾向於去對那些有意義的錯誤作測試,因此,我對一些比較複雜的條件邏輯會異常地當心。當在一個團隊中,我會很是當心的測試那些會讓團隊容易出錯的代碼。
https://coolshell.cn/articles/8209.html


Mock和Stub怎麼作

Mock(模擬)和Stub(樁)是在測試過程當中,模擬外部依賴行爲的兩種經常使用的技術手段。
經過Mock和Stub咱們不只可讓測試環境沒有外部依賴,並且還能夠模擬一些異常行爲,如數據庫服務不可用,沒有文件的訪問權限等等。


Mock和Stub的區別

在Go語言中,能夠這樣描述Mock和Stub:

  • Mock:在測試包中建立一個結構體,知足某個外部依賴的接口interface{}

  • Stub:在測試包中建立一個模擬方法,用於替換生成代碼中的方法

仍是有點抽象,下面舉例說明。


Mock示例

Mock:在測試包中建立一個結構體,知足某個外部依賴的接口interface{}

生產代碼:


1//auth.go
2//假設咱們有一個依賴http請求的鑑權接口
3type AuthService interface{
4    Login(username string,password string) (token string,e error)
5    Logout(token string) error
6}


mock代碼:


1//auth_test.go
2type authService struct {
3}
4func (auth *authService) Login (username string,password string) (string,error) {
5    return "token"nil
6}
7func (auth *authService) Logout(token string) error{
8    return nil
9}


在這裏咱們用authService實現了AuthService接口,這樣測試Login,Logout就再也不需須要依賴網絡請求了。並且咱們也能夠模擬一些錯誤的狀況進行測試:


 1//auth_test.go
2//模擬登陸失敗
3type authLoginErr struct {
4    auth AuthService  //可使用組合的特性,Logout方法咱們不關心,只用「覆蓋」Login方法便可
5}
6func (auth *authLoginErr) Login (username string,password string) (string,error) {
7    return "", errors.New("用戶名密碼錯誤")
8}
9
10//模擬api服務器宕機
11type authUnavailableErr struct {
12}
13func (auth *authLoginErr) Login (username string,password string) (string,error) {
14    return "", errors.New("api服務不可用")
15}
16func (auth *authLoginErr) Logout(token string) error{
17    return errors.New("api服務不可用")
18}


Stub示例

Stub:在測試包中建立一個模擬方法,用於替換生成代碼中的方法。
這是《Go語言聖經》(11.2.3)當中的一個例子:
生產代碼:


 1//storage.go
2//發送郵件
3var notifyUser = func(username, msg string) { //<--將發送郵件的方法變成一個全局變量
4    auth := smtp.PlainAuth("", sender, password, hostname)
5    err := smtp.SendMail(hostname+":587", auth, sender,
6        []string{username}, []byte(msg))
7    if err != nil {
8        log.Printf("smtp.SendEmail(%s) failed: %s", username, err)
9    }
10}
11//檢查quota,quota不足將發郵件
12func CheckQuota(username string) {
13    used := bytesInUse(username)
14    const quota = 1000000000 // 1GB
15    percent := 100 * used / quota
16    if percent < 90 {
17        return // OK
18    }
19    msg := fmt.Sprintf(template, used, percent)
20    notifyUser(username, msg) //<---發郵件
21}


顯然,在跑單元測試的過程當中,咱們確定不會真的給用戶發郵件。在書中採用了stub的方式來進行測試:


 1//storage_test.go
2func TestCheckQuotaNotifiesUser(t *testing.T) {
3    var notifiedUser, notifiedMsg string
4    notifyUser = func(user, msg string) {  //<-看這裏就夠了,在測試中,覆蓋了發送郵件的全局變量
5        notifiedUser, notifiedMsg = user, msg
6    }
7
8    // ...simulate a 980MB-used condition...
9
10    const user = "joe@example.org"
11    CheckQuota(user)
12    if notifiedUser == "" && notifiedMsg == "" {
13        t.Fatalf("notifyUser not called")
14    }
15    if notifiedUser != user {
16        t.Errorf("wrong user (%s) notified, want %s",
17            notifiedUser, user)
18    }
19    const wantSubstring = "98% of your quota"
20    if !strings.Contains(notifiedMsg, wantSubstring) {
21        t.Errorf("unexpected notification message <<%s>>, "+
22            "want substring %q", notifiedMsg, wantSubstring)
23    }
24}


能夠看到,在Go中,若是要用stub,那將是侵入式的,必須將生產代碼設計成能夠用stub方法替換的形式。上述例子體現出來的結果就是:爲了測試,專門用一個全局變量notifyUser來保存了具備外部依賴的方法。然而在不提倡使用全局變量的Go語言當中,這顯然是不合適的。因此,並不提倡這種Stub方式。


Mock與Stub相結合

既然不提倡Stub方式,那是否是在Go測試當中就能夠拋棄Stub了呢?本來我是這麼認爲的,但直到我讀了這篇譯文Golang 標準包佈局[譯],雖然這篇譯文講的是包的佈局,但裏面的測試示例很值得學習。


 1//生產代碼 myapp.go
2package myapp
3
4type User struct {
5    ID      int
6    Name    string
7    Address Address
8}
9//User的一些增刪改查
10type UserService interface {
11    User(id int) (*User, error)
12    Users() ([]*User, error)
13    CreateUser(u *User) error
14    DeleteUser(id int) error
15}


常規Mock方式:


 1//測試代碼 myapp_test.go
2type userService struct{
3}
4func (u* userService) User(id int) (*User,error) {
5    return &User{Id:1,Name:"name",Address:"address"},nil
6}
7//..省略其餘實現方法
8
9//模擬user不存在
10type userNotFound struct {
11    u UserService
12}
13func (u* userNotFound) User(id int) (*User,error) {
14    return nil,errors.New("not found")
15}
16
17//其餘...


通常來講,mock結構體內部不多會放變量,針對每個要模擬的場景(好比上面的user不存在),最政治正確的方法應該是新建一個mock結構體。這樣有兩個好處:

  1. mock出來的結構體十分簡單,不須要進行額外的設置,不容易出錯。

  2. mock出來的結構體職責單一,測試代碼自說明能力更強,可讀性更高。

但在剛纔提到的文章中,他是這麼作的:


 1//測試代碼
2// UserService 表明一個myapp.UserService.的 mock實現 
3type UserService struct {
4    UserFn      func(id int) (*myapp.User, error)
5    UserInvoked bool
6
7    UsersFn     func() ([]*myapp.User, error)
8    UsersInvoked bool
9
10    // 其餘接口方法補全..
11}
12
13// User調用mock實現, 並標記這個方法爲已調用
14func (s *UserService) User(id int) (*myapp.User, error)
 {
15    s.UserInvoked = true
16    return s.UserFn(id)
17}


這裏不只實現了接口,還經過在結構體內放置與接口方法函數簽名一致的方法(UserFn UsersFn ...),以及XxxInvoked是否調用標識符來追蹤方法的調用狀況。這種作法其實將mock與stub相結合了起來:在mock對象的內部放置了能夠被測試函數替換的函數變量UserFn UsersFn…)。咱們能夠在咱們的測試函數中,根據測試的須要,手動更換函數實現:


 1//mock與stub結合的方式
2func TestUserNotFound(t *testing.T) {
3    userNotFound := &UserService{}
4    userNotFound.UserFn = func(id int) (*myapp.User, error) { //<---本身實現UserFn的實現
5        return nil,errors.New("not found")
6    }
7    //後續業務測試代碼...
8
9    if !userNotFound.UserInvoked {
10        t.Fatal("沒有調用User()方法")
11    }
12}


1//傳統的mock方式
2func TestUserNotFound(t *testing.T) {
3    userNotFound := &userNotFound{} //<---結構體方法已經決定了返回值
4    //後續業務測試代碼
5}


經過將mock與stub結合,不只能在測試方法中動態的更改實現,還追蹤方法的調用狀況,上述例子中只是追蹤了方法是否被調用,實際中,若是有須要,咱們也能夠追蹤方法的調用次數,甚至是方法的調用順序:


 1type UserService struct {
2    UserFn      func(id int) (*myapp.User, error)
3    UserInvoked bool
4    UserInvokedTime int //<--追蹤調用次數
5
6
7    UsersFn     func() ([]*myapp.User, error)
8    UsersInvoked bool
9
10    // 其餘接口方法補全..
11
12    FnCallStack []string //<---函數名slice,追蹤調用順序
13}
14
15// User調用mock實現, 並標記這個方法爲已調用
16func (s *UserService) User(id int) (*myapp.User, error)
 {
17    s.UserInvoked = true
18    s.UserInvokedTime++ //<--調用發次數
19    s.FnCallStack = append(s.FnCallStack,"User"//調用順序
20    return s.UserFn(id)
21}


但同時,咱們也會發現咱們的mock結構體更復雜了,維護成本也隨之增長了。兩種mock風格各有各的好處,反正要記得軟件工程沒有銀彈,合適的場景選用合適的方法就好了。
但整體而言,mock與stub相結合的這種方式的確是一種不錯的測試思路,尤爲是當咱們須要追蹤函數是否調用,調用次數,調用順序等信息時,mock+stub將是咱們的不二選擇。舉個例子:


 1//緩存依賴
2type Cache interface{
3    Get(id intinterface{} //獲取某id的緩存 
4    Put(id int,obj interface{}) //放入緩存
5}
6
7//數據庫依賴
8type UserRepository interface{
9    //....
10}
11//User結構體
12type User struct {
13    //...
14}
15//userservice
16type UserService interface{
17    cache Cache 
18    repository UserRepository
19}
20
21func (u *UserService) Get(id int) *User {
22    //先從緩存找,緩存找不到在去repository裏面找
23}
24
25func main() {   
26    userService := NewUserService(xxx) //注入一些外部依賴
27    user := userService.Get(2//獲取id = 2的user
28}


如今要測試userService.Get(id)方法的行爲:

  1. Cache命中以後是否還查數據庫?(不該該再查了)

  2. Cache未命中的狀況下是否會查庫?

這種測試經過mock+stub結合作起來將會很是方便,做爲小練習,能夠嘗試本身實現一下。

相關文章
相關標籤/搜索