如何寫好測試用例以及go單元測試工具testify簡單介紹

2022-06-27 06:01:39

背景

​ 最近在工作和業餘開源貢獻中,和單元測試接觸的比較頻繁。但是在這兩個場景之下寫出來的單元測試貌似不太一樣,即便是同一個程式碼場景,今天寫出來的單元測試和昨天寫的也不是很一樣,我感受到了對於單元測試,我沒有一個比較統一的規範和一套單元測試實踐的方法論。在寫了一些單元測試之後我開始想去了解寫單元測試的一些最佳實踐和技巧。(其實後來我反思的時候覺得,我應該先去學習單元測試相關的最佳實踐,現有一個大致的概念,再去實操會好一些。)在這裡總結成一篇文章分享給大家,希望讀者朋友們有所收穫。

1. 為什麼要寫單元測試

​ 單元測試是一個優秀專案必不可少的一部分,在一個頻繁變動和多人合作的專案中顯得尤為關鍵。站在寫程式的人的角度出發,其實很多時候你並不能百分之百確定你的程式碼就是一點問題都沒有的,在計算機的世界裡其實不確定的因素很多,比如我們可能不確定程式碼中的一些依賴項,在實際程式碼執行的過程中他會符合我們的預期,我們也不能確定我們寫的邏輯是否可以涵蓋所有的場景,比如可能會存在寫了if沒有些else的情況。所以我們需要寫自測去自證我們的程式碼沒有問題,當然寫自測也並不可以保證程式碼就完完全全沒有問題了,只能說可以做到儘可能的避免問題吧。其次對於一個多人蔘與的專案來說,開源專案也好,工作中多人共同作業也好,如果要看懂一段邏輯是幹嘛的,或者要了解程式碼是怎麼運作的,最好的切入點往往是看這個專案的單元測試或者參與編寫這個專案的單元測試。我個人要學習一個開源專案也是首先從單元測試入手的,單元測試可以告訴我一段邏輯這段程式碼是幹什麼的,他的預期是輸入是什麼,產出是什麼,什麼場景會報錯。

2. 如何寫好單元測試

​ 這一章節將會介紹為什麼一些程式碼比較難以測試,以及如何寫一個比較好的測試。在這裡會結合一些我看過的一些開源專案的程式碼進行舉例講述。

2.1 什麼程式碼比較難測試

​ 其實不是所有的程式碼都是可以測試的,或者說有的程式碼其實是不容易測試的,有時候為了方便測試,需要把程式碼重構成容易測試的樣子。但是很多時候在寫單元測試之前,你都不知道你寫的程式碼其實是不可以測的。這裡我舉go-mysql的一些程式碼例子來闡述不可測或者不容易測的因素都有哪些。

go-mysql 是pingcap首席架構師唐劉大佬實現的一個mysql工具庫,裡面提供了一些實用的工具,比如canal模組可以消費mysql-binlog資料實現mysql資料的複製,client模組是一個簡單的mysql驅動,實現與mysql的互動等等,其他功能可以去github上看readme詳細介紹。最近由於工作需要看了大量這個庫的原始碼,所以在這裡拿一些程式碼出來舉舉例子。

2.1.1 程式碼依賴外部的環境

​ 在我們實際些程式碼的時候,實際一部分程式碼會比較依賴外部的環境,比如我們的一些邏輯可能會需要連線到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去驗證自己的邏輯。

2.1.2 程式碼太過冗餘

​ 有時候寫程式碼可能就是圖個爽快,一把梭哈把所有的邏輯都放在一個函數裡面,這樣就會導致過多的邏輯堆積在一起,測試的時候分支可能過多,所以為了單元測試看起來比較簡潔可能需要我們把這樣的邏輯進行拆分,把專門做一件事情的邏輯放在一起,去做對應的測試。然後對整段邏輯做整體測試就好。

2.2 如何寫好一個單元測試

​ 為了方便去描述這個一些內容,這裡我簡單的提供一個這樣的函數。這個函數邏輯比較簡單,就是輸入一個名字,然後返回一個跟你打招呼的資訊。

