最近在工作和業餘開源貢獻中,和單元測試接觸的比較頻繁。但是在這兩個場景之下寫出來的單元測試貌似不太一樣,即便是同一個程式碼場景,今天寫出來的單元測試和昨天寫的也不是很一樣,我感受到了對於單元測試,我沒有一個比較統一的規範和一套單元測試實踐的方法論。在寫了一些單元測試之後我開始想去了解寫單元測試的一些最佳實踐和技巧。(其實後來我反思的時候覺得,我應該先去學習單元測試相關的最佳實踐,現有一個大致的概念,再去實操會好一些。)在這裡總結成一篇文章分享給大家,希望讀者朋友們有所收穫。
單元測試是一個優秀專案必不可少的一部分,在一個頻繁變動和多人合作的專案中顯得尤為關鍵。站在寫程式的人的角度出發,其實很多時候你並不能百分之百確定你的程式碼就是一點問題都沒有的,在計算機的世界裡其實不確定的因素很多,比如我們可能不確定程式碼中的一些依賴項,在實際程式碼執行的過程中他會符合我們的預期,我們也不能確定我們寫的邏輯是否可以涵蓋所有的場景,比如可能會存在寫了if沒有些else的情況。所以我們需要寫自測去自證我們的程式碼沒有問題,當然寫自測也並不可以保證程式碼就完完全全沒有問題了,只能說可以做到儘可能的避免問題吧。其次對於一個多人蔘與的專案來說,開源專案也好,工作中多人共同作業也好,如果要看懂一段邏輯是幹嘛的,或者要了解程式碼是怎麼運作的,最好的切入點往往是看這個專案的單元測試或者參與編寫這個專案的單元測試。我個人要學習一個開源專案也是首先從單元測試入手的,單元測試可以告訴我一段邏輯這段程式碼是幹什麼的,他的預期是輸入是什麼,產出是什麼,什麼場景會報錯。
這一章節將會介紹為什麼一些程式碼比較難以測試,以及如何寫一個比較好的測試。在這裡會結合一些我看過的一些開源專案的程式碼進行舉例講述。
其實不是所有的程式碼都是可以測試的,或者說有的程式碼其實是不容易測試的,有時候為了方便測試,需要把程式碼重構成容易測試的樣子。但是很多時候在寫單元測試之前,你都不知道你寫的程式碼其實是不可以測的。這裡我舉go-mysql的一些程式碼例子來闡述不可測或者不容易測的因素都有哪些。
go-mysql 是pingcap首席架構師唐劉大佬實現的一個mysql工具庫,裡面提供了一些實用的工具,比如canal模組可以消費mysql-binlog資料實現mysql資料的複製,client模組是一個簡單的mysql驅動,實現與mysql的互動等等,其他功能可以去github上看readme詳細介紹。最近由於工作需要看了大量這個庫的原始碼,所以在這裡拿一些程式碼出來舉舉例子。
在我們實際些程式碼的時候,實際一部分程式碼會比較依賴外部的環境,比如我們的一些邏輯可能會需要連線到mysql,或者你會需要一個tcp的連線。比如下面這段程式碼:
/*
Conn is the base class to handle MySQL protocol.
*/
type Conn struct {
net.Conn
bufPool *BufPool
br *bufio.Reader
reader io.Reader
copyNBuf []byte
header [4]byte
Sequence uint8
}
這個是go-msyql處理網路連線的結構體,我們可以看到的是這個結構體裡面包裹的是一個net.Conn介面,並不是某一個具體的實現,這樣子提供了很靈活的測試方式,只需要mock一個net.Conn的實現類就可以測試他的相關方法了,如果這裡封裝的是net.Conn的具體實現比如TCPConn,這樣就變得不好測試了,在寫單元測試的時候你可能需要給他提供一個TCP的環境,這樣子其實比較麻煩了。
第二個例子來自go-mysql canal這個模組,這個模組的主要功能通過消費mysql binlog的形式來複制mysql的資料,那麼這裡的整體邏輯怎麼測試呢,這個模組是偽裝成mysql的從節點去複製資料的,那麼主節點在哪裡呢,這裡就要切切實實的mysql環境了。我們可以看看作者是怎麼測試的,這裡程式碼太長我就不貼出來了,把GitHub的程式碼連結貼在這裡,感興趣的讀者可以去看點選這裡看github程式碼。作者在CI環境里弄了一個mysql的環境,然後在測試之前通過執行一些sql語句來構建測試的環境,在測試的過程中也是通過執行sql的方式來產生對應的binlog去驗證自己的邏輯。
有時候寫程式碼可能就是圖個爽快,一把梭哈把所有的邏輯都放在一個函數裡面,這樣就會導致過多的邏輯堆積在一起,測試的時候分支可能過多,所以為了單元測試看起來比較簡潔可能需要我們把這樣的邏輯進行拆分,把專門做一件事情的邏輯放在一起,去做對應的測試。然後對整段邏輯做整體測試就好。
為了方便去描述這個一些內容,這裡我簡單的提供一個這樣的函數。這個函數邏輯比較簡單,就是輸入一個名字,然後返回一個跟你打招呼的資訊。
func Greeter(name string) string {
return "hi " + name
}
那麼如何寫這個函數的測試呢。我理解有兩個關鍵的點,一是單元測試的命名,二是單元測試的內容架構。
命名其實也是有講究的,我理解單元測試也是給別人看的,所以當我看你寫的單元測試的時候,最好在命名上有:測試物件,輸入,預期輸出。這樣可以通過名字知道這個單元測試大致內容是什麼。
測試的內容架構主要是這幾件事情:
所以綜合上面兩點,比較好的實踐是這樣的。
// 比較詳細的寫法,測試的是什麼(Greeter), 入參是什麼(elliot), 預期結果是什麼(hi elliot)
func Test_Greeter_when_param_is_elliot_get_hi_Elliot(t *testing.T) {
// 準備
name := "elliot"
// 執行
greet := Greeter(name)
// 驗證
assert.Equal(t, "hi elliot", greet)
}
// 比較省略的寫法,測試的是什麼(Greeter), 入參是name, 預期結果是一個打招呼的msg,GreetMsg
func Test_Greeter_name_greetMsg(t *testing.T) {
// 準備
name := "elliot"
// 執行
greet := Greeter(name)
// 驗證
assert.Equal(t, "hi elliot", greet)
}
這裡要注意一個問題,儘量避免執行和驗證的程式碼寫在一起,比如寫成這樣子:
assert.Equal(t, "hi elliot", Greeter("elliot"))
這樣子其實在功能上是一樣的,但是會影響程式碼的可讀性。不是特別推薦。
在講了如何寫一個單元測試之後,我們來說說什麼的測試才是好的測試。我個人認為一個好的測試應該具備一下三點:
其實講了一些概念之後對怎麼樣寫好一個測試我們還是沒什麼印象的,那麼可以從一些不好的case去入手,我們知道了那些實踐是不好的之後,就會對好的實踐有一個大致的認識。
// 可讀性比較低,因為讀者並不知道這個「hi elliot」是什麼
assert.Equal(t, "hi elliot", greet)
// 這樣就會好一些
expectedGreetMsg := "hi elliot"
assert.Equal(t, expectedGreetMsg, greet)
在講完大概比較好的單元測試實踐之後,我們可以稍微提升一下。我們不妨假設有這麼一個場景,其實是測一段邏輯,但是會有好幾個測試用例需要測試,那麼我們需要寫好幾個測試的函數嘛?其實是不用的,這裡就涉及到了,引數化測試,什麼意思呢?我們直接舉例吧。看下面這段程式碼。
func isLargerThanTen(num int) bool {
return num > 10
}
func TestIsLargerThanTen_All(t *testing.T) {
var tests = []struct {
name string
num int
expected bool
}{
{
name: "test_larger_than_ten",
num: 11,
expected: true,
},
{
name: "test_less_than_ten",
num: 9,
expected: false,
},
{
name: "test_equal_than_ten",
num: 10,
expected: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
res := isLargerThanTen(test.num)
assert.Equal(t, test.expected, res)
})
}
}
這裡面測試的是一個判斷入參是否大於10的函數,那麼我們自然而然的想到三個測試用例,引數大於10的,等於10的,小於10的。但是實際上這三個測試用例都在測試一段邏輯,實際上是不太需要寫三個函數的。所以把這三個測試用例和對應的預期結果封裝起來,在for迴圈裡面跑這三個測試用例。個人覺得這是一種比較好的測試方法。
在講完上面的一些測試方法之後,在這裡推薦一些在go裡面的測試工具。其中最著名的testify就是不得不推薦的了。很多開源專案都在用這個庫構建測試用例。說到這裡突然想到之前有人給goleveldb提交pr程式碼寫自己的單元測試時引入了這個庫,我還「批鬥」了他,說修改程式碼和引入新的庫是兩碼事,請你分開做hhhh,現在想想還蠻不好意思的。迴歸正題,我們來簡單介紹一些testify這個庫。
testify這個庫主要有三個核心內容,assert, mock, suite。assert就是斷言,可以封裝了一些判斷是否相等,是否會有異常之類的。文章篇幅有限,這裡就不對assert的api一一介紹了,感興趣的朋友們可以看衍生閱讀的相關文章。這裡我主要介紹mock和suite模組。
在我們要準備測試的時候經常需要準備一些資料,mock模組通過實現介面的方式來偽造資料。從而在測試的時候可以用這個mock的物件作為引數進行傳遞。廢話不多說我們看下怎麼簡單的實踐一下。
首先我們定義一個介面:
//go:generate mockery --name=Man
type Man interface {
GetName() string
IsHandSomeBoy() bool
}
這個介面定義了一個男孩子,一個方法是獲取他的名字,第二個方法是看他是不是帥哥。這裡我還推薦使用go:generate的方式執行mockery(執行go get -u -v github.com/vektra/mockery/.../安裝)命令去生成對應的mock物件(生成的程式碼會放在當前目錄的mocks目錄下,當然你也可以在命令上新增引數指定生成路徑),這樣就不需要我們去實現mock物件的一些方法了。下面我們看下生成的程式碼是怎麼樣的。
// Code generated by mockery v2.10.0. DO NOT EDIT.
package mocks
import mock "github.com/stretchr/testify/mock"
// Man is an autogenerated mock type for the Man type
type Man struct {
mock.Mock
}
// GetName provides a mock function with given fields:
func (_m *Man) GetName() string {
ret := _m.Called()
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// IsHandSomeBoy provides a mock function with given fields:
func (_m *Man) IsHandSomeBoy() bool {
ret := _m.Called()
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
那麼我們怎麼使用呢?看看下面程式碼:
func TestMan_All(t *testing.T) {
man := mocks.Man{}
// 可以通過這段話來新增某個方法對應的返回
man.On("GetName").Return("Elliot").On("IsHandSomeBoy").Return(true)
assert.Equal(t, "Elliot", man.GetName())
assert.Equal(t, true, man.IsHandSomeBoy())
}
有時候我們可能需要測的不是一個單獨的函數,是一個物件的很多方法,比如想對leveldb的一些主要方法進行測試,比如簡單的讀寫,範圍查詢,那麼如果每個功能的單元測試都寫成一個函數,那麼可能這裡會重複初始化一些東西,比如db。其實這裡是可以做到共用一些狀態的,比如資料寫入之後就可以測試把這個資料讀出來,或者範圍查詢。在這裡的話其實用一種比較緊密的方式把他們串聯起來會比較好。那麼suite套件就應運而生。這裡我就不打算在詳細介紹了,感興趣的讀者可以移步衍生閱讀中的《go每日一庫之testify》。我理解這篇文章講的比較清晰了。但是這裡的話我可以提供nutsdb的一個相關測試用例大家參考:https://github.com/nutsdb/nutsdb/blob/master/bucket_meta_test.go 大家感興趣的話也可以參考這段程式碼。
這篇文章主要是總結最近我在單元測試上面的一些思考和沉澱,以及對go的測試工具的粗略講解。在本文中使用到的一些開源專案的原始碼,主要是分享一些自己的思考,希望對大家有所幫助。