Go語言原生支持測試工具go test
,省去了各類各樣測試框架的學習成本。說來也慚愧,寫代碼這麼些年,也歷來沒有給本身的代碼寫過單元測試,代碼質量的確堪憂。遂花時間學習整理了一下單元測試的基本方法,以及在Go中的實踐技巧。html
如下是我在嘗試進行單元測試的過程當中遇到的一些難點,在下文中會介紹相應的一些應對方案。程序員
單元測試粒度是讓人十分頭疼的問題,特別是對於初嘗單元測試的程序員(好比我)。測試粒度作的太細,會耗費大量的開發以及維護時間,每改一個方法,都要改動其對應的測試方法。當發生代碼重構的時候那簡直就是噩夢(由於你全部的單元測試又都要寫一遍了…)。 如單元測試粒度太粗,一個測試方法測試了n多方法,那麼單元測試將顯的很是臃腫,脫離了單元測試的本意,容易把單元測試寫成__集成測試__。算法
單元測試中是不容許有任何外部依賴的,也就是說這些外部依賴都須要被模擬(mock)。外部依賴越多,mock越複雜。如何用模擬的依賴來測試真實依賴的行爲?mock寫的太簡單,達不到測試的目的。mock太複雜, 不只成本增長,並且又如何確保mock的正確性呢?shell
有的時候模擬是有效的方便的。可是其餘一些時候,過多的模擬對象,Stub對象,假對象,致使單元測試主要在測模擬對象而不是實際的系統。數據庫
在受益於單元測試的好處的同時,也必然增長了代碼量以及維護成本(單元測試代碼也是要維護的)。下面這張成本/價值象限圖很清晰的闡述了在不一樣性質的系統中單元測試__成本__和__價值__之間的關係。api
1.依賴不多的簡單的代碼(左下)緩存
對於外部依賴少,代碼又簡單的代碼。天然其成本和價值都是比較低的。舉Go官方庫裏errors包爲例,整個包就兩個方法New()
和Error()
,沒有任何外部依賴,代碼也很簡單,因此其單元測試起來也是至關方便。服務器
依賴一多,mock和stub就必然增多,單元測試的成本也就隨之增長。但代碼又如此簡單(好比上述errors包的例子),這個時候寫單元測試的成本已經大於其價值,還不如不寫單元測試。網絡
像這一類代碼,是最有價值寫單元測試的。好比一些獨立的複雜算法(銀行利息計算,保險費率計算,TCP協議解析等),像這一類代碼外部依賴不多,但卻很容易出錯,若是沒有單元測試,幾乎不能保證代碼質量。app
這種代碼顯然是單元測試的噩夢。寫單元測試吧,代價高昂;不寫單元測試吧,風險過高。像這種代碼咱們儘可能在設計上將其分爲兩部分:1.處理複雜的邏輯部分 2.處理依賴部分
而後1部分進行單元測試
原文參考:http://blog.stevensanderson.com/2009/11/04/selective-unit-testing-costs-and-benefits/
識別系統中的外部依賴,廣泛來講,咱們遇到最多見的依賴無非下面幾種:
網絡依賴——函數執行依賴於網絡請求,好比第三方http-api,rpc服務,消息隊列等等
數據庫依賴
I/O依賴(文件)
固然,還有多是依賴還未開發完成的功能模塊。可是處理方法都是大同小異的——抽象成接口,經過mock和stub進行模擬測試。
當咱們開始敲產品代碼的時候,咱們必然已通過初步的設計,已經瞭解系統中的外部依賴以及業務複雜的部分,這些部分是要優先考慮寫單元測試的。在寫每個方法/結構體的時候同時思考這個方法/結構體需不須要測試?如何測試?對於什麼樣的方法/結構體須要測試,什麼樣的能夠不作,除了能夠從上面的成本/價值象限圖中得到答案外,還能夠參考如下關於單元測試粒度要作多細問題的回答:
老闆爲個人代碼付報酬,而不是測試,因此,我對此的價值觀是——測試越少越好,少到你對你的代碼質量達到了某種自信(我以爲這種的自信標準應該要高於業內的標準,固然,這種自信也多是種自大)。若是個人編碼生涯中不會犯這種典型的錯誤(如:在構造函數中設了個錯誤的值),那我就不會測試它。我傾向於去對那些有意義的錯誤作測試,因此,我對一些比較複雜的條件邏輯會異常地當心。當在一個團隊中,我會很是當心的測試那些會讓團隊容易出錯的代碼。
https://coolshell.cn/articles/8209.html
Mock(模擬)和Stub(樁)是在測試過程當中,模擬外部依賴行爲的兩種經常使用的技術手段。
經過Mock和Stub咱們不只可讓測試環境沒有外部依賴,並且還能夠模擬一些異常行爲,如數據庫服務不可用,沒有文件的訪問權限等等。
在Go語言中,能夠這樣描述Mock和Stub:
Mock:在測試包中建立一個結構體,知足某個外部依賴的接口interface{}
Stub:在測試包中建立一個模擬方法,用於替換生成代碼中的方法
仍是有點抽象,下面舉例說明。
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:在測試包中建立一個模擬方法,用於替換生成代碼中的方法。
這是《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方式。
既然不提倡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結合作起來將會很是方便,做爲小練習,能夠嘗試本身實現一下。