func Greeter(name string) string {
	return "hi " + name
}

​ 那麼如何寫這個函數的測試呢。我理解有兩個關鍵的點,一是單元測試的命名,二是單元測試的內容架構。

2.2.1 單元測試的命名

​ 命名其實也是有講究的,我理解單元測試也是給別人看的,所以當我看你寫的單元測試的時候,最好在命名上有:測試物件,輸入,預期輸出。這樣可以通過名字知道這個單元測試大致內容是什麼。

2.2.2 測試內容架構

​ 測試的內容架構主要是這幾件事情:

  1. 測試準備。在測試之前可能需要準備一些資料,mock一些入參。
  2. 執行。執行需要測試的程式碼。
  3. 驗證。驗證我們的邏輯對不對,這裡主要做的是執行程式碼之後預期的返回和實際返回之間的一個比對。

所以綜合上面兩點,比較好的實踐是這樣的。

// 比較詳細的寫法,測試的是什麼(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"))

這樣子其實在功能上是一樣的,但是會影響程式碼的可讀性。不是特別推薦。

3. 什麼是好的測試

​ 在講了如何寫一個單元測試之後,我們來說說什麼的測試才是好的測試。我個人認為一個好的測試應該具備一下三點:

  1. 可信賴。首先我們寫的單元測試的作用是測試某一段邏輯的正確性,如果我們的寫的單元測試都是不值得信賴的,那麼又如何保證測試的物件是值得信賴的呢?有時候一些單元測試也有可能時好時壞,比如一個單元測試依賴一個亂數去做一些邏輯,那麼本身這個亂數就是不可控的,可能這下執行是好的,下一次執行就過不了了。
  2. 可維護。業務邏輯會不斷的迭代,那麼單元測試也會跟著不斷的迭代,如果每次改單元測試都要花很多時間,那這個單元測試的可維護性就比較差了。其實把所有邏輯都塞在一個函數裡,我個人認為這樣子的程式碼對應的單元測試可維護性是比較差的,全部堆在一塊意味著每次的改動所帶來的單元測試的改動都需要兼顧全域性的影響。如果儘可能的拆分開來,可以實現單元測試的按需改動。
  3. 可讀性。最後的也是最重要的 就是單元測試的程式碼的可讀性了,一個無法讓人理解的單元測試其實和沒寫沒什麼區別,無法理解基本也以為著不可信賴和不可維護。我程式碼都看不懂怎麼信任你呢?所以保障程式碼的可讀性是很重要的。

​ 其實講了一些概念之後對怎麼樣寫好一個測試我們還是沒什麼印象的,那麼可以從一些不好的case去入手,我們知道了那些實踐是不好的之後,就會對好的實踐有一個大致的認識。

  1. 可讀性低的測試,上面寫到的對greeter函數的單元測試中,其實這段程式碼寫的不是很好的,可讀性比較低。因為對於讀這段程式碼的人來說,我都不知道這個「hi elliot」是什麼,他為什麼會出現在這裡。如果把他稍微命名成一個變數的話可讀性會高一些。
// 可讀性比較低,因為讀者並不知道這個「hi elliot」是什麼
assert.Equal(t, "hi elliot", greet)

