一文說盡Golang單元測試實戰(zhàn)的那些事兒

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

本文結合了公司級漏洞掃描系統(tǒng)洞犀在DevOps上探索的經驗,以Golang為例,列舉了編寫單元測試需要的工具和方法,然后針對寫單測遇到的各種依賴問題,提出相應的解決辦法,并展示了自動化單元測試的結果。最后再和大家探討一下關于單元測試上的一些思考。
二、測試工具與方法
1.測試框架
相信大家都熟悉go內置了go test測試框架來執(zhí)行和管理測試用例。通過文件名_test.go結尾來表示測試文件,通過函數(shù)以Test開頭并只有一個參數(shù)*testing.T來表示一個測試函數(shù)。例如:
// sample_test.gopackage sample_testimport ( "testing" )func TestDownload(t *testing.T) {}func TestUpload(t *testing.T) {}
而其中測試框架testing的類型*T提供了一系列方法,例如主要會用到的下面三個方法:
t.Fatal:會讓測試函數(shù)立刻返回錯誤
t.Error:會輸出錯誤并記錄失敗,但任然會繼續(xù)運行
t.Log:輸出 debug 信息,go test -v參數(shù)下有效
除此之外,還有其他用的比較多的測試包。例如斷言包"github.com/stretchr/testify/assert",比如如果想判斷返回的錯誤是否是空,如果用原生方法會是:
if err != nilt.Errorf("got error %v", err)}
表格驅動測試通過定義一組不同的輸入,可以讓代碼得到充分的測試,同時也能有效地測試負路徑。 例如下面函數(shù)會判斷參數(shù)類型,如果是int就乘以二,如果是string就先轉成int然后乘以二,如果是其他類型就返回錯誤:
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方式替換實際依賴,并通過打樁操作其返回內容。
通過本地啟動一個模擬依賴環(huán)境,比如模擬redis服務等,然后直接訪問模擬服務。
下面幾小節(jié)詳細介紹了上述兩種辦法在不通場景下的應用,其中替換函數(shù)或方法、依賴接口類型和mysql數(shù)據庫依賴對應了第一種思路;訪問訪問http接口、mysql數(shù)據庫依賴和redis依賴對應了上面第二條思路。
四、訪問 http 接口
代碼里經常會遇到要訪問http接口的情況,這時如果在測試代碼里不做處理直接訪問,可能遇到環(huán)境不同訪問不通等問題。為此go標準庫內置了專門用于測試http服務的包net/http/httptest,不過我們這里并不用它來測試http服務,而是用來模擬要請求的http服務。
基本流程是先創(chuàng)建一個路由器,然后注冊一個響應函數(shù)用來模擬要請求的服務:
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ù)或方法
大家用的最多的應該就是monkey補丁庫了,可以用它來替換各種函數(shù)和方法,使用起來非常方便,這類庫原理大致相同,通過運行時用unsafe包替換函數(shù)地址來實現(xiàn)。比如https://github.com/agiledragon/gomon
key,不過這次我們用公司內部同源測試團隊封裝的monkey庫來演示ngmock。
首先是替換函數(shù),新建一個函數(shù)mock對象,參數(shù)有*testing.T和要mock的函數(shù)。比如被測函數(shù)需要調用db.New新建一個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í)行被測函數(shù)函數(shù)來驗證是否生效了,這里用到了上面提到的另一個測試框架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í)行被測函數(shù)convey.So(dbs, convey.ShouldResemble, &DBs{})convey.So(err, convey.ShouldNotBeNil)convey.So(err.Error(), convey.ShouldEqual, "fake err") // 驗證是否生效})})}
execCmdMock := ngmock.MockStruct(t, exec.Cmd{}, ngmock.Silent)defer execCmdMock.Finish()
mock模式主要有兩種:
Silent結構體內沒有mock的方法返回類型默認值,調用沒有mock的方法不會報錯,最后對調用方法的統(tǒng)計不會報錯。
KeepOrigin結構體內沒有mock的方法按照原方法邏輯返回數(shù)據,調用沒有mock的方法不會報錯,最后對調用方法的統(tǒng)計不會報錯。
在mock方法時,需要指定方法名,比如下面就mock了該結構體的Output方法,方法如果有參數(shù)的話,可以在后面加上參數(shù)。其他的就和前面一樣了。
execCmdMock.Mock("Output").Return([]byte("1"), nil)如果在MacOS上執(zhí)行測試遇到了permission denied的錯誤,這是 MacOS保護機制導致的,具體解決辦法見https://github.com/eisenx
p/macos-golink-wrapper 。
六、依賴接口類型
如果依賴的數(shù)據是接口類型,那么可以很方便的通過依賴注入的方式傳入測試用的接口實現(xiàn)來替換原始依賴。go 官方出品的gomock 可以根據接口定義自動生成相應實現(xiàn)的mock樁代碼:https://github.com/golang/
mock。gomock庫會有個二進制文件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ù)據庫依賴
數(shù)據庫依賴也是經常要遇到的一個問題,如何解決測試過程中的依賴呢?我這里總結了兩種辦法: 首先是sqlmock:https://github.com/DATA-DOG/go-sqlmock。看到mock字眼大家大概也知道它是怎么使用的了,也是通過對執(zhí)行sql語句打樁來完成測試。首先初始化mock對象,返回第一個是*sql.DB,用來傳給被測代碼依賴的db,第二個就是mock對象,用來設置打樁代碼。控制sqlDB的行為。
sqlDB, dbMock, err := sqlmock.New()具體使用項目文檔里有,我這里簡單說一下:比如下面一個函數(shù)執(zhí)行一些sql語句,先調用Begin創(chuàng)建事務,然后分別Query和Exec執(zhí)行sql,最后如果返回錯誤則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}
那么針對上面函數(shù),編寫測試用例如下。其中打樁代碼按照上面順序,希望先執(zhí)行Begin;然后執(zhí)行Query,并且希望sql語句滿足正則select .* from test并返回兩行結果;然后執(zhí)行Exec,希望 sql 滿足正則update test并返回錯誤;最后執(zhí)行Rollback。接下來執(zhí)行被測函數(shù),如果被測函數(shù)按照打樁代碼的順序執(zhí)行相應sql的話就會返回指定內容,否則就會報錯。
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í)行被測函數(shù)convey.So(err, convey.ShouldNotBeNil)convey.So(err.Error(), convey.ShouldEqual, "fake error") // 驗證打樁是否生效// 確認所有打樁都被調用convey.So(dbMock.ExpectationsWereMet(), convey.ShouldBeNil)})}
有時候我們的代碼不會直接使用*sql.DB,而是用到一些第三方 ORM 框架,那么需要想辦法讓這些框架使用我們的 mock db,比如對于 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是什么那該怎么辦?或者說被測函數(shù)有一堆sql語句,一個一個打樁起來實在是太麻煩。那么對于這種情況如果能有一個本地數(shù)據庫環(huán)境就好了,省去了打樁的麻煩,但是如果是mysql這種DB的話,本地建一個最快也是用容器跑才行。那么有沒有更輕量化的辦法呢?
可以本地臨時創(chuàng)建一個sqlite數(shù)據庫來代替當前依賴的數(shù)據庫比如mysql等,sqlite是可以在本地直接跑的輕量級數(shù)據庫,常見sql語句增刪改查什么的和mysql區(qū)別不大。不過需要注意的是目前所有的go sqlite驅動都是基于CGO的,因為sqlite使用C寫的。所以引用這些驅動會導致測試前程序編譯速度變慢和跨平臺支持問題,不過目前測試在MacOS和linux上是沒有問題的。
如下所示首先創(chuàng)建一個臨時的sqlite gorm框架DB,其中連接地址置空,這樣在關閉db之后數(shù)據庫也會自動刪除。之后就可以正常使用了。它底層使用的是這個驅動github.com/mattn/go-sqlite3。
import("gorm.io/driver/sqlite""gorm.io/gorm")db, err := gorm.Open(sqlite.Open(""), &gorm.Config{})
如果使用場景只是增刪改查什么的,問題不會很大,我目前遇到的和 mysql 不兼容的就是create table a like b這種 sql。而且如果不直接執(zhí)行 sql 而用框架取調用相關函數(shù)的話,兼容性會好很多。
八、redis 依賴
很多項目還會依賴redis數(shù)據庫,那么這種怎么解決依賴問題呢?可以使用miniredis庫解決問題:https://github.com/alicebob/miniredis 。
miniredis是一個純GO寫的測試用的redis服務,它支持絕大多數(shù)redis命令,具體可以看項目介紹。使用起來很簡單,直接調用Run函數(shù)啟動一個測試服務,服務對象的Addr()方法返回服務連接地址。接下來可以就可以拿著這個地址替換當前依賴了。
import "github.com/alicebob/miniredis/v2"import "github.com/go-redis/redis/v8"mr, err := miniredis.Run()addr := mr.Addr() // redis服務的tcp連接地址// 比如創(chuàng)建一個客戶端opt, err := redis.ParseURL("redis://:@" + mr.Addr())cli := redis.NewClient(opt)
九、執(zhí)行測試用例前后設置
有時需要在測試之前或之后進行額外的設置(setup)或拆卸(teardown),為此testing包提供了TestMain函數(shù)作為測試文件的入口。如下所示,該文件的測試用例都會在m.Run里運行,如果成功返回0否則非零,因此可以判斷執(zhí)行是否成功。值得注意的是最后應該使用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 ]")}}
十、忽略指定目錄
有時需要忽略指定目錄,例如自動生成的樁代碼,proto文件等,以提高覆蓋率,那么對于下面的測試命令: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`然后可以運行go tool cover -html=coverage_unit.out -o cover.html,生成網頁版報告,查看覆蓋率情況。當然還有一個比較tricky的方法,如果生成的樁代碼僅限于某個包內使用,那么直接把樁代碼文件名改成_test.go后綴的就行了。
十一、關于單元測試的思考
1.單測的意義
首先必須承認有了單元測試之后,增加了代碼質量的保障。而且在做修改和重構的時候,也能降低心智負擔,相信大家都體驗過對一堆沒有單測的代碼做修改時心里都會有點打怵,生怕改出什么問題。
但是對于沒有單元測試的人來說,剛開始寫單測無疑是讓人非常頭大,簡直寸步難行。因為已經維護的代碼可能在設計上就很難測試,各種耦合各種依賴沒有抽象混在一起,一行代碼成百上千行,這些都加深了接入單元測試的難度和工作量。而由于沒有質量保證又不敢動這些祖?zhèn)鞔a,從而導致陷入死循環(huán)。
但總得想辦法改變現(xiàn)狀,最近看了公司內部技術論壇的測試專題,之后也是跟各路大神學習到了一些東西。首先可以先讓重要邏輯代碼有測試。其次就是關注代碼設計問題,對新增代碼堅持寫單側,我在碼客上看到有前輩說,**UT 不是用來找BUG的,而是通過UT來改良設計,從而提升代碼質量,降低BUG數(shù)量。**反之如果UT不好寫,說明代碼結構混亂,出現(xiàn)BUG的概率也變高。
2.不能為了單測而單測
單元測試覆蓋率高真的可以確保質量嗎?是否能消除BUG?這個按我個人經驗其實是不能完全保證的。首先得考慮單測覆蓋代碼分支是否完備?有時候為了偷懶只測了主路徑,對于其他負路徑等沒有測試,那么肯定會有問題的。其次測試環(huán)境和線上實際環(huán)境的潛在差異可能也會導致代碼BUG沒測試出來。我遇到過在寫打樁代碼的時候,懶得校驗參數(shù),直接用mock.Any代替,導致做集成測試的時候發(fā)現(xiàn)參數(shù)傳錯了,寫這種單測除了浪費時間之外基本上也發(fā)現(xiàn)不了什么問題。
3.有沒有更好折中方案
有時候函數(shù)邏輯比較復雜導致插樁過程繁瑣,或者有些依賴不方便 mock,那么是否能在執(zhí)行測試用例的時候創(chuàng)建一個本地測試環(huán)境,里面包含了各種依賴,這樣或許會方便很多。比如上一節(jié)介紹解決依賴的辦法里有提到為了解決DB依賴,可以臨時創(chuàng)建一個sqlite數(shù)據庫,或者啟動一個容器來模擬執(zhí)行環(huán)境。
作者簡介
張力
騰訊后臺開發(fā)工程師,負責高危服務掃描系統(tǒng)建設。
推薦閱讀


