一文說盡Golang單元測(cè)試實(shí)戰(zhàn)的那些事兒
導(dǎo)語(yǔ)?|?單元測(cè)試,通常是單獨(dú)測(cè)試一個(gè)方法、類或函數(shù),讓開發(fā)者確信自己的代碼在按預(yù)期運(yùn)行,為確保代碼可以測(cè)試且測(cè)試易于維護(hù)。騰訊后臺(tái)開發(fā)工程師張力結(jié)合了公司級(jí)漏洞掃描系統(tǒng)洞犀在DevOps上探索的經(jīng)驗(yàn),以Golang為例,列舉了編寫單元測(cè)試需要的工具和方法,然后針對(duì)寫單測(cè)遇到的各種依賴問題,詳細(xì)介紹了通過Mock的方式解決各種常用依賴,方便讀者在寫go語(yǔ)言UT的時(shí)候,遇到依賴問題,能夠快速找到解決方案。最后再和大家探討一下關(guān)于單元測(cè)試上的一些思考。
一、前言
單元測(cè)試,通常是單獨(dú)測(cè)試一個(gè)方法、類或函數(shù),讓開發(fā)者確信自己的代碼在按預(yù)期運(yùn)行,為確保代碼可以測(cè)試且測(cè)試易于維護(hù)。另一方面,DevOps里提倡自動(dòng)化測(cè)試,并且主張?jiān)皆绨l(fā)現(xiàn)代價(jià)越小。關(guān)于單元測(cè)試的更多思考,可以看看本文最后一節(jié)。