// 這樣就會好一些
expectedGreetMsg := "hi elliot"
assert.Equal(t, expectedGreetMsg, greet)
  1. 帶有邏輯的測試。作為一個單元測試,應該儘量避免裡面帶有邏輯,如果有過多的邏輯在裡面,那麼就會演變成他本身也是需要測試的程式碼,因為過多的邏輯帶來了更多的不可信賴。
  2. 有錯誤處理的測試。在單元測試中不要帶有錯誤處理的邏輯,因為單元測試本身就是用來發現程式中的一些錯誤的,如果我們直接把panic給捕獲了,那麼也不知道程式碼是在哪裡錯的。另外對於單元測試來說錯誤應該也是一種預期的結果。
  3. 無法重現的測試。這個《單元測試的藝術》這本書裡提供了一個比較有意思的例子。在程式碼中使用了亂數進行測試,每次產生的亂數都不一樣,意味著每次測試的資料也就不一樣了,這意味著這個測試程式碼可信賴程度比較低。
  4. 單元測試之間儘量隔離。儘量做到每個單元測試之間的資料都是自己準備的,儘量不要共用一套東西,因為這樣做就意味著一個單元測試的成功與否與另外一個單元測試開始有了關聯,不可控的東西就增加了。舉個例子,nutsdb的單元測試有幾個全域性變數,其中大多數單元測試的db範例是共用的,如果上一個單元測試把db關閉了,或者修改了一些設定重啟了db,對於下一個單元測試來說他是不知道別人操作了什麼,等他執行的時候有可能就會出現意想不到的錯誤。
  5. 每一個單元測試都儘量獨立。每個單元測試儘量可以獨立執行。也不要有先後順序,不要在一個測試裡去呼叫另外一個測試。

在講完大概比較好的單元測試實踐之後,我們可以稍微提升一下。我們不妨假設有這麼一個場景,其實是測一段邏輯,但是會有好幾個測試用例需要測試,那麼我們需要寫好幾個測試的函數嘛?其實是不用的,這裡就涉及到了,引數化測試,什麼意思呢?我們直接舉例吧。看下面這段程式碼。

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迴圈裡面跑這三個測試用例。個人覺得這是一種比較好的測試方法。

3. go測試工具推薦

​ 在講完上面的一些測試方法之後,在這裡推薦一些在go裡面的測試工具。其中最著名的testify就是不得不推薦的了。很多開源專案都在用這個庫構建測試用例。說到這裡突然想到之前有人給goleveldb提交pr程式碼寫自己的單元測試時引入了這個庫,我還「批鬥」了他,說修改程式碼和引入新的庫是兩碼事,請你分開做hhhh,現在想想還蠻不好意思的。迴歸正題,我們來簡單介紹一些testify這個庫。

3.1 testify

​ testify這個庫主要有三個核心內容,assert, mock, suite。assert就是斷言,可以封裝了一些判斷是否相等,是否會有異常之類的。文章篇幅有限,這裡就不對assert的api一一介紹了,感興趣的朋友們可以看衍生閱讀的相關文章。這裡我主要介紹mock和suite模組。

3.1.1 mock

在我們要準備測試的時候經常需要準備一些資料,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())
}

3.1.2 suite

​ 有時候我們可能需要測的不是一個單獨的函數,是一個物件的很多方法,比如想對leveldb的一些主要方法進行測試,比如簡單的讀寫,範圍查詢,那麼如果每個功能的單元測試都寫成一個函數,那麼可能這裡會重複初始化一些東西,比如db。其實這裡是可以做到共用一些狀態的,比如資料寫入之後就可以測試把這個資料讀出來,或者範圍查詢。在這裡的話其實用一種比較緊密的方式把他們串聯起來會比較好。那麼suite套件就應運而生。這裡我就不打算在詳細介紹了,感興趣的讀者可以移步衍生閱讀中的《go每日一庫之testify》。我理解這篇文章講的比較清晰了。但是這裡的話我可以提供nutsdb的一個相關測試用例大家參考:https://github.com/nutsdb/nutsdb/blob/master/bucket_meta_test.go 大家感興趣的話也可以參考這段程式碼。

4. 總結

​ 這篇文章主要是總結最近我在單元測試上面的一些思考和沉澱,以及對go的測試工具的粗略講解。在本文中使用到的一些開源專案的原始碼,主要是分享一些自己的思考,希望對大家有所幫助。

延伸閱讀

  1. Best Practices for Testing in Go:https://fossa.com/blog/golang-best-practices-testing-go/
  2. 《單元測試的藝術》
  3. go每日一庫之testify:https://segmentfault.com/a/1190000040501767
  4. 使用testify和mockery庫簡化單元測試:https://segmentfault.com/a/1190000016897506