使用testify和mockery庫簡化單元測試
前言
2016年我寫過一篇關於ofollow,noindex" target="_blank">Go語言單元測試
的文章,簡單介紹了 testing 庫的使用方法。後來發現testify/require 和 testify/assert
可以大大簡化單元測試的寫法,完全可以替代t.Fatalf
和t.Errorf
,而且程式碼實現更為簡短、優雅。
再後來,發現了mockery 庫,它可以為 Go interface 生成一個 mocks struct。通過 mocks struct,在單元測試中我們可以模擬所有 normal cases 和 corner cases,徹底消除細節實現上的bug。mocks 在測試無狀態函式 (對應 FP 中的 pure function) 中意義不大,其應用場景主要在於處理不可控的第三方服務、資料庫、磁碟讀寫等。如果這些服務的呼叫細節已經被封裝到 interface 內部,呼叫方只看到了 interface 定義的一組方法,那麼在測試中 mocks 就能控制第三方服務返回任意期望的結果,進而實現對呼叫方邏輯的全方位測試。
關於 interface 的諸多用法,我會單獨拎出來一篇文章來講。本文中,我會通過兩個例子展示testify/require
和mockery
的用法,分別是:
-
使用
testify/require
簡化 table driven test -
使用
mockery
和testify/mock
為 lazy cache 寫單元測試
準備工作
# download require, assert, mock go get -u -v github.com/stretchr/testify # install mockery into GoBin go get -u -v github.com/vektra/mockery/.../
testify/require
首先,我們通過一個簡單的例子看下 require 的用法。我們針對函式Sqrt
進行測試,其實現為:
// Sqrt calculate the square root of a non-negative float64 // number with max error of 10^-9. For simplicity, we don't // discard the part with is smaller than 10^-9. func Sqrt(x float64) float64 { if x < 0 { panic("cannot be negative") } if x == 0 { return 0 } a := x / 2 b := (a + 2) / 2 erro := a - b for erro >= 0.000000001 || erro <= -0.000000001 { a = b b = (b + x/b) / 2 erro = a - b } return b }
這裡我們使用了一個常規的方法實現Sqrt
,該實現的最大精確度是到小數點後9位(為了方便演示,這裡沒有對超出9位的部分進行刪除)。我們首先測試x < 0
導致 panic 的情況,看require
如何使用,測試程式碼如下:
func TestSqrt_Panic(t *testing.T) { defer func() { r := recover() require.Equal(t, "cannot be negative", r) }() _ = Sqrt(-1) }
在上面的函式中,我們只使用require.Equal
一行程式碼就實現了執行結果校驗。如果使用testing
來實現的話,變成了三行,並且需要手寫一串描述:
func TestSqrt_Panic(t *testing.T) { defer func() { r := recover() if r.(string) != "cannot be negative" { t.Fatalf("expect to panic with message \"cannot be negative\", but got \"%s\"\n", r) } }() _ = Sqrt(-1) }
使用require
之後,不僅使測試程式碼更易於編寫,而且能夠在測試執行失敗時,格式化執行結果,方便定位和修改bug。這裡你不妨把-1
改成一個正數,執行go test
,檢視執行結果。
上面我們能夠看到require
庫帶來的編碼和除錯效率的上升。在 table driven test 中,我們會有更深刻的體會。
Table Driven Test
我們仍然以Sqrt
為例,來看下如何在 table driven test 中使用require
。這裡我們測試的傳入常規引數的情況,程式碼實現如下:
func TestSqrt(t *testing.T) { testcases := []struct { descstring inputfloat64 expect float64 }{ { desc:"zero", input:0, expect: 0, }, { desc:"one", input:1, expect: 1, }, { desc: "a very small rational number", input: 0.00000000000000000000000001, expect: 0.0, }, { desc:"rational number result: 2.56", input:2.56, expect: 1.6, }, { desc:"irrational number result: 2", input:2, expect: 1.414213562, }, } for _, ts := range testcases { got := Sqrt(ts.input) erro := got - ts.expect require.True(t, erro < 0.000000001 && erro > -0.000000001, ts.desc) } }
在上面這個例子,有三點值得注意:
-
匿名struct
允許我們填充任意型別的欄位,非常方便於構建測試資料集; -
每個
匿名struct
都包含一個desc string
欄位,用於描述該測試要處理的狀況。在測試執行失敗時,非常有助於定位失敗位置; -
使用
require
而不是assert
,因為使用require
時,測試失敗以後,所有測試都會停止執行。
關於require
,除了本文中提到的require.True
,require.Equal
,還有一個比較實用的方法是require.EqualValues
,它的應用場景在於處理 Go 的強型別問題,我們不妨看一段程式碼:
func Test_Require_EqualValues(t *testing.T) { // tests will pass require.EqualValues(t, 12, 12.0, "compare int32 and float64") require.EqualValues(t, 12, int64(12), "compare int32 and int64") // tests will fail require.Equal(t, 12, 12.0, "compare int32 and float64") require.Equal(t, 12, int64(12), "compare int32 and int64") }
更多require
的方法參考require's godoc
。
mockery
mockery 與 Go 指令(directive) 結合使用,我們可以為 interface 快速建立對應的 mock struct。即便沒有具體實現,也可以被其他包呼叫。我們通過 LazyCache 的例子來看它的使用方法。
假設有一個第三方服務,我們把它封裝在thirdpartyapi
包裡,並加入 go directive,程式碼如下:
package thirdpartyapi //go:generate mockery -name=Client // Client defines operations a third party service has type Client interface { Get(key string) (data interface{}, err error) }
我們在 thirdpartyapi 目錄下執行go generate
,在 mocks 目錄下生成對應的 mock struct。目錄結構如下:
~ $ tree thirdpartyapi/ thirdpartyapi/ ├── client.go └── mocks └── Client.go 1 directory, 2 files
在執行go generate
時,指令//go:generate mockery -name=Client
被觸發。它本質上是mockery -name=Client
的快捷方式,優勢是 go generate 可以批量執行多個目錄下的多個指令(需要多加一個引數,具體可以參考文件)。
此時,我們只有 interface,並沒有具體的實現,但是不妨礙在LazyCache
中呼叫它,也不妨礙在測試中呼叫thirdpartyapi
的 mocks client。為了方便理解,這裡把LazyCache
的實現也貼出來 (忽略 import):
//go:generate mockery -name=LazyCache // LazyCache defines the methods for the cache type LazyCache interface { Get(key string) (data interface{}, err error) } // NewLazyCache instantiates a default lazy cache implementation func NewLazyCache(client thirdpartyapi.Client, timeout time.Duration) LazyCache { return &lazyCacheImpl{ cacheStore:make(map[string]cacheValueType), thirdPartyClient: client, timeout:timeout, } } type cacheValueType struct { datainterface{} lastUpdated time.Time } type lazyCacheImpl struct { sync.RWMutex cacheStoremap[string]cacheValueType thirdPartyClient thirdpartyapi.Client timeouttime.Duration // cache would expire after timeout } // Get implements LazyCache interface func (c *lazyCacheImpl) Get(key string) (data interface{}, err error) { c.RLock() val := c.cacheStore[key] c.RUnlock() timeNow := time.Now() if timeNow.After(val.lastUpdated.Add(c.timeout)) { // fetch data from third party service and update cache latest, err := c.thirdPartyClient.Get(key) if err != nil { return nil, err } val = cacheValueType{latest, timeNow} c.Lock() c.cacheStore[key] = val c.Unlock() } return val.data, nil }
為了簡單,我們暫時不考慮 cache miss 或 timeout 與cache被更新的時間間隙,大量請求直接打到thirdpartyapi
可能導致的後果。
介紹測試之前,我們首先了解一下 "控制變數法",在自然科學中,它被廣泛用於各類實驗中。在智庫百科
,它被定義為指把多因素的問題變成多個單因素的問題,而只改變其中的某一個因素,從而研究這個因素對事物影響,分別加以研究,最後再綜合解決的方法
。該方法同樣適用於電腦科學,尤其是測試不同場景下程式是否能如期望般執行。我們將這種方法應用於本例中Get
方法的測試。
在Get
方法中,可變因素有cacheStore
、thirdPartyClient
和timeout
。在測試中,cacheStore
和timeout
是完全可控的,thirdPartyClient
的行為需要通過 mocks 自定義期望行為以覆蓋預設實現。事實上,mocks 的功能要強大的多,下面我們用程式碼來看。
為 LazyCache 寫測試
這裡,我只拿出Cache Miss Update Failure 一個case 來分析,覆蓋所有 case 的程式碼檢視github repo 。
func TestGet_CacheMiss_Update_Failure(t *testing.T) { testKey := "test_key" errTest := errors.New("test error") mockThirdParty := &mocks.Client{} mockThirdParty.On("Get", testKey).Return(nil, errTest).Once() mockCache := &lazyCacheImpl{ memStore:map[string]cacheValueType{}, thirdPartyClient: mockThirdParty, timeout:testTimeout, } // test cache miss, fails to fetch from data source _, gotErr := mockCache.Get(testKey) require.Equal(t, errTest, gotErr) mock.AssertExpectationsForObjects(t, mockThirdParty) }
這裡,我們只討論mockThirdParty
,主要有三點:
-
mockThirdParty.On("Get", testKey).Return(nil, errTest).Once()
用於定義該物件Get
方法的行為:Get
方法接受testKey
作為引數,當且僅當被呼叫一次時,會返回errTest
。如果同樣的引數,被呼叫第二次,就會報錯; -
_, gotErr := mockCache.Get(testKey)
觸發一次上一步中定義的行為; -
mock.AssertExpectationsForObjects
函式會對傳入物件進行檢查,保證預定義的期望行為完全被精確地觸發;
在 table driven test 中,我們可以通過mockThirdParty.On
方法定義Get
針對不同引數返回不同的結果。
在上面的測試中.Once()
等價於.Times(1)
。如果去掉.Once()
,意味著mockThirdParty.Get
方法可以被呼叫任意次。
更多 mockery 的使用方法參考github
小結
在本文中,我們結合例項講解了testify
和mockery
兩個庫在單元測試中的作用。最後分享一個圖,希望大家能重視單元測試。