序言
在軟件開發(fā)中,產品代碼的正確性通過測試代碼來保證,而測試代碼的正確性誰來保證?答案是毫無爭議的,肯定是程序員自己。這就要求測試代碼必須足夠簡單且表達力強,讓錯誤無處藏身。我們要有一個好鼻子,能夠嗅出測試的壞味道,及時的進行測試重構,從而讓測試代碼易于維護。筆者從大量的編碼實踐中感悟道:雖然能寫出好的產品代碼的程序員很牛,但能寫出好的測試代碼的程序員更牛,尤其對于TDD實踐。
要寫出好的測試代碼,必須精通相關的框架。對于Golang程序員來說,至少需要掌握下面兩個框架:
本文將主要介紹GoConvey框架的基本使用方法,從而指導讀者更好的進行測試實踐,最終寫出簡單優(yōu)雅的測試代碼。
GoConvey簡介
GoConvey是一款針對Golang的測試框架,可以管理和運行測試用例,同時提供了豐富的斷言函數(shù),并支持很多 Web 界面特性。
Golang雖然自帶了單元測試功能,并且在GoConvey框架誕生之前也出現(xiàn)了許多第三方測試框架,但沒有一個測試框架像GoConvey一樣能夠讓程序員如此簡潔優(yōu)雅的編寫測試代碼。
安裝
在命令行運行下面的命令:
go get github.com/smartystreets/goconvey
運行時間較長,運行完后你會發(fā)現(xiàn):
- 在$GOPATH/src目錄下新增了github.com子目錄,該子目錄里包含了GoConvey框架的庫代碼
- 在$GOPATH/bin目錄下新增了GoConvey框架的可執(zhí)行程序goconvey
注:上面是在gopath時代使用GoConvey的API前的安裝方法,而在gomod時代一般不需要先顯式安裝(gomod機制會自動從goproxy拉取依賴到本地cache),除非要使用GoConvey的web界面,這時需要提前安裝GoConvey的二進制,命令為go install github.com/smartystreets/goconvey@latest。
基本使用方法
我們通過一個案例來介紹GoConvey框架的基本使用方法,并對要點進行歸納。
產品代碼
我們實現(xiàn)一個判斷兩個字符串切片是否相等的函數(shù)StringSliceEqual,主要邏輯包括:
- 兩個字符串切片長度不相等時,返回false
- 兩個字符串切片一個是nil,另一個不是nil時,返回false
- 遍歷兩個切片,比較對應索引的兩個切片元素值,如果不相等,返回false
- 否則,返回true
根據(jù)上面的邏輯,代碼實現(xiàn)如下所示:
func StringSliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
if (a == nil) != (b == nil) {
return false
}
for i, v := range a {
if v != b[i] {
return false
}
}
return true
}
對于邏輯“兩個字符串切片一個是nil,另一個不是nil時,返回false”的實現(xiàn)代碼有點不好理解:
if (a == nil) != (b == nil) {
return false
}
我們實例化一下a和b,即[]string{}和[]string(nil),這時兩個字符串切片的長度都是0,但肯定不相等。
測試代碼
先寫一個正常情況的測試用例,如下所示:
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestStringSliceEqual(t *testing.T) {
Convey("TestStringSliceEqual should return true when a != nil && b != nil", t, func() {
a := []string{"hello", "goconvey"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeTrue)
})
}
由于GoConvey框架兼容Golang原生的單元測試,所以可以使用go test -v來運行測試。
打開命令行,進入$GOPATH/src/infra/alg目錄下,運行go test -v,則測試用例的執(zhí)行結果日下:
=== RUN TestStringSliceEqual
TestStringSliceEqual should return true when a != nil && b != nil ?
1 total assertion
--- PASS: TestStringSliceEqual (0.00s)
PASS
ok infra/alg 0.006s
上面的測試用例代碼有如下幾個要點:
- import goconvey包時,前面加點號".",以減少冗余的代碼。凡是在測試代碼中看到Convey和So兩個方法,肯定是convey包的,不要在產品代碼中定義相同的函數(shù)名
- 測試函數(shù)的名字必須以Test開頭,而且參數(shù)類型必須為*testing.T
- 每個測試用例必須使用Convey函數(shù)包裹起來,它的第一個參數(shù)為string類型的測試描述,第二個參數(shù)為測試函數(shù)的入?yún)ⅲ愋蜑?testing.T),第三個參數(shù)為不接收任何參數(shù)也不返回任何值的函數(shù)(習慣使用閉包)
- Convey函數(shù)的第三個參數(shù)閉包的實現(xiàn)中通過So函數(shù)完成斷言判斷,它的第一個參數(shù)為實際值,第二個參數(shù)為斷言函數(shù)變量,第三個參數(shù)或者沒有(當?shù)诙€參數(shù)為類ShouldBeTrue形式的函數(shù)變量)或者有(當?shù)诙€函數(shù)為類ShouldEqual形式的函數(shù)變量)
我們故意將該測試用例改為不過:
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestStringSliceEqual(t *testing.T) {
Convey("TestStringSliceEqual should return true when a != nil && b != nil", t, func() {
a := []string{"hello", "goconvey"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeFalse)
})
}
測試用例的執(zhí)行結果日下:
=== RUN TestStringSliceEqual
TestStringSliceEqual should return true when a != nil && b != nil ?
Failures:
* /Users/zhangxiaolong/Desktop/D/go-workspace/src/infra/alg/slice_test.go
Line 45:
Expected: false
Actual: true
1 total assertion
--- FAIL: TestStringSliceEqual (0.00s)
FAIL
exit status 1
FAIL infra/alg 0.006s
我們再補充3個測試用例:
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestStringSliceEqual(t *testing.T) {
Convey("TestStringSliceEqual should return true when a != nil && b != nil", t, func() {
a := []string{"hello", "goconvey"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeTrue)
})
Convey("TestStringSliceEqual should return true when a == nil && b == nil", t, func() {
So(StringSliceEqual(nil, nil), ShouldBeTrue)
})
Convey("TestStringSliceEqual should return false when a == nil && b != nil", t, func() {
a := []string(nil)
b := []string{}
So(StringSliceEqual(a, b), ShouldBeFalse)
})
Convey("TestStringSliceEqual should return false when a != nil && b != nil", t, func() {
a := []string{"hello", "world"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeFalse)
})
}
從上面的測試代碼可以看出,每一個Convey語句對應一個測試用例,那么一個函數(shù)的多個測試用例可以通過一個測試函數(shù)的多個Convey語句來呈現(xiàn)。
測試用例的執(zhí)行結果如下:
=== RUN TestStringSliceEqual
TestStringSliceEqual should return true when a != nil && b != nil ?
1 total assertion
TestStringSliceEqual should return true when a == nil && b == nil ?
2 total assertions
TestStringSliceEqual should return false when a == nil && b != nil ?
3 total assertions
TestStringSliceEqual should return false when a != nil && b != nil ?
4 total assertions
--- PASS: TestStringSliceEqual (0.00s)
PASS
ok infra/alg 0.006s
Convey語句的嵌套
Convey語句可以無限嵌套,以體現(xiàn)測試用例之間的關系。需要注意的是,只有最外層的Convey需要傳入*testing.T類型的變量t。
我們將前面的測試用例通過嵌套的方式寫另一個版本:
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestStringSliceEqual(t *testing.T) {
Convey("TestStringSliceEqual", t, func() {
Convey("should return true when a != nil && b != nil", func() {
a := []string{"hello", "goconvey"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeTrue)
})
Convey("should return true when a == nil && b == nil", func() {
So(StringSliceEqual(nil, nil), ShouldBeTrue)
})
Convey("should return false when a == nil && b != nil", func() {
a := []string(nil)
b := []string{}
So(StringSliceEqual(a, b), ShouldBeFalse)
})
Convey("should return false when a != nil && b != nil", func() {
a := []string{"hello", "world"}
b := []string{"hello", "goconvey"}
So(StringSliceEqual(a, b), ShouldBeFalse)
})
})
}
測試用例的執(zhí)行結果如下:
=== RUN TestStringSliceEqual
TestStringSliceEqual
should return true when a != nil && b != nil ?
should return true when a == nil && b == nil ?
should return false when a == nil && b != nil ?
should return false when a != nil && b != nil ?
4 total assertions
--- PASS: TestStringSliceEqual (0.00s)
PASS
ok infra/alg 0.006s
可見,Convey語句嵌套的測試日志和Convey語句不嵌套的測試日志的顯示有差異,筆者更喜歡這種以測試函數(shù)為單位多個測試用例集中顯示的形式。
此外,Convey語句嵌套還有一種三層嵌套的慣用法,即按BDD風格來寫測試用例,核心點是通過GWT(Given…When…Then)格式來描述測試用例,示例如下:
func TestStringSliceEqualIfBothNotNil(t *testing.T) {
Convey("Given two string slice which are both not nil", t, func() {
a := []string{"hello", "goconvey"}
b := []string{"hello", "goconvey"}
Convey("When the comparision is done", func() {
result := StringSliceEqual(a, b)
Convey("Then the result should be true", func() {
So(result, ShouldBeTrue)
})
})
})
}
GWT測試用例的執(zhí)行結果如下:
=== RUN TestStringSliceEqualIfBothNotNil
Given two string slice which are both not nil
When the comparision is done
Then the result should be true ?
1 total assertion
--- PASS: TestStringSliceEqualIfBothNotNil (0.00s)
ok infra/alg 0.007s
按GWT格式寫測試用例時,每一組GWT對應一條測試用例,即最內層的Convey語句不像兩層嵌套時可以有多個,而是只能有一個Convey語句。
我們依次寫出其余三個用例的三層嵌套形式:
func TestStringSliceEqualIfBothNil(t *testing.T) {
Convey("Given two string slice which are both nil", t, func() {
var a []string = nil
var b []string = nil
Convey("When the comparision is done", func() {
result := StringSliceEqual(a, b)
Convey("Then the result should be true", func() {
So(result, ShouldBeTrue)
})
})
})
}
func TestStringSliceNotEqualIfNotBothNil(t *testing.T) {
Convey("Given two string slice which are both nil", t, func() {
a := []string(nil)
b := []string{}
Convey("When the comparision is done", func() {
result := StringSliceEqual(a, b)
Convey("Then the result should be false", func() {
So(result, ShouldBeFalse)
})
})
})
}
func TestStringSliceNotEqualIfBothNotNil(t *testing.T) {
Convey("Given two string slice which are both not nil", t, func() {
a := []string{"hello", "world"}
b := []string{"hello", "goconvey"}
Convey("When the comparision is done", func() {
result := StringSliceEqual(a, b)
Convey("Then the result should be false", func() {
So(result, ShouldBeFalse)
})
})
})
}
我們再將上面的四條用例使用測試套的形式來寫,即一個測試函數(shù)包含多條用例,每條用例使用Convey語句四層嵌套的慣用法:
func TestStringSliceEqual(t *testing.T) {
Convey("TestStringSliceEqualIfBothNotNil", t, func() {
Convey("Given two string slice which are both not nil", func() {
a := []string{"hello", "goconvey"}
b := []string{"hello", "goconvey"}
Convey("When the comparision is done", func() {
result := StringSliceEqual(a, b)
Convey("Then the result should be true", func() {
So(result, ShouldBeTrue)
})
})
})
})
Convey("TestStringSliceEqualIfBothNil", t, func() {
Convey("Given two string slice which are both nil", func() {
var a []string = nil
var b []string = nil
Convey("When the comparision is done", func() {
result := StringSliceEqual(a, b)
Convey("Then the result should be true", func() {
So(result, ShouldBeTrue)
})
})
})
})
Convey("TestStringSliceNotEqualIfNotBothNil", t, func() {
Convey("Given two string slice which are both nil", func() {
a := []string(nil)
b := []string{}
Convey("When the comparision is done", func() {
result := StringSliceEqual(a, b)
Convey("Then the result should be false", func() {
So(result, ShouldBeFalse)
})
})
})
})
Convey("TestStringSliceNotEqualIfBothNotNil", t, func() {
Convey("Given two string slice which are both not nil", func() {
a := []string{"hello", "world"}
b := []string{"hello", "goconvey"}
Convey("When the comparision is done", func() {
result := StringSliceEqual(a, b)
Convey("Then the result should be false", func() {
So(result, ShouldBeFalse)
})
})
})
})
}
Web 界面
GoConvey不僅支持在命令行進行自動化編譯測試,而且還支持在 Web 界面進行自動化編譯測試。想要使用GoConvey的 Web 界面特性,需要在測試文件所在目錄下執(zhí)行goconvey:
$GOPATH/bin/goconvey
這時彈出一個頁面,如下圖所示:
在 Web 界面中:
- 可以設置界面主題
- 查看完整的測試結果
- 使用瀏覽器提醒等實用功能
- 自動檢測代碼變動并編譯測試
- 半自動化書寫測試用例
- 查看測試覆蓋率
- 臨時屏蔽某個包的編譯測試
Skip
針對想忽略但又不想刪掉或注釋掉某些斷言操作,GoConvey提供了Convey/So的Skip方法:
- SkipConvey函數(shù)表明相應的閉包函數(shù)將不被執(zhí)行
- SkipSo函數(shù)表明相應的斷言將不被執(zhí)行
當存在SkipConvey或SkipSo時,測試日志中會顯式打上"skipped"形式的標記:
- 當測試代碼中存在SkipConvey時,相應閉包函數(shù)中不管是否為SkipSo,都將被忽略,測試日志中對應的符號僅為一個"?"
- 當測試代碼Convey語句中存在SkipSo時,測試日志中每個So對應一個"?"或"?",每個SkipSo對應一個"?",按實際順序排列
- 不管存在SkipConvey還是SkipSo時,測試日志中都有字符串"{n} total assertions (one or more sections skipped)",其中{n}表示測試中實際已運行的斷言語句數(shù)
定制斷言函數(shù)
我們先看一下So函數(shù)的聲明:
func So(actual interface{}, assert Assertion, expected ...interface{})
第二個參數(shù)為assert,是一個函數(shù)變量,它的類型Assertion的定義為:
type Assertion func(actual interface{}, expected ...interface{}) string
當Assertion的變量的返回值為""時表示斷言成功,否則表示失敗:
const assertionSuccess = ""
我們簡單實現(xiàn)一個Assertion函數(shù):
func ShouldSummerBeComming(actual interface{}, expected ...interface{}) string {
if actual == "summer" && expected[0] == "comming" {
return ""
} else {
return "summer is not comming!"
}
}
我們仍然在slice_test文件中寫一個簡單測試:
func TestSummer(t *testing.T) {
Convey("TestSummer", t, func() {
So("summer", ShouldSummerBeComming, "comming")
So("winter", ShouldSummerBeComming, "comming")
})
}
根據(jù)ShouldSummerBeComming的實現(xiàn),Convey語句中第一個So將斷言成功,第二個So將斷言失敗。
我們運行測試,查看執(zhí)行結果,符合期望:
=== RUN TestSummer
TestSummer ??
Failures:
* /Users/zhangxiaolong/Desktop/D/go-workspace/src/infra/alg/slice_test.go
Line 52:
summer is not comming!
2 total assertions
--- FAIL: TestSummer (0.00s)
FAIL
exit status 1
FAIL infra/alg 0.006s
小結
Golang雖然自帶了單元測試功能,但筆者建議讀者使用已經成熟的第三方測試框架。本文主要介紹了GoConvey框架,通過文字結合代碼示例講解基本的使用方法,要點歸納如下:
- import goconvey包時,前面加點號".",以減少冗余的代碼;
- 測試函數(shù)的名字必須以Test開頭,而且參數(shù)類型必須為*testing.T;
- 每個測試用例必須使用Convey語句包裹起來,推薦使用Convey語句的嵌套,即一個函數(shù)有一個或多個測試函數(shù),一個測試函數(shù)嵌套兩層、三層或四層Convey語句;
- Convey語句的第三個參數(shù)習慣以閉包的形式實現(xiàn),在閉包中通過So語句完成斷言;
- 使用GoConvey框架的 Web 界面特性,作為命令行的補充;
- 在適當?shù)膱鼍跋率褂肧kipConvey函數(shù)或SkipSo函數(shù);
- 當測試中有需要時,可以定制斷言函數(shù)。
至此,希望讀者已經掌握了GoConvey框架的基本用法,從而可以寫出簡單優(yōu)雅的測試代碼。
然而,事情并沒有這么簡單!試想,如果在被測函數(shù)中調用了底層rand包的Intn函數(shù),你會如何寫測試代碼?經過思考,你應該會發(fā)現(xiàn)需要給rand包的Intn函數(shù)打樁。如何低成本的滿足用戶各種測試場景的打樁訴求,這正是GoMonkey框架的專長。