go 單元測試進階篇

騰訊雲加社群發表於2019-03-04

騰訊雲技術社群-掘金主頁持續為大家呈現雲端計算技術文章,歡迎大家關注!


作者介紹:熊訓德(英文名:Sundy),16年畢業於四川大學大學並加入騰訊。目前在騰訊雲從事hadoop生態相關的雲端儲存和計算等後臺開發,喜歡並專注於研究大資料、虛擬化和人工智慧等相關技術。

本文件說明go語言自帶的測試框架未提供或者未方便地提供的測試方案,主要是用於解決寫單元測試中比較頭痛的依賴問題。也就是偽造模式,經典的偽造模式有樁物件(stub),模擬物件(mock)和偽物件(fake)。比較幸運的是,社群有豐富的第三方測試框架支援支援。下面就對筆者親身試用並實踐到專案中的幾個框架做介紹:

1.gomock

godoc.org/github.com/…

gomock模擬物件的方式是讓使用者宣告一個介面,然後使用gomock提供的mockgen工具生成mock物件程式碼。要模擬(mock)被測試程式碼的依賴物件時候,即可使用mock出來的物件來模擬和記錄依賴物件的各種行為:比如最常用的返回值,呼叫次數等等。文字敘述有點抽象,直接上程式碼:

go 單元測試進階篇

dick.go中DickFunc依賴外部物件OutterObj,本示例就是說明如何使用gomock框架控制所依賴的物件。

func DickFunc( outterObj MockInterface,para int)(result int){
    fmt.Println("This init DickFunc")
    fmt.Println("call outter.func:")

    return outterObj.OutterFunc(para)
}複製程式碼

mockgen工具命令是:

mockgen -source {source_file}.go -destination {dest_file}.go

比如,本示例即是:

mockgen -source src_mock.go -destination dst_mock.go

執行完後,可在同目錄下找到生成的dst_mock.go檔案,可以看到mockgen工具也實現了介面:

go 單元測試進階篇

接下來就可以使用mockgen工具生成的NewMockInterFace來生產mock物件,使用這個mock物件。OutterFunc()這個函式,gomock在控制mock類時支援鏈式程式設計的方式,其原理和其他鏈式程式設計類似一直維持了一個Call物件,把需要控制的方法名,入參,出參,呼叫次數以及前置和後置動作等,最後使用反射來呼叫方法,所以這個Call物件是mock物件的代理。jmockit的早期版本也是jdk自帶的java.reflect.Proxy動態代理實現的(最近的版本是動態Instrumentation配合代理模式)。

go 單元測試進階篇

在本示例中只簡單的更改了返回值,拋磚引玉:

func TestDickFunc(t *testing.T ){
   mockCtrl := gomock.NewController(t)
//defer mockCtrl.Finish()

   mockObj := dick.NewMockMockInterface(mockCtrl)
   mockObj.EXPECT().OutterFunc(3).Return(10)

   result :=dick.DickFunc(mockObj,3)
   t.Log("resutl:",result)

}複製程式碼

使用go test命令執行這個單測

go 單元測試進階篇

從結果看:本來應該輸出3,最後輸出就是10,和其他語言mock框架相似,生產出來的Mock物件不用自己去重定義這麼麻煩。

更多示例可以檢視官網一個囊括gomock幾乎所有功能的例子:

godoc.org/github.com/…

2.httpexcept

由於go在網路架構上的優秀封裝,使得go在很多網路場景被廣泛使用,而http協議是其中重要部分,在面對http請求的時候,可以對http的client進行測試,算是mock的特殊應用場景。

看一個簡單的示例就輕鬆的看懂了:

func TestHttp(t *testing.T) {

    handler := FruitServer()

    server := httptest.NewServer(handler)
    defer server.Close()

    e := httpexpect.New(t, server.URL)

    e.GET("/fruits").
        Expect().
        Status(http.StatusOK).JSON().Array().Empty()
}複製程式碼

其中還支援對不同方法(包括Header,Post等)的構造以及返回值Json的自定義,更多細節檢視其官網

3.testify

還有一個testify使用起來可以說相容了《一》中的gocheck和gomock,但是其mock使用稍微有點煩雜,使用繼承tetify.Mock(匿名組合)重新實現需要Mock的介面,在這個介面裡使用者自己使用Called(反射實現)被Mock的介面。

