<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

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

          共 11149字,需瀏覽 23分鐘

           ·

          2021-07-31 02:58


          導語 | 單元測試,通常是單獨測試一個方法、類或函數(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.go
          package sample_test
          import ( "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 != nil t.Errorf("got error %v", err)}

          但用assert包只需要一行代碼就可以實現(xiàn)上述功能,而且可以輸出具體錯誤代碼行:assert.Nil(t, err)。另外還有封裝了testing的測試框架https://github.com/smartystreets/goconvey,里面包含了子測試斷言等功能。

          2.表格驅動測試

          表格驅動測試通過定義一組不同的輸入,可以讓代碼得到充分的測試,同時也能有效地測試負路徑。 例如下面函數(shù)會判斷參數(shù)類型,如果是int就乘以二,如果是string就先轉成int然后乘以二,如果是其他類型就返回錯誤:


          func twice(i interface{}) (int, error) { switch v := i.(type) { case int:  return v * 2, nil case string:  value, err := strconv.Atoi(v)  if err != nil {   return 0, errors.Wrapf(err, "invalid string num %s", v)  }  return value * 2, nil default:  return 0, errors.New("unknown type") }}

          可以看到該函數(shù)有多個分支,如果要覆蓋到不同分支,就需要不同類型輸入,那么這就很適合表格驅動測試:

          func Test_twice(t *testing.T) { type args struct {  i interface{} } tests := []struct {  name    string  args    args  want    int  wantErr 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 {  t.Run(tt.name, func(t *testing.T) {   got, err := twice(tt.args.i)   if (err != nil) != tt.wantErr {    t.Errorf("twice() error = %v, wantErr %v", err, tt.wantErr)    return   }if got != tt.want {    t.Errorf("twice() got = %v, want %v", got, tt.want)   }  }) }}

          上面還用到了go test的子測試功能t.Run(name string, subTest func(t *T))。如果想在一個測試函數(shù)里面執(zhí)行多個測試用例,例如要同時測試一個函數(shù)的返回成功和失敗等各種情況,那么可以使用子測試來區(qū)分不同情況。

          另外,上面表格測試代碼框架是用Goland自動生成的,自己只需要填寫tests數(shù)組就行了。點擊函數(shù)名然后右鍵,選擇generate,然后選擇test for function就會自動生成測試函數(shù)了。不過上面生成的函數(shù)沒有校驗返回的錯誤內容,如有需要可以自己稍微修改一下。

          三、解決常見的依賴等問題


          解決常見的依賴等問題目前有兩種思路:


          • 通過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"}`))})

          接著啟動這個服務,httptest會真的在localhost啟動它,然后這個URL就是要訪問的服務地址了。

          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()

          然后在執(zhí)行被測函數(shù)之前,設置mock函數(shù)接收什么參數(shù),并且要返回什么,比如下面指定接收一個任意參數(shù)并且讓db.New返回指定錯誤。該設置默認只會生效一次,如果要生效多次或者一直生效可以配置次數(shù)。

          dbNewMock.Mock(ngmock.Any()).Return(nilerrors.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") // 驗證是否生效  }) })}

          可以看到,mock依賴函數(shù)之后執(zhí)行被測函數(shù),會返回我們設置的錯誤fake error,在調用完成獲得返回錯誤之后可以判斷一下是否是我們設置的錯誤。還可以mock結構體方法,使用方式和上面類似,第二個參數(shù)傳結構體或者指針,第三個是mock模式:

          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

          ?


          可以執(zhí)行mockgen來生成上述接口,具體命令如上,-destination指定生成文件名,-package是生成文件包名,-self_package指定生成的包路徑,-source就是源接口文件路徑名。如果最后不指定接口名的話,會生成所有接口或者可以指定要生成的接口,多個用逗號連接。 當然也可以讀取標準庫的接口:mockgen database/sql/driver Conn,Driver樁代碼生成好了之后,就可以調用代碼里類似 NewMockXXXX(ctrl)方法來創(chuàng)建mock對象,如下所示,這樣創(chuàng)建的encoderMock實現(xiàn)了上面的Encoder接口,接下來就用這個encoderMock來初始化被測函數(shù)依賴的接口即可。

          ctrl := gomock.NewController(t) // *testing.Tdefer ctrl.Finish()// mockdata 是上面生成的樁代碼目錄encoderMock := mockdata.NewMockEncoder(ctrl)

          在調用被測函數(shù)之前,需要先打樁:我們希望如果encoderMock在執(zhí)行Encode方法時傳入會兩個指定參數(shù),那么就執(zhí)行指定的函數(shù)并返回:

          codecMock.EXPECT().Encode(gomock.Any(), gomock.Any()).DoAndReturn(func(obj interface{}, w io.Writer) error { w.Write([]byte("test_data")) return nil})

          接下來執(zhí)行被測函數(shù),當實際調用到Encode方法時,就會執(zhí)行我們設置的函數(shù)。看起來和上面一節(jié)的替換函數(shù)和方法類似是吧?這種希望當調用函數(shù)Encode()并且參數(shù)一致,那么就執(zhí)行指定邏輯的方式,就是打樁(stub)。打樁過程還可以配置執(zhí)行次數(shù)和執(zhí)行順序等,如果不知道打樁函數(shù)具體會被傳入什么參數(shù)可以用gomock.Any()來代替。通過打樁可以控制依賴接口的行為,解決測試時接口依賴的問題。

          七、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)建設。


          推薦閱讀


          燃爆盛夏 | 騰訊極客挑戰(zhàn)賽正式開啟!
          你一定聽過這些不太標準的技術圈發(fā)音...
          系統(tǒng)如何設計才能更快地查詢到數(shù)據?
          前以色列國防軍安全技術成員教你做好 Serverless 追蹤
          替代Docker,登上頂刊,這款開源沙箱牛在哪里?



          瀏覽 67
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  中文字幕 日韩有码 | 亚洲欧美精品日韩在线观看 | 亚洲AV综合伊人 | 国产精品成人毛片 | 免费看黃色AAAAAA 片 |