《分佈式對象存儲》做者手把手教你寫 GO 語言單元測試!

第一部分:如何寫Go語言單元測試

Go語言內建了單元測試(Unit Test)框架。這是爲了從語言層面規範寫UT的方式。 Go語言的命名規則會將以_test.go結尾的go文件視做單元測試代碼。 當咱們用go build構建可執行程序時,這些_test.go文件被排除在構建範圍以外。 而當咱們用go test來進行單元測試時,這些_test.go文件則會參與構建,且還會提供一個默認的TestMain函數做爲UT的起始入口。 接下來,就讓咱們經過一個例子來看看如何寫Go語言的單元測試。git

一個例子

首先讓咱們來看這樣一段代碼:程序員

package db
//db包實現了一個DB結構體用來封裝對某個數據庫的訪問

import (
	"someDB"
	//someDB提供了對實際數據庫的
	//insert/get/delete等函數
)
...

type DB struct {
//DB結構體的內部細節忽略
...
}

//DB結構體提供了Put/Get/Delete
//三個方法,具體實現略
func (d *DB)Put(key, value string) error {
...
	someDB.insert(...)
...
}

func (d *DB)Get(key string) (string, error) {
...
	return someDB.get(...)
}

func (d *DB)Delete(key string) error {
...
	someDB.delete(...)
...
}
複製代碼

從上面的代碼能夠看到咱們在db.go中實現了一個DB結構體用來抽象對某個數據庫的訪問。 如今,要爲DB寫UT,咱們一般會將測試代碼放在db_test.go中。 (雖然Go語言自己並不要求文件名的一一對應,可是這種約定俗成的命名規則能帶給咱們更好的可讀性)。github

package db
//UT的用例必須和代碼在同一個包內

import (
	"testing"
	//testing包提供了測試函數
	//必須用到的數據結構
	...
)

//咱們會爲DB結構體的每個
//方法都寫一個測試函數
//這裏先列出各測試函數的簽名
//具體實現後面會給出
func TestPut(t *testing.T) {
...
}

func TestGet(t *testing.T) {
...
}

func TestDelete(t *testing.T) {
...
}
複製代碼

爲了讓Go語言的測試框架可以自動發現咱們全部的測試用例, 測試函數的簽名也要遵循其特定的規則:數據庫

  1. 函數名必須以Test開頭,後面一般是待測方法的函數名
  2. 參數必須是*testing.T,它提供了Error,Fatal等方法用來報錯和終止測試的運行

這些測試用例會在測試框架下併發地執行,併發度由go test時的-parallel參數指定。編程

具體的UT實現

TestPut

func TestPut(t *testing.T) {
	//爲了進行測試,咱們首先要建立
	//一個DB結構體的實例,具體參數略
	d := NewDB(...)
	
	//咱們調用待測方法Put
	//將一些數據寫入數據庫
	err := d.Put("testputkey", "value")
	//必須檢查返回的錯誤,確保返回nil
	if err != nil {
		//用Error來打印錯誤信息
		t.Error(err)
	}

	//接下來咱們用someDB的get接口
	//來獲取這些數據,這裏注意儘可能
	//避免用待測的DB.Get方法
	//緣由見下
	value, _ := someDB.get(...)
	//校驗數據
	if value != "value" {
		t.Error("some msg")
	}
}
複製代碼

在獲取數據的時候不建議使用另外一個待測方法Get,這樣能夠避免測試污染。 所謂測試污染是指由非待測函數致使的失敗,好比TestPut的待測函數是DB.Put,若是咱們使用DB.Get方法來獲取數據,那麼DB.Get若是出錯就會致使測試用例失敗,而此時咱們須要額外的信息來判斷到底是Put出了問題仍是Get出了問題。而someDB.get方法在someDB包裏已經通過了測試,一般被認爲是可信的。 咱們會在後面的測試用例中看到相似的處理。bash

TestGet

func TestGet(t *testing.T) {
	d := NewDB(...)
	//首先測試Get不存在的key
	//儘量讓參數名字自解釋
	_, err := d.Get("testgetnonexist")
	if err != ErrNotFound {
		t.Error("some msg")
	}

	//用someDB的insert接口
	//來寫入一些測試數據
	err = someDB.insert(...)
	if err != nil {
		t.Fatal("some msg")
	}

	//而後調用待測方法Get讀取這些數據
	value, err := d.Get("testgetkey")
	if err != nil {
		t.Error("some msg")
	}

	//校驗數據
	if value != "value" {
		t.Error("some msg")
	}
}
複製代碼

