這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
序言
在軟體開發中,產品代碼的正確性通過測試代碼來保證,而測試代碼的正確性誰來保證?答案是毫無爭議的,肯定是程式員自己。這就要求測試代碼必須足夠簡單且表達力強,讓錯誤無處藏身。我們要有一個好鼻子,能夠嗅出測試的壞味道,及時的進行測試重構,從而讓測試代碼易於維護。筆者從大量的編碼實踐中感悟道:雖然能寫出好的產品代碼的程式員很牛,但能寫出好的測試代碼的程式員更牛,尤其對於TDD實踐。
要寫出好的測試代碼,必須精通相關的測試架構。對於Golang的程式員來說,至少需要掌握下面三個測試架構:
筆者將通過多篇文章來闡述這三個測試架構,同時對於GoStub架構還將進行二次開發實踐,以便高效的解決較複雜情境的打樁問題。
本文將主要介紹GoConvey架構的基本使用方法,從而指導讀者更好的進行測試實踐,最終寫出簡單優雅的測試代碼。
說明:本文的實踐都是在 Mac OS X 下進行,操作過程可能與讀者所在的系統略有差異。
GoConvey簡介
GoConvey類似於C/C++語言的測試架構GTest,是一款針對Golang的測試架構,可以管理和運行測試案例,同時提供了豐富的斷言函數,並支援很多 Web 介面特性。
Golang雖然內建了單元測試功能,並且在GoConvey架構誕生之前也出現了許多第三方測試架構,但沒有一個測試架構像GoConvey一樣能夠讓程式員如此簡潔優雅的編寫測試代碼。
安裝
在命令列輸入命令:
sudo go get github.com/smartystreets/goconvey
你會發現:
- 在$GOPATH/src目錄下新增了github.com子目錄,子目錄裡包含了GoConvey架構的庫代碼。
- 在/usr/local目錄下產生了子目錄go,同時在go目錄下包含了三個子目錄,分別是bin,pkg和src。
我們將/usr/local/go/bin拷貝到$GOPATH下:
cp -r bin $GOPATH
基本使用方法
我們通過一個案例來介紹GoConvey架構的基本使用方法,並對要點進行歸納。
產品代碼
我們實現一個判斷兩個字串切片是否相等的函數StringSliceEqual,主要邏輯包括:
- 兩個字串切片長度不相等時,返回false
- 兩個字串切片一個是nil,另一個不是nil時,返回false
- 遍曆兩個切片,比較對應索引的兩個切片元素值,如果不相等,返回false
- 否則,返回true
根據上面的邏輯,代碼實現如下所示:
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”的實現代碼有點不好理解:
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,則測試案例的執行結果日下:
=== RUN TestStringSliceEqual TestStringSliceEqual should return true when a != nil && b != nil 1 total assertion--- PASS: TestStringSliceEqual (0.00s)PASSok infra/alg 0.006s
上面的測試案例代碼有如下幾個要點:
- import goconvey包時,前面加點號".",以減少冗餘的代碼。凡是在測試代碼中看到Convey和So兩個方法,肯定是convey包的,不要在產品代碼中定義相同的函數名
- 測試函數的名字必須以Test開頭,而且參數類型必須為*testing.T
- 每個測試案例必須使用Convey函數包裹起來,它的第一個參數為string類型的測試描述,第二個參數為測試函數的入參(類型為*testing.T),第三個參數為不接收任何參數也不返回任何值的函數(習慣使用閉包)
- Convey函數的第三個參數閉包的實現中通過So函數完成斷言判斷,它的第一個參數為實際值,第二個參數為斷言函數變數,第三個參數或者沒有(當第二個參數為類ShouldBeTrue形式的函數變數)或者有(當第二個函數為類ShouldEqual形式的函數變數)
我們故意將該測試案例改為不過:
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) })}
測試案例的執行結果日下:
=== 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: true1 total assertion--- FAIL: TestStringSliceEqual (0.00s)FAILexit status 1FAIL 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語句對應一個測試案例,那麼一個函數的多個測試案例可以通過一個測試函數的多個Convey語句來呈現。
測試案例的執行結果如下:
=== 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)PASSok infra/alg 0.006s
Convey語句的嵌套
Convey語句可以無限嵌套,以體現測試案例之間的關係。需要注意的是,只有最外層的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) }) })}
測試案例的執行結果如下:
=== 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)PASSok infra/alg 0.006s
可見,Convey語句嵌套的測試日誌和Convey語句不嵌套的測試日誌的顯示有差異,筆者更喜歡這種以測試函數為單位多個測試案例集中顯示的形式。
Web 介面
GoConvey不僅支援在命令列進行自動化編譯測試,而且還支援在 Web 介面進行自動化編譯測試。想要使用GoConvey的 Web 介面特性,需要在測試檔案所在目錄下執行goconvey:
$GOPATH/bin/goconvey
這時彈出一個頁面,如所示:
goconvey-web.png
在 Web 介面中:
- 可以設定介面主題
- 查看完整的測試結果
- 使用瀏覽器提醒等實用功能
- 自動檢測代碼變動並編譯測試
- 半自動化書寫測試案例
- 查看測試覆蓋率
- 臨時屏蔽某個包的編譯測試
Skip
針對想忽略但又不想刪掉或注釋掉某些斷言操作,GoConvey提供了Convey/So的Skip方法:
- SkipConvey函數表明相應的閉包函數將不被執行
- SkipSo函數表明相應的斷言將不被執行
當存在SkipConvey或SkipSo時,測試日誌中會顯式打上"skipped"形式的標記:
- 當測試代碼中存在SkipConvey時,相應閉包函數中不管是否為SkipSo,都將被忽略,測試日誌中對應的符號僅為一個""
- 當測試代碼Convey語句中存在SkipSo時,測試日誌中每個So對應一個""或"✘",每個SkipSo對應一個"",按實際順序排列
- 不管存在SkipConvey還是SkipSo時,測試日誌中都有字串"{n} total assertions (one or more sections skipped)",其中{n}表示測試中實際已啟動並執行Assert 陳述式數
定製斷言函數
我們先看一下So的函數原型:
func So(actual interface{}, assert assertion, expected ...interface{})
第二個參數為assertion,它的原型為:
type assertion func(actual interface{}, expected ...interface{}) string
當assertion的傳回值為""時表示斷言成功,否則表示失敗,GoConvey架構中的相關代碼為:
const ( success = "" needExactValues = "This assertion requires exactly %d comparison values (you provided %d)." needNonEmptyCollection = "This assertion requires at least 1 comparison value (you provided 0).")
我們簡單實現一個assertion函數:
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") })}
根據ShouldSummerBeComming的實現,Convey語句中第一個So將斷言成功,第二個So將宣告失敗。
我們運行測試,查看執行結果,符合期望:
=== 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)FAILexit status 1FAIL infra/alg 0.006s
小結
Golang雖然內建了單元測試功能,但筆者建議大家使用已經成熟的第三方測試架構。本文主要介紹了GoConvey架構,通過文字結合程式碼範例講解基本的使用方法,要點歸納如下:
- import goconvey包時,前面加點號".",以減少冗餘的代碼
- 測試函數的名字必須以Test開頭,而且參數類型必須為*testing.T
- 每個測試案例必須使用Convey函數包裹起來,推薦使用Convey語句的嵌套,即一個函數有一個測試函數,測試函數中嵌套兩級Convey語句,第一級Convey語句對應測試函數,第二級Convey語句對應測試案例
- Convey語句的第三個參數習慣以閉包的形式實現,在閉包中通過So陳述式完成斷言
- 使用GoConvey架構的 Web 介面特性,作為命令列的補充
- 在適當的情境下使用SkipConvey函數或SkipSo函數
- 當測試中有需要時,可以定製斷言函數
至此,希望讀者已經掌握了GoConvey架構的基本用法,從而可以寫出簡單優雅的測試代碼。
然而,事情並沒有這麼簡單!試想,如果函數中多次調用了底層的同步操作,比如os包的exec函數,我們該如何寫測試代碼?
其實答案也並不複雜,我們將在下一篇文章中揭曉。