本文結(jié)合了公司級(jí)漏洞掃描系統(tǒng)洞犀在DevOps上探索的經(jīng)驗(yàn),以Golang為例,列舉了編寫單元測(cè)試需要的工具和方法,然后針對(duì)寫單測(cè)遇到的各種依賴問題,提出相應(yīng)的解決辦法,并展示了自動(dòng)化單元測(cè)試的結(jié)果。最后再和大家探討一下關(guān)于單元測(cè)試上的一些思考。
二、測(cè)試工具與方法
1.測(cè)試框架
相信大家都熟悉go內(nèi)置了go test測(cè)試框架來執(zhí)行和管理測(cè)試用例。通過文件名_test.go結(jié)尾來表示測(cè)試文件,通過函數(shù)以Test開頭并只有一個(gè)參數(shù)*testing.T來表示一個(gè)測(cè)試函數(shù)。例如:
// sample_test.gopackage sample_testimport ( "testing" )func?TestDownload(t?*testing.T)?{}func?TestUpload(t?*testing.T)?{}
而其中測(cè)試框架testing的類型*T提供了一系列方法,例如主要會(huì)用到的下面三個(gè)方法:
t.Fatal:會(huì)讓測(cè)試函數(shù)立刻返回錯(cuò)誤
t.Error:會(huì)輸出錯(cuò)誤并記錄失敗,但任然會(huì)繼續(xù)運(yùn)行
t.Log:輸出 debug 信息,go test -v參數(shù)下有效
除此之外,還有其他用的比較多的測(cè)試包。例如斷言包"github.com/stretchr/testify/assert",比如如果想判斷返回的錯(cuò)誤是否是空,如果用原生方法會(huì)是:
if err != nilt.Errorf("got error %v", err)}
表格驅(qū)動(dòng)測(cè)試通過定義一組不同的輸入,可以讓代碼得到充分的測(cè)試,同時(shí)也能有效地測(cè)試負(fù)路徑。?例如下面函數(shù)會(huì)判斷參數(shù)類型,如果是int就乘以二,如果是string就先轉(zhuǎn)成int然后乘以二,如果是其他類型就返回錯(cuò)誤:
func twice(i interface{}) (int, error) {switch v := i.(type) {case int:return v * 2, nilcase string:value, err := strconv.Atoi(v)if err != nil {return 0, errors.Wrapf(err, "invalid string num %s", v)}return value * 2, nildefault:return 0, errors.New("unknown type")}}
func Test_twice(t *testing.T) {type args struct {i interface{}}tests := []struct {name stringargs argswant intwantErr bool}{{name: "int",args: args{i: 10},want: 20,},{name: "string success",args: args{i: "11"},want: 22,},{name: "string failed",args: args{i: "aaa"},wantErr: true,},{name: "unknown type",args: args{i: []byte("1")},wantErr: true,},}for _, tt := range tests {func(t *testing.T) {err := twice(tt.args.i)if (err != nil) != tt.wantErr {error = %v, wantErr %v", err, tt.wantErr)return}if got != tt.want {got = %v, want %v", got, tt.want)}})}}
三、解決常見的依賴等問題
解決常見的依賴等問題目前有兩種思路:
通過mock方式替換實(shí)際依賴,并通過打樁操作其返回內(nèi)容。
通過本地啟動(dòng)一個(gè)模擬依賴環(huán)境,比如模擬redis服務(wù)等,然后直接訪問模擬服務(wù)。
下面幾小節(jié)詳細(xì)介紹了上述兩種辦法在不通場(chǎng)景下的應(yīng)用,其中替換函數(shù)或方法、依賴接口類型和mysql數(shù)據(jù)庫(kù)依賴對(duì)應(yīng)了第一種思路;訪問訪問http接口、mysql數(shù)據(jù)庫(kù)依賴和redis依賴對(duì)應(yīng)了上面第二條思路。
四、訪問?http?接口
代碼里經(jīng)常會(huì)遇到要訪問http接口的情況,這時(shí)如果在測(cè)試代碼里不做處理直接訪問,可能遇到環(huán)境不同訪問不通等問題。為此go標(biāo)準(zhǔn)庫(kù)內(nèi)置了專門用于測(cè)試http服務(wù)的包net/http/httptest,不過我們這里并不用它來測(cè)試http服務(wù),而是用來模擬要請(qǐng)求的http服務(wù)。
基本流程是先創(chuàng)建一個(gè)路由器,然后注冊(cè)一個(gè)響應(yīng)函數(shù)用來模擬要請(qǐng)求的服務(wù):
mux := http.NewServeMux()mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {writer.Write([]byte(`{"code":0,"msg":"ok"}`))})
server := httptest.NewServer(mux)defer server.Close()url?:=?server.URL
五、替換函數(shù)或方法
大家用的最多的應(yīng)該就是monkey補(bǔ)丁庫(kù)了,可以用它來替換各種函數(shù)和方法,使用起來非常方便,這類庫(kù)原理大致相同,通過運(yùn)行時(shí)用unsafe包替換函數(shù)地址來實(shí)現(xiàn)。比如https://github.com/agiledragon/gomon
key,不過這次我們用公司內(nèi)部同源測(cè)試團(tuán)隊(duì)封裝的monkey庫(kù)來演示ngmock。
首先是替換函數(shù),新建一個(gè)函數(shù)mock對(duì)象,參數(shù)有*testing.T和要mock的函數(shù)。比如被測(cè)函數(shù)需要調(diào)用db.New新建一個(gè)DB,那么下面就mock了db.New函數(shù)。
dbNewMock := ngmock.MockFunc(t, db.New)defer?dbNewMock.Finish()
dbNewMock.Mock(ngmock.Any()).Return(nil,?errors.New("fake?err")).AnyTimes()接下來就是執(zhí)行被測(cè)函數(shù)函數(shù)來驗(yàn)證是否生效了,這里用到了上面提到的另一個(gè)測(cè)試框架convey,convey.Convey同*T.Run(),convey.So是 assert。
func TestNewDBs(t *testing.T) {convey.Convey("TestNewDBs", t, func() {dbNewMock := ngmock.MockFunc(t, db.New)defer dbNewMock.Finish()convey.Convey("TestNewDBs failed", func() {dbNewMock.Mock(ngmock.Any()).Return(nil, errors.New("fake err")).AnyTimes()dbs, err := NewDBs(DbUrl{}) // 執(zhí)行被測(cè)函數(shù)convey.So(dbs, convey.ShouldResemble, &DBs{})convey.So(err, convey.ShouldNotBeNil)convey.So(err.Error(), convey.ShouldEqual, "fake err") // 驗(yàn)證是否生效})})}
execCmdMock := ngmock.MockStruct(t, exec.Cmd{}, ngmock.Silent)defer?execCmdMock.Finish()
mock模式主要有兩種:
Silent結(jié)構(gòu)體內(nèi)沒有mock的方法返回類型默認(rèn)值,調(diào)用沒有mock的方法不會(huì)報(bào)錯(cuò),最后對(duì)調(diào)用方法的統(tǒng)計(jì)不會(huì)報(bào)錯(cuò)。
KeepOrigin結(jié)構(gòu)體內(nèi)沒有mock的方法按照原方法邏輯返回?cái)?shù)據(jù),調(diào)用沒有mock的方法不會(huì)報(bào)錯(cuò),最后對(duì)調(diào)用方法的統(tǒng)計(jì)不會(huì)報(bào)錯(cuò)。
在mock方法時(shí),需要指定方法名,比如下面就mock了該結(jié)構(gòu)體的Output方法,方法如果有參數(shù)的話,可以在后面加上參數(shù)。其他的就和前面一樣了。
execCmdMock.Mock("Output").Return([]byte("1"),?nil)如果在MacOS上執(zhí)行測(cè)試遇到了permission denied的錯(cuò)誤,這是 MacOS保護(hù)機(jī)制導(dǎo)致的,具體解決辦法見https://github.com/eisenx
p/macos-golink-wrapper 。
六、依賴接口類型
如果依賴的數(shù)據(jù)是接口類型,那么可以很方便的通過依賴注入的方式傳入測(cè)試用的接口實(shí)現(xiàn)來替換原始依賴。go 官方出品的gomock 可以根據(jù)接口定義自動(dòng)生成相應(yīng)實(shí)現(xiàn)的mock樁代碼:https://github.com/golang/
mock。gomock庫(kù)會(huì)有個(gè)二進(jìn)制文件mockgen用來生成代碼,?比如文件里有一些接口定義:
// interfaces.go// Encoder 編碼器type Encoder interface {Encode(obj interface{}, w io.Writer) error}//go:generate?mockgen?-destination=./mockdata/interfaces_mock.go?-package=mockdata?-self_package=./mockdata?-source=interfaces.go
ctrl := gomock.NewController(t) // *testing.Tdefer ctrl.Finish()// mockdata 是上面生成的樁代碼目錄encoderMock?:=?mockdata.NewMockEncoder(ctrl)
codecMock.EXPECT().Encode(gomock.Any(), gomock.Any()).DoAndReturn(func(obj interface{}, w io.Writer) error {w.Write([]byte("test_data"))return nil})
七、mysql?數(shù)據(jù)庫(kù)依賴
數(shù)據(jù)庫(kù)依賴也是經(jīng)常要遇到的一個(gè)問題,如何解決測(cè)試過程中的依賴呢?我這里總結(jié)了兩種辦法:?首先是sqlmock:https://github.com/DATA-DOG/go-sqlmock。看到mock字眼大家大概也知道它是怎么使用的了,也是通過對(duì)執(zhí)行sql語(yǔ)句打樁來完成測(cè)試。首先初始化mock對(duì)象,返回第一個(gè)是*sql.DB,用來傳給被測(cè)代碼依賴的db,第二個(gè)就是mock對(duì)象,用來設(shè)置打樁代碼。控制sqlDB的行為。
sqlDB,?dbMock,?err?:=?sqlmock.New()具體使用項(xiàng)目文檔里有,我這里簡(jiǎn)單說一下:比如下面一個(gè)函數(shù)執(zhí)行一些sql語(yǔ)句,先調(diào)用Begin創(chuàng)建事務(wù),然后分別Query和Exec執(zhí)行sql,最后如果返回錯(cuò)誤則Rollback否則Commit。
func testFunc(db *sql.DB) error {tx, err := db.Begin()if err != nil {return err}defer func() {if err != nil {tx.Rollback()} else {tx.Commit()}}()rows, err := tx.Query("select * from test where id > 10")if err != nil {return err}defer rows.Close()for rows.Next() {// 省略}if _, err := tx.Exec("update test set num = num +1"); err != nil {return err}return nil}
那么針對(duì)上面函數(shù),編寫測(cè)試用例如下。其中打樁代碼按照上面順序,希望先執(zhí)行Begin;然后執(zhí)行Query,并且希望sql語(yǔ)句滿足正則select .* from test并返回兩行結(jié)果;然后執(zhí)行Exec,希望 sql 滿足正則update test并返回錯(cuò)誤;最后執(zhí)行Rollback。接下來執(zhí)行被測(cè)函數(shù),如果被測(cè)函數(shù)按照打樁代碼的順序執(zhí)行相應(yīng)sql的話就會(huì)返回指定內(nèi)容,否則就會(huì)報(bào)錯(cuò)。
func Test_testFunc(t *testing.T) {convey.Convey("Test_testFunc exec failed", t, func() {sqlDB, dbMock, err := sqlmock.New()convey.So(err, convey.ShouldBeNil)dbMock.ExpectBegin()// sql支持正則匹配dbMock.ExpectQuery("select .* from test").WillReturnRows(sqlmock.NewRows([]string{"id", "num"}).AddRow(1, 10).AddRow(2, 20))dbMock.ExpectExec("update test").WillReturnError(errors.New("fake error"))dbMock.ExpectRollback()err = testFunc(sqlDB) // 執(zhí)行被測(cè)函數(shù)convey.So(err, convey.ShouldNotBeNil)convey.So(err.Error(), convey.ShouldEqual, "fake error") // 驗(yàn)證打樁是否生效// 確認(rèn)所有打樁都被調(diào)用convey.So(dbMock.ExpectationsWereMet(), convey.ShouldBeNil)})}
有時(shí)候我們的代碼不會(huì)直接使用*sql.DB,而是用到一些第三方 ORM 框架,那么需要想辦法讓這些框架使用我們的 mock db,比如對(duì)于 gorm 框架,可以這么配置:
sqlDB, dbMock, err := sqlmock.New()// "gorm.io/driver/mysql"// "gorm.io/gorm"db, err = gorm.Open(mysql.New(mysql.Config{Conn: sqlDB}), &gorm.Config{})
談到gorm框架,那么問題來了,如果我不直接操作*sql.DB而是用的框架,但我不知道最后生成的sql是什么那該怎么辦?或者說被測(cè)函數(shù)有一堆sql語(yǔ)句,一個(gè)一個(gè)打樁起來實(shí)在是太麻煩。那么對(duì)于這種情況如果能有一個(gè)本地?cái)?shù)據(jù)庫(kù)環(huán)境就好了,省去了打樁的麻煩,但是如果是mysql這種DB的話,本地建一個(gè)最快也是用容器跑才行。那么有沒有更輕量化的辦法呢?
可以本地臨時(shí)創(chuàng)建一個(gè)sqlite數(shù)據(jù)庫(kù)來代替當(dāng)前依賴的數(shù)據(jù)庫(kù)比如mysql等,sqlite是可以在本地直接跑的輕量級(jí)數(shù)據(jù)庫(kù),常見sql語(yǔ)句增刪改查什么的和mysql區(qū)別不大。不過需要注意的是目前所有的go sqlite驅(qū)動(dòng)都是基于CGO的,因?yàn)閟qlite使用C寫的。所以引用這些驅(qū)動(dòng)會(huì)導(dǎo)致測(cè)試前程序編譯速度變慢和跨平臺(tái)支持問題,不過目前測(cè)試在MacOS和linux上是沒有問題的。
如下所示首先創(chuàng)建一個(gè)臨時(shí)的sqlite gorm框架DB,其中連接地址置空,這樣在關(guān)閉db之后數(shù)據(jù)庫(kù)也會(huì)自動(dòng)刪除。之后就可以正常使用了。它底層使用的是這個(gè)驅(qū)動(dòng)github.com/mattn/go-sqlite3。
import("gorm.io/driver/sqlite""gorm.io/gorm")db, err := gorm.Open(sqlite.Open(""), &gorm.Config{})
如果使用場(chǎng)景只是增刪改查什么的,問題不會(huì)很大,我目前遇到的和 mysql 不兼容的就是create table a like b這種 sql。而且如果不直接執(zhí)行 sql 而用框架取調(diào)用相關(guān)函數(shù)的話,兼容性會(huì)好很多。
八、redis?依賴
很多項(xiàng)目還會(huì)依賴redis數(shù)據(jù)庫(kù),那么這種怎么解決依賴問題呢?可以使用miniredis庫(kù)解決問題:https://github.com/alicebob/miniredis 。
miniredis是一個(gè)純GO寫的測(cè)試用的redis服務(wù),它支持絕大多數(shù)redis命令,具體可以看項(xiàng)目介紹。使用起來很簡(jiǎn)單,直接調(diào)用Run函數(shù)啟動(dòng)一個(gè)測(cè)試服務(wù),服務(wù)對(duì)象的Addr()方法返回服務(wù)連接地址。接下來可以就可以拿著這個(gè)地址替換當(dāng)前依賴了。
import "github.com/alicebob/miniredis/v2"import "github.com/go-redis/redis/v8"mr, err := miniredis.Run()addr := mr.Addr() // redis服務(wù)的tcp連接地址// 比如創(chuàng)建一個(gè)客戶端opt, err := redis.ParseURL("redis://:@" + mr.Addr())cli := redis.NewClient(opt)
九、執(zhí)行測(cè)試用例前后設(shè)置
有時(shí)需要在測(cè)試之前或之后進(jìn)行額外的設(shè)置(setup)或拆卸(teardown),為此testing包提供了TestMain函數(shù)作為測(cè)試文件的入口。如下所示,該文件的測(cè)試用例都會(huì)在m.Run里運(yùn)行,如果成功返回0否則非零,因此可以判斷執(zhí)行是否成功。值得注意的是最后應(yīng)該使用code作為os.Exit參數(shù)退出。
func TestMain(m *testing.M) {code := m.Run()if code == 0 {TearDone(true)} else {TearDone(false)}os.Exit(code)}func TearDone(isSuccess bool) {fmt.Println("Global test environment tear-down")if isSuccess {fmt.Println("[ PASSED ]")} else {fmt.Println("[ FAILED ]")}}
十、忽略指定目錄
有時(shí)需要忽略指定目錄,例如自動(dòng)生成的樁代碼,proto文件等,以提高覆蓋率,那么對(duì)于下面的測(cè)試命令:go test -v -covermode=count -coverprofile=coverage_unit.out '-gcflags=all=-N -l' ./... 如果要忽略掉mockdata目錄的話,后面加上grep -v mockdata即可:
go test -v -covermode=count -coverprofile=coverage_unit.out '-gcflags=all=-N -l' `go list ./... | grep -v /mockdata`然后可以運(yùn)行g(shù)o tool cover -html=coverage_unit.out -o cover.html,生成網(wǎng)頁(yè)版報(bào)告,查看覆蓋率情況。當(dāng)然還有一個(gè)比較tricky的方法,如果生成的樁代碼僅限于某個(gè)包內(nèi)使用,那么直接把樁代碼文件名改成_test.go后綴的就行了。
十一、關(guān)于單元測(cè)試的思考
1.單測(cè)的意義
首先必須承認(rèn)有了單元測(cè)試之后,增加了代碼質(zhì)量的保障。而且在做修改和重構(gòu)的時(shí)候,也能降低心智負(fù)擔(dān),相信大家都體驗(yàn)過對(duì)一堆沒有單測(cè)的代碼做修改時(shí)心里都會(huì)有點(diǎn)打怵,生怕改出什么問題。
但是對(duì)于沒有單元測(cè)試的人來說,剛開始寫單測(cè)無疑是讓人非常頭大,簡(jiǎn)直寸步難行。因?yàn)橐呀?jīng)維護(hù)的代碼可能在設(shè)計(jì)上就很難測(cè)試,各種耦合各種依賴沒有抽象混在一起,一行代碼成百上千行,這些都加深了接入單元測(cè)試的難度和工作量。而由于沒有質(zhì)量保證又不敢動(dòng)這些祖?zhèn)鞔a,從而導(dǎo)致陷入死循環(huán)。
但總得想辦法改變現(xiàn)狀,最近看了公司內(nèi)部技術(shù)論壇的測(cè)試專題,之后也是跟各路大神學(xué)習(xí)到了一些東西。首先可以先讓重要邏輯代碼有測(cè)試。其次就是關(guān)注代碼設(shè)計(jì)問題,對(duì)新增代碼堅(jiān)持寫單側(cè),我在碼客上看到有前輩說,**UT 不是用來找BUG的,而是通過UT來改良設(shè)計(jì),從而提升代碼質(zhì)量,降低BUG數(shù)量。**反之如果UT不好寫,說明代碼結(jié)構(gòu)混亂,出現(xiàn)BUG的概率也變高。
2.不能為了單測(cè)而單測(cè)
單元測(cè)試覆蓋率高真的可以確保質(zhì)量嗎?是否能消除BUG?這個(gè)按我個(gè)人經(jīng)驗(yàn)其實(shí)是不能完全保證的。首先得考慮單測(cè)覆蓋代碼分支是否完備?有時(shí)候?yàn)榱送祽兄粶y(cè)了主路徑,對(duì)于其他負(fù)路徑等沒有測(cè)試,那么肯定會(huì)有問題的。其次測(cè)試環(huán)境和線上實(shí)際環(huán)境的潛在差異可能也會(huì)導(dǎo)致代碼BUG沒測(cè)試出來。我遇到過在寫打樁代碼的時(shí)候,懶得校驗(yàn)參數(shù),直接用mock.Any代替,導(dǎo)致做集成測(cè)試的時(shí)候發(fā)現(xiàn)參數(shù)傳錯(cuò)了,寫這種單測(cè)除了浪費(fèi)時(shí)間之外基本上也發(fā)現(xiàn)不了什么問題。
3.有沒有更好折中方案
有時(shí)候函數(shù)邏輯比較復(fù)雜導(dǎo)致插樁過程繁瑣,或者有些依賴不方便 mock,那么是否能在執(zhí)行測(cè)試用例的時(shí)候創(chuàng)建一個(gè)本地測(cè)試環(huán)境,里面包含了各種依賴,這樣或許會(huì)方便很多。比如上一節(jié)介紹解決依賴的辦法里有提到為了解決DB依賴,可以臨時(shí)創(chuàng)建一個(gè)sqlite數(shù)據(jù)庫(kù),或者啟動(dòng)一個(gè)容器來模擬執(zhí)行環(huán)境。
作者簡(jiǎn)介
張力
騰訊后臺(tái)開發(fā)工程師,負(fù)責(zé)高危服務(wù)掃描系統(tǒng)建設(shè)。
推薦閱讀