Fatal和Error的區別在於Fatal在報錯後會當即終止當前用例繼續運行,若是insert失敗,則後續的Get也沒有意義,因此用Fatal終止。數據結構

TestDelete

func TestDelete(t *testing.T) {
	d := NewDB(...)
	//首先用someDB的insert接口
	//來寫入一些測試數據
	err := someDB.insert(...)
	if err != nil {
		t.Fatal("some msg")
	}

	//而後調用待測方法Delete
	//刪除這些數據
	err = d.Delete("testdeletekey")
	if err != nil {
		t.Error("some msg")
	}

	//用someDB的Get接口
	//來驗證數據的刪除
	_, err := someDB.get(...)
	if err != ErrNotFound {
		t.Error("some msg")
	}
}
複製代碼

運行測試的常見命令

  • 運行go test命令便可在編譯並執行當前目錄下的全部測試用例
  • 若是須要執行當前目錄以及全部子目錄中的測試用例,則運行命令go test ./...
  • 若是須要執行某個測試用例,好比單單執行TestGet用例則運行go test -run TestGet
  • 運行go test -help可查看詳細的參數列表,好比以前提到的-parallel參數等

第二部分:如何寫好GO語言單元測試

咱們在第一部分已經見過了基本的單元測試框架,會寫本身的單元測試了。 但是要想寫出好的單元測試還不是那麼簡單,有不少要素須要注意。併發

用斷言來代替原生的報錯函數

讓咱們看這樣一個例子:框架

if XXX {
	t.Error("msg")
}

if AAA != BBB {
	t.Error("msg2")
}
複製代碼

Go語言提供的Error太不友好了,判斷的if須要寫在前頭。 這對於咱們這些寫UT行數還要超過功能代碼的Go語言程序員來講,增長的代碼量是很是恐怖的。 使用斷言可讓咱們省略這個判斷的if語句,加強代碼的可讀性。 Go語言自己沒有提供assert包,不過有不少開源的選擇。好比使用https://github.com/stretchr/testify,上面的例子能夠簡化爲:函數

assert.True(t, XXX, "msg")
assert.Equal(t, AAA, BBB, "msg2")
複製代碼

除了True和Equal以外固然還有不少其它斷言,這就須要咱們本身看代碼或文檔去發現了

避免隨機結果

讓咱們看這樣一個例子:

a := rand.Intn(100)
b := rand.Intn(10)
result := div(a, b)
assert.Equal(t, a/b, result)
複製代碼

UT的結果應當是決定性(decisive)的,當咱們使用了隨機的輸入值來進行UT時,咱們讓本身的測試用例變得不可控。 當一切正常時,咱們還不會意識到這樣的壞處,然而當糟糕的事情發生時,隨機的結果讓咱們難以debug。 好比,上例在大多數時候都能正常運行,惟有當b隨機到0時會crash。在上例,比較正確的作法是:

result := div(6, 3)
assert.Equal(t, 2, result)
複製代碼

避免無心義重複

讓咱們看這樣一個例子:

n := 10000
for i:=0; i<n; i++ {
	doSomeThing()
	assertSomeThing()
}
複製代碼

在設計UT時,咱們要問問本身,重複執行doSomeThing屢次會帶來不一樣的結果嗎,若是老是一樣的結果,那麼doSomeThing只作一次就足夠了。 若是確實會出現不一樣的結果,那簡單重複10000次不只浪費了有限的CPU等資源,也比不上精心設計的不一樣斷言能給咱們帶來的更多好處。 在上例,比較正確的作法是:

doSomeThing()
assertSomeThing()
doSomeThing()
//斷言咱們在第二次doSomeThing時
//發生了不一樣的故事
assertSomeThingElse()
複製代碼

儘可能避免斷言時間的結果

讓咱們看這樣一個例子:

start := time.Now()
doSomeThing()
assert.WithinDuration(t, time.Now(), start, time.Second)
複製代碼

即使咱們很篤定doSomeThing()必定肯定以及確定能在1秒內完成,這個測試用例依然有很大可能在某個性能不好的容器上跑失敗。 除非咱們就是在測試Sleep之類跟時間有關的函數,不然對時間的斷言一般老是能被轉化爲跟時間無關的斷言。 必定要斷言時間的話,斷言超時比斷言及時更不容易出錯。 好比上面的例子,咱們沒辦法斷言它必定在1秒內完成,可是大概能斷言它在10微秒內完不成。

