Go的單元測試技巧
單元測試(Unit Test)
Go語言原生支援測試工具 go test
,省去了各種各樣測試框架的學習成本。說來也慚愧,寫程式碼這麼些年,也從來沒有給自己的程式碼寫過單元測試,程式碼質量的確堪憂。遂花時間學習整理了一下單元測試的基本方法,以及在Go中的實踐技巧。
單元測試的難點
以下是我在嘗試進行單元測試的過程中遇到的一些難點,在下文中會介紹相應的一些應對方案。
1.掌握單元測試粒度
單元測試粒度是讓人十分頭疼的問題,特別是對於初嘗單元測試的程式員(比如我)。測試粒度做的太細,會耗費大量的開發以及維護時間,每改一個方法,都要改動其對應的測試方法。當發生程式碼重構的時候那簡直就是噩夢(因為你所有的單元測試又都要寫一遍了…)。 如單元測試粒度太粗,一個測試方法測試了n多方法,那麼單元測試將顯的非常臃腫,脫離了單元測試的本意,容易把單元測試寫成__整合測試__。
2. 破除外部依賴(mock,stub 技術)
單元測試中是不允許有任何外部依賴的,也就是說這些外部依賴都需要被模擬(mock)。外部依賴越多,mock越複雜。如何用模擬的依賴來測試真實依賴的行為?mock寫的太簡單,達不到測試的目的。mock太複雜, 不僅成本增加,而且又如何確保mock的正確性呢?
有的時候模擬是有效的方便的。但是其他一些時候,過多的模擬物件,Stub物件,假物件,導致單元測試主要在測模擬物件而不是實際的系統。
Costs and Benefits
在受益於單元測試的好處的同時,也必然增加了程式碼量以及維護成本(單元測試程式碼也是要維護的)。下面這張 成本/價值象限圖 很清晰的闡述了在不同性質的系統中單元測試__成本__和__價值__之間的關係。
1.依賴很少的簡單的程式碼(左下)
對於外部依賴少,程式碼又簡單的程式碼。自然其成本和價值都是比較低的。舉Go官方庫裡errors包為例,整個包就兩個方法 New()
和 Error()
,沒有任何外部依賴,程式碼也很簡單,所以其單元測試起來也是相當方便。
2. 依賴較多但是很簡單的程式碼(右下)
依賴一多,mock和stub就必然增多,單元測試的成本也就隨之增加。但程式碼又如此簡單(比如上述errors包的例子),這個時候寫單元測試的成本已經大於其價值, 還不如不寫單元測試 。
3. 依賴很少的複雜程式碼 (左上)
像這一類程式碼,是最有價值寫單元測試的。比如一些獨立的複雜演算法(銀行利息計算,保險費率計算,TCP協議解析等),像這一類程式碼外部依賴很少,但卻很容易出錯,如果沒有單元測試,幾乎不能保證程式碼質量。
4.依賴很多又很複雜(右上)
這種程式碼顯然是單元測試的噩夢。寫單元測試吧,代價高昂;不寫單元測試吧,風險太高。像這種程式碼我們儘量在設計上將其分為兩部分:1.處理複雜的邏輯部分 2.處理依賴部分
然後1部分進行單元測試
原文參考:http://blog.stevensanderson.com/2009/11/04/selective-unit-testing-costs-and-benefits/
邁出單元測試第一步
1. 識別依賴,抽象成介面
識別系統中的外部依賴,普遍來說,我們遇到最常見的依賴無非下面幾種:
● 網路依賴——函式執行依賴於網路請求,比如第三方http-api,rpc服務,訊息佇列等等
● 資料庫依賴
● 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 = "[email protected]"
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結構體。這樣有兩個好處:
● mock出來的結構體十分簡單,不需要進行額外的設定,不容易出錯。
● 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 int) interface{} //獲取某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)
方法的行為:
● Cache命中之後是否還查資料庫?(不應該再查了)
● Cache未命中的情況下是否會查庫?
● …
這種測試通過mock+stub結合做起來將會非常方便,作為小練習,可以嘗試自己實現一下。
原文釋出時間為:2018-10-10
本文作者:DrmagicE
本文來自雲棲社群合作伙伴“ ofollow,noindex">Golang語言社群 ”,瞭解相關資訊可以關注“ Golang語言社群 ”。