搞定Go單元測試(一)——基礎原理

作者:水立方 链接:https://juejin.cn/post/6844903853528186894 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

單元測試是代碼質量的保證。本系列文章將一步步由淺入深展示如何在Go中做單元測試。 Go對單元測試的支持相當友好,標準包中就支持單元測試,在開始本系閱讀之前,需要對標準測試包的基本用法有所了解。 現在,我們從單元測試的基本思想和原理入手,一起來看看如何基於Go提供的標準測試包來進行單元測試。

單元測試的難點

1.掌握單元測試粒度

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

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

單元測試一般不允許有任何外部依賴(文件依賴,網絡依賴,數據庫依賴等),我們不會在測試代碼中去連接數據庫,調用api等。這些外部依賴在執行測試的時候需要被模擬(mock/stub)。在測試的時候,我們使用模擬的對象來模擬真實依賴下的各種行為。如何運用mock/stub來模擬系統真實行為算是單元測試道路上的一隻攔路虎。別著急,本文會通過示例來展示如何在Go中使用mock/stub來完成單元測試。

有的時候模擬是有效的方便的。但我們要提防過度的mock/stub,因為其會導致單元測試主要在測模擬對象而不是實際的系統。


Costs and Benefits

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

1.依賴很少的簡單的代碼(左下)

對於外部依賴少,代碼又簡單的代碼。自然其成本和價值都是比較低的。舉Go官方庫裡errors包為例,整個包就兩個方法New()Error(),沒有任何外部依賴,代碼也很簡單,所以其單元測試起來也是相當方便。

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

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

3. 依賴很少的複雜代碼 (左上)

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

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

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

原文參考:blog.stevensanderson.com/2009/11/04/…


邁出單元測試第一步

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{} 生產代碼:

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

mock 代碼:

//auth_test.go
type authService struct {}
func (auth *authService) Login (username string,password string) (string,error){
    return "token", nil
}
func (auth *authService) Logout(token string) error{    
    return nil
}

在這裡我們用 authService實現了 AuthService接口,這樣測試 Login,Logout就不再需需要依賴網絡請求了。而且我們也可以模擬一些錯誤的情況進行測試:

//auth_test.go
//模擬登錄失敗
type authLoginErr struct {
    auth AuthService  //可以使用組合的特性,Logout方法我們不關心,只用“覆蓋”Login方法即可
}
func (auth *authLoginErr) Login (username string,password string) (string,error) {
    return "", errors.New("用戶名密碼錯誤")
}

//模擬api服務器宕機
type authUnavailableErr struct {
}
func (auth *authUnavailableErr) Login (username string,password string) (string,error) {
    return "", errors.New("api服務不可用")
}
func (auth *authUnavailableErr) Logout(token string) error{
    return errors.New("api服務不可用")
}

Stub示例

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

//storage.go
//發送郵件
var notifyUser = func(username, msg string) { //<--將發送郵件的方法變成一個全局變量
    auth := smtp.PlainAuth("", sender, password, hostname)
    err := smtp.SendMail(hostname+":587", auth, sender,
        []string{username}, []byte(msg))
    if err != nil {
        log.Printf("smtp.SendEmail(%s) failed: %s", username, err)
    }
}
//檢查quota,quota不足將發郵件
func CheckQuota(username string) {
    used := bytesInUse(username)
    const quota = 1000000000 // 1GB
    percent := 100 * used / quota
    if percent < 90 {
        return // OK
    }
    msg := fmt.Sprintf(template, used, percent)
    notifyUser(username, msg) //<---發郵件
}

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

//storage_test.go
func TestCheckQuotaNotifiesUser(t *testing.T) {
    var notifiedUser, notifiedMsg string
    notifyUser = func(user, msg string) {  //<-看這裡就夠了,在測試中,覆蓋了發送郵件的全局變量
        notifiedUser, notifiedMsg = user, msg
    }

    // ...simulate a 980MB-used condition...

    const user = "joe@example.org"
    CheckQuota(user)
    if notifiedUser == "" && notifiedMsg == "" {
        t.Fatalf("notifyUser not called")
    }
    if notifiedUser != user {
        t.Errorf("wrong user (%s) notified, want %s",
            notifiedUser, user)
    }
    const wantSubstring = "98% of your quota"
    if !strings.Contains(notifiedMsg, wantSubstring) {
        t.Errorf("unexpected notification message <<%s>>, "+
            "want substring %q", notifiedMsg, wantSubstring)
    }
}

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

Mock與Stub相結合

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

//生產代碼 myapp.go
package myapp

type User struct {
    ID      int
    Name    string
    Address Address
}
//User的一些增刪改查
type UserService interface {
    User(id int) (*User, error)
    Users() ([]*User, error)
    CreateUser(u *User) error
    DeleteUser(id int) error
}

常規Mock方式:

//測試代碼 myapp_test.go
type userService struct{
}
func (u* userService) User(id int) (*User,error) {
    return &User{Id:1,Name:"name",Address:"address"},nil
}
//..省略其他實現方法

//模擬user不存在
type userNotFound struct {
    u UserService
}
func (u* userNotFound) User(id int) (*User,error) {
    return nil,errors.New("not found")
}

