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

單元測試是代碼質量的保證。本系列文章將一步步由淺入深展現如何在Go中作單元測試。html

Go對單元測試的支持至關友好,標準包中就支持單元測試,在開始本系閱讀以前,須要對標準測試包的基本用法有所瞭解。程序員

如今,咱們從單元測試的基本思想和原理入手,一塊兒來看看如何基於Go提供的標準測試包來進行單元測試。算法

單元測試的難點

1.掌握單元測試粒度

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

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

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

有的時候模擬是有效的方便的。但咱們要提防過分的mock/stub,由於其會致使單元測試主要在測模擬對象而不是實際的系統。api

Costs and Benefits

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

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

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

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. 明確須要測什麼

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

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

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 *authLoginErr) Login (username string,password string) (string,error) {
	return "", errors.New("api服務不可用")
}
func (auth *authLoginErr) 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結構體。這樣有兩個好處:

  1. mock出來的結構體十分簡單,不須要進行額外的設置,不容易出錯。
  2. 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測試框架,提升咱們的測試效率。

相關文章
相關標籤/搜索