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

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

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


Go語言內建了單元測試(Unit Test)框架。這是爲了從語言層面規範寫UT的方式。

Go語言的命名規則會將以_test.go結尾的go文件視作單元測試代碼。

當我們用go build構建可執行程序時,這些_test.go文件被排除在構建範圍之外。

而當我們用go test來進行單元測試時,這些_test.go文件則會參與構建,且還會提供一個默認的TestMain函數作爲UT的起始入口。

接下來,就讓我們通過一個例子來看看如何寫Go語言的單元測試。

一個例子


首先讓我們來看這樣一段代碼:

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語言本身並不要求文件名的一一對應,但是這種約定俗成的命名規則能帶給我們更好的可讀性)。

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包裏已經經過了測試,通常被認爲是可信的。


我們會在後面的測試用例中看到類似的處理。

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的寫法,讓自己的生活更美好。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章