《單元測試的藝術》中認為stub和mock最大的區別就依賴物件是否和被測物件有互動,而從結果看就是樁物件不會使測試失敗,它只是為被測物件提供依賴的物件,並不改變測試結果,而mock則會根據不同的互動測試要求,很可能會更改測試的結果。說了這麼多理論,但其實這兩種方法都不是割裂的,所以gomock框架除了像其名字一樣可以模擬物件以外,還提供了樁物件的功能(stub)。以其實現來說,更像是一個樁物件的注入。但是因為相容了多個有用的功能,所以其在社群最為火爆。

具體用法可參考其github主頁

4.go-sqlmock

還有一種比較常見的場景就是和資料庫的互動場景,go-sqlmock是sql模擬(Mock)驅動器,主要用於測試資料庫的互動,go-sqlmock提供了完整的事務的執行測試框架,最新的版本(16.11.02)還支援prepare引數化提交和執行的Mock方案。

比如有這樣的被測函式:

func recordStats(db *sql.DB, userID, productID int64) (err error) {
    tx, err := db.Begin()
    if err != nil {
        return
    }

    defer func() {
        switch err {
        case nil:
            err = tx.Commit()
        default:
            tx.Rollback()
        }
    }()

    if _, err = tx.Exec("UPDATE products SET views = views + 1"); err != nil {
        return
    }
    if _, err = tx.Exec("INSERT INTO product_viewers (user_id, product_id) VALUES (?, ?)", userID, productID); err != nil {
        return
    }
    return
}

func main() {

    db, err := sql.Open("mysql", "root@/root")
    if err != nil {
        panic(err)
    }
    defer db.Close()

    if err = recordStats(db, 1 , 5 ); err != nil {
        panic(err)
    }
}複製程式碼

單測時:

func TestShouldUpdateStats(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("mock error: `%s` ", err)
    }
    defer db.Close()

    mock.ExpectBegin()
    mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectExec("INSERT INTO product_viewers")
          .WithArgs(2, 3)
          .WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectCommit()

    if err = recordStats(db, 2, 3); err != nil {
        t.Errorf("exe error: %s", err)
    }

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Errorf("not implements: %s", err)
    }
}

//測試回滾
func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("mock error: `%s`", err)
    }
    defer db.Close()

    mock.ExpectBegin()
    mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectExec("INSERT INTO product_viewers")
           .WithArgs(2, 3)
           .WillReturnError(fmt.Errorf("some error"))
    mock.ExpectRollback()

    // 執行被測方法,有錯
    if err = recordStats(db, 2, 3); err == nil {
        t.Errorf("not error")
    }

    // 執行被測方法,mock物件
    if err := mock.ExpectationsWereMet(); err != nil {
        t.Errorf("not implements: %s", err)
    }
}複製程式碼

更多例子和詳情,請檢視官網:

github.com/DATA-DOG/go…

介紹了這麼多框架,最後需要說明的也可能最重要的是寫程式碼時就應該考慮程式碼是可被測試的。要使得單元測試容易寫,或者說程式碼容易被測,其實很重要的一個部分就是被測程式碼本身是容易被測的,也就是說在設計和編寫程式碼的時候就應該先想到相好如何單元測試,甚至有人提出可以先寫單元測試,再寫具體被測程式碼。因為一個介面(或者稱為單元)在被設計好後,它實現就確定了,實際效果也確定了。這種方式被稱作測試驅動開發(Test-Driven Development, TDD)。而對於已經寫好的程式碼,很大程度上不好測試,有一種方式是測試性重構,就是為了更好的測試而進行重構。這些一定程度上來說並瞭解這些框架更重要,有意向可以,可以查閱有關兩本書《單元測試的藝術(第2版)》《xUnit測試模式》

參考:
codethoughts.info/go/2015/04/…

nathany.com/go-testing-…

shinley.com/index.html

《單元測試的藝術》

《xUnit測試模式》

相關閱讀:

go單元測試基本篇
【騰訊TMQ】敏捷測試-快速俘虜產品&開發


此文已由作者授權騰訊雲技術社群釋出,轉載請註明文章出處;獲取更多雲端計算技術乾貨,可請前往騰訊雲技術社群

相關文章