//其他...

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

mock出來的結構體十分簡單,不需要進行額外的設置,不容易出錯。 mock出來的結構體職責單一,測試代碼自說明能力更強,可讀性更高。

但在剛才提到的文章中,他是這麼做的:

//測試代碼
// UserService 代表一個myapp.UserService.的 mock實現 
type UserService struct {
    UserFn      func(id int) (*myapp.User, error)
    UserInvoked bool

    UsersFn     func() ([]*myapp.User, error)
    UsersInvoked bool
    // 其他接口方法補全..
}

// User調用mock實現, 並標記這個方法為已調用
func (s *UserService) User(id int) (*myapp.User, error) {
    s.UserInvoked = true
    return s.UserFn(id)
}

這裡不僅實現了接口,還通過在結構體內放置與接口方法函數簽名一致的方法( UserFnUsersFn...),以及 XxxInvoked是否調用標識符來追踪方法的調用情況。這種做法其實將mock與stub相結合了起來:在mock對象的內部放置了可以被測試函數替換的函數變量UserFn UsersFn...)。我們可以在我們的測試函數中,根據測試的需要,手動更換函數實現。

//mock與stub結合的方式
func TestUserNotFound(t *testing.T) {
    userNotFound := &UserService{}
    userNotFound.UserFn = func(id int) (*myapp.User, error) { //<--- 設置UserFn的期望返回結果
        return nil,errors.New("not found")
    }
    //後續業務測試代碼...

    if !userNotFound.UserInvoked {
        t.Fatal("沒有調用User()方法")
    }
}


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

通過將mock與stub結合,不僅能在測試方法中動態的更改實現,還追踪方法的調用情況,上述例子中只是追踪了方法是否被調用,實際中,如果有需要,我們也可以追踪方法的調用次數,甚至是方法的調用順序:

type UserService struct {
    UserFn      func(id int) (*myapp.User, error)
    UserInvoked bool
    UserInvokedTime int //<--追踪調用次數


    UsersFn     func() ([]*myapp.User, error)
    UsersInvoked bool

    // 其他接口方法補全..
    FnCallStack []string //<---函數名slice,追踪調用順序
}

// User調用mock實現, 並標記這個方法為已調用
func (s *UserService) User(id int) (*myapp.User, error) {
    s.UserInvoked = true
    s.UserInvokedTime++ //<--調用發次數
    s.FnCallStack = append(s.FnCallStack,"User") //調用順序
    return s.UserFn(id)
}

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

//緩存依賴
type Cache interface{
    Get(id int) interface{} //獲取某id的緩存 
    Put(id int,obj interface{}) //放入緩存
}

//數據庫依賴
type UserRepository interface{
    //....
}

//User結構體
type User struct {
    //...
}

//userservice
type UserService interface{
    cache Cache 
    repository UserRepository
}

func (u *UserService) Get(id int) *User {
    //先從緩存找,緩存找不到在去repository裡面找
}

func main() {   
    userService := NewUserService(xxx) //注入一些外部依賴
    user := userService.Get(2) //獲取id = 2的user
}

現在要測試 userService.Get(id)方法的行為:

  1. Cache命中之後是否還查數據庫? (不應該再查了)
  2. Cache未命中的情況下是否會查庫?
  3. ....

這種測試通過mock+stub結合做起來將會非常方便,作為小練習,可以嘗試自己實現一下。


使用依賴注入傳遞接口

接口需要以依賴注入的方式註入到結構體中,這樣才能為測試提供替換接口實現的可能。 Why?我們先看一個反面例子,形如下面的寫法是無法測試的:

type A interface {   
    Fun1() 
}
func (f *Foo) Bar() {    
    a := NewInstanceOfA(...參數若干) //生成A接口的某個實現    
    a.Fun1()  //調用接口方法
}

當你辛辛苦苦的將A接口mock出來後,卻發現你根本沒有辦法在Bar()方法中將mock對象替換進去。下面來看看正確的寫法:

type A interface {    
    Fun1() 
}
type Foo struct {   
    a A // A接口
}
func (f *Foo) Bar() {   
    f.a.Fun1() //調用接口方法
}
// NewFoo, 通過構造函數的方式,將A接口注入
func NewFoo(a A) *Foo {     
    return &Foo{a: A}
}

在例子中我們使用了構造函數傳參的方法來做依賴注入(當然你也可以用setter的方式做)。在測試的時候,就可以通過NewFoo()方法將我們的mock對像傳遞給*Foo了。

通常我們會在main.go中進行依賴注入


總結一下

長篇大論了一大堆,稍微總結一下單元測試的幾個關鍵步驟:

  1. 識別依賴(網絡,文件,未完成的功能等等)
  2. 將依賴抽象成接口
  3. main.go中使用依賴注入方式將接口注入

現在,我們已經對單元測試有了一個基本的認識,如果你能完成文中的小練習,那麼恭喜你,你已經理解應當如何做單元測試,並成功邁出第一步了。在下一篇文章中,將介紹gomock測試框架,提高我們的測試效率。

Reference

© Kimi Tsai all right reserved.            Updated : 2023-07-12 09:04:53

results matching ""

    No results matching ""

    results matching ""

      No results matching ""