儘可能避免依賴外部服務

即便咱們十分確信某個公有云服務是在線的,在UT中依賴它也不是一個好主意。 畢竟咱們的UT不只會跑在本身的開發機上,也會跑在一些沙盒容器裏,咱們可沒法知道這些沙盒容器必定能訪問到這個公有云服務。若是訪問受限,那麼測試用例就會失敗。 要讓咱們的測試用例在任何狀況下都能成功運行,寫一個mock服務會是更好的選擇。 不過有些外部服務是必須依賴且沒法mock的,好比測試數據庫驅動時必須依賴具體的數據庫服務,對於這樣的狀況,咱們須要在開始UT以前設置好相應的環境。 此時也有一些須要注意的地方,見下節。

優雅地實行前置和後置任務

爲了設置環境或者爲了不測試數據污染,有時候有必要進行必定的前置和後置任務,好比在全部的測試開始的先後清空某個測試數據庫中的內容等。 這樣的任務若是在每一個測試用例中都重複執行,那不只是的代碼冗餘,也是資源的浪費。 咱們可讓TestMain來幫咱們執行這些前置和後置任務:

func TestMain(m *testing.M) {
	doSomSetup()
	r := m.Run()
	doSomeClear()
	os.Exit(r)
}
複製代碼

TestMain函數是Go測試框架的入口點,運行m.Run會執行測試。 TestMain函數不是必須的,除非確實有必要在m.Run的先後執行一些任務,咱們徹底能夠不實現這個函數。

測試用例之間相互隔離

TestA,TestB這樣的命名規則已經幫咱們在必定程度上隔離了測試用例,但這樣還不夠。 若是咱們的測試會訪問到外部的文件系統或數據庫,那麼最好確保不一樣的測試用例之間用到的文件名,數據庫名,數據表名等資源的隔離。 用測試函數的名字來作前綴或後綴會是一個不錯的方案,好比:

func TestA(t *testing.T) {
	f, err := os.Open("somefilefortesta")
	...
}

func TestB(t *testing.T) {
	f, err := os.Open("somefilefortestb")
	...
}
複製代碼

這樣隔離的緣由是全部的測試用例會併發執行,咱們不但願咱們的用例因爲試圖在同一時間訪問同一個文件而互相影響。

面向接口編程

這是典型的測試倒逼功能代碼。 功能代碼自己也許徹底不須要面向接口編程,一個具體的結構體就足夠完成任務。 但是當咱們去實現相應的單元測試時,有時候會發現構造這樣一個具體的結構體會十分複雜。 這種狀況下,咱們會考慮在實際代碼中使用接口(interface),並在單元測試中用一個mock組件來實現這個接口。 考慮以下代碼:

type someStruct struct {
	ComplexInnerStruct
}
複製代碼

咱們要爲這個someStruct寫UT,就不得不先構造出一個ComplexInnerStruct。 而這個ComplexInnerStruct可能依賴了幾十個外部服務,構造這樣一個結構體會是一件十分麻煩的事情。 此時咱們能夠這樣作,首先咱們修改實際的代碼,讓someStruct依賴某個接口而不是某個具體的結構體

type someStruct struct {
	someInterface
}
type someInterface interface {
	//只適配那些被用到的方法
	someMethod()
}
複製代碼

接下來咱們的UT就能夠用一個mock結構體來代替那個ComplexInnerStruct:

type mockStruct struct {}

func (m *mockStruct) someMethod() {
	...
}

s := &someStruct{
	someInterface: &mockStruct{},
}
複製代碼

這樣,咱們就幫本身省去了在UT中建立一個ComplexInnerStruct的繁雜工做。

結語

在工做中,咱們通常都會將UT加入編譯job做爲代碼提交流程的一部分。 有時咱們會發現本身或其餘同事寫的UT換個環境就冒出一些難以調查的隨機失敗。 重啓編譯job並向程序員之神祈禱有時候確實可讓一些隨機失敗再也不重現,但這只是掩蓋了失敗背後真正的問題。 做爲一個有鑽研精神的程序員,咱們不妨仔細調查錯誤的可能成因,改良代碼和UT的寫法,讓本身的生活更美好。

相關文章
相關標籤/搜索