您好,登錄后才能下訂單哦!
?
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語言的測試框架能夠自動發現我們所有的測試用例,
測試函數的簽名也要遵循其特定的規則:
?
函數名必須以Test開頭,后面通常是待測方法的函數名
?
這些測試用例會在測試框架下并發地執行,并發度由go test時的-parallel參數指定。
?
?
?
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包里已經經過了測試,通常被認為是可信的。
?
我們會在后面的測試用例中看到類似的處理。
?
?
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終止。
?
?
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")
}
}
?
?
?
?
?
?
我們在第一部分已經見過了基本的單元測試框架,會寫自己的單元測試了。
?
可是要想寫出好的單元測試還不是那么簡單,有很多要素需要注意。
?
?
讓我們看這樣一個例子:
?
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的寫法,讓自己的生活更美好。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。