一文了解一線網際網路大廠的 Golang 單測最佳實戰經驗

張哥說技術發表於2023-01-31


Go 單測實戰篇:Golang 單測最佳實戰經驗

一文了解一線網際網路大廠的 Golang 單測最佳實戰經驗

深入理解 Go 單測中 stub 和 mock 的原理

Go 單測裡面,最常見的就是透過 gomonkey(stub) 打樁或者 mocker(mock) 的模擬來替換掉我們原本的執行邏輯,因此首先我們要對這兩種方式有一個比較深入的理解,要理解為何 Go 單測的時候能夠替換掉原來的方法!!!

gomonkey(stub) 打樁的原理和細節

打樁就是編寫或生成樁程式碼,Go 裡面用的最多的打樁的庫是 gomonkey 庫早期我們團隊使用 gomonkey 庫非常多,但是後面經過內部團隊的討論,最終因為 gomonkey 存在的一些問題,轉而開始使用 mock 的方式。但即便如此,在業界,使用 gomonkey 還是依然非常多

gomonkey 作為一個打樁的工具,使用場景還是比較廣泛,可以使用在我們大部分的應用場景。但是,它依然還是有很多限制,它必須要找到該方法對應的真實的類(結構體):

  • • gomonkey 必須禁用 golang 編譯器的內聯最佳化,不然函式被內聯之後,就找不到接縫了,stub 無法進行。一般我們是透過 go test 的時候帶上 '-gcflags=all=-N -l' 來禁用內聯最佳化。

  • • gomonkey 需要很高的系統許可權,因為在執行時替換函式入口是一個許可權要求較高的事情,在一個安全的系統上,比如在10.15+的macos上,這一點就是很難做到的。

  • • gomonkey 不支援異包未匯出函式的打樁、不支援同包未匯出方法的打樁

mocker(mock) 模擬的原理和細節

Mock 是在測試過程中,對於一些不容易構造/獲取的物件,建立一個Mock 物件來模擬物件的行為。Mock 最大的功能是幫你把單元測試進行解耦透過 mock 模擬的機制,生成一個模擬方法,然後替換呼叫原有程式碼中的方法,它其實是做一個真實的環境替換掉業務本需要的環境。

透過 mock 可以實現:

  • • 驗證這個物件的某些方法的呼叫情況,呼叫了多少次,引數是什麼,返回值是什麼等等

  • • 指定這個物件的某些方法的行為,返回特定的值,或者是執行特定的動作等等

Go 官方有一個 gomock 和 mocker,但是隻能模擬 interface 方法,這就要求我們業務編寫程式碼的時候具有非常好的介面設計,這樣才能順利生成 mock 程式碼。

mock 的大致原理是,在編譯階段去確定要呼叫的物件在 runtime 時需要指向的 mock 類,也就是改變了執行時函式指標的指向。對於介面 interface 的 mock,我們透過 gomock or mocker 庫來幫我們自動生成符合介面的類併產生對應的檔案,然後需要透過 gomock or mocker 約定的 API 就能夠對 interface 中的函式按我們自己所需要的方式來模擬。這樣,runtime 執行時其實就可以指向 mock 的 interface 實現來滿足我們的單測訴求。

測試用例編寫的最佳方式

非常簡單的邏輯可以採用 assert 庫

比較結果的時候,不要直接判斷 A 是否 等於 B,而需要採用 assert 方式 :

最差實踐:
func TestAdd(t *testing.T) {
    actual := 2 + 2
    expected := 4
    if (actual != expected) {
        t.Errorf("Expected %d, but got %d", expected, actual)
    }
}


最優實踐:
func TestAdd(t *testing.T) {
    actual := 2 + 2
    expected := 4
    assert.Equal(t, expected, actual)
}

推薦使用表驅動的方式

Table Driven 表驅動測試方法,就是把測試的輸入和和期望的輸出都寫在一起組成一個 struct 陣列,陣列中的每條記錄都是一個含有輸入和期望值的完整測試用例,這種方式可以使我們的測試更加清晰和簡練,減少了複製貼上,並大大提高的測試程式碼的可讀性。業界很多開源專案都是表驅動測試方法,比如etcd 的表驅動測試示例、Goland 自帶的單測生成工具也是表驅動。

表驅動的方式來實現的具體使用就是,如果要測試多個條件,則用 struct 陣列的形式:

func TestAdd(t *testing.T) {
    cases := []struct {
        A, B, Expected int
    }{
        {1, 1, 2},
        {1, -1, 0},
        {1, 0, 1},
        {0, 0, 0},
    }

    for _, tc := range cases {
        t.Run(fmt.Sprintf("%d + %d", tc.A, tc.B), func(t *testing.T) {
            t.Parallel()
            assert.Equal(t, t.Expected, tc.A+tc.B)
        })
    }
}

推薦使用 convey 包來進行測試

表驅動的方式,可以很好透過批次輸入輸出的方式來執行不同的單測邏輯,但是,他們的定義和斷言並沒有放在一起,閱讀起來並沒有那麼直觀。並且表驅動的方式如果有測試用例的話,那麼可能導致在我們的 IDE 上屏都展現不完,也就是比較佔地方。

如果想要可讀性更好的測試方式的話,可以使用 "github.com/smartystreets/goconvey/convey",它的可讀性會更好點,利用 convey 可以讓我們的單測程式碼變得更為優雅和簡潔。這個也是我在實際工作中用到的最多的方式:

package alarm

import (
    "testing"

    . "github.com/smartystreets/goconvey/convey"
)

func Test_subs(t *testing.T) {
    Convey("Test_subs", t, func() {
        Convey("case subs len is less", func() {

            index := subs("123456", 3)
            So(index, ShouldEqual, "123")
        })
        Convey("case subs len is more", func() {
            index := subs("123456", 10)
            So(index, ShouldEqual, "123456")
        })
    })
}

採用 sub-tests 架構來測試一個方法中的多個分支

比如,一個方法中,要測試成功的 case、失敗的 case 等多個不同的分支,那麼不要每一個 case 一個測試方法,而是在一個測試方法中執行不同的邏輯:

func TestSomeFunction(t *testing.T) {
    t.Run("success", func(t *testing.T){
        //...
    })

    t.Run("wrong input", func(t *testing.T){
        //...
    })
}

當然, 如果用我推薦的 Convey 包來測試的話,同樣也是這樣的效果

func TestSomeFunction(t *testing.T) {
    Convey("TestSomeFunction", t, func() {
        Convey("case success", func() {
                //...
        })
        Convey("case wrong input", func() {
                //...
        })
    })
}

是不是看著就很順暢 ?如果有同樣的看法,那麼就果斷用起來吧

mock 的使用經驗說明

瞭解自己的測試意圖

測試意圖是說,單測裡面主要是測自己寫的業務邏輯,不要把單測精力放在 RPC 介面上的測試,像 redis、mysql 這些外部網路請求操作都可以 mock 掉,但是自己寫的業務邏輯,一定不能 mock,一定要有相對詳細的測試。

為什麼?因為我們這裡說的是單元測試,不是介面測試也不是整合測試,單元測試就是要層我們自己寫的一個個的小函式、小方法,而這些函式里面的邏輯是你可以控制的,並且需要經常修改的,因此容易出錯,所以,要測試。

而外部網路請求如 RPC 介面呼叫或者資料庫請求,過程是你無法控制的,要麼成功、要麼失敗,你要測試的邏輯是成功後你要返回什麼,失敗後,要你要返回什麼,只要關心並測試網路請求的結果即可。因此外部依賴的請求,我們一般推薦使用 mock 的發方式來解決,從而減少對外部的真正依賴。mock 的時候,通常我們 mock 出兩個 case,一個是成功的 case,一個是失敗的 case。

初期大家會覺得寫單測麻煩,耗時太長,花那麼多時間去寫單測覺得心累,但是隻要堅持一段時間,就會發現,只要前面做好了,後面寫單測會非常簡單,因為套路都是一樣的,前面已經有經驗了。並且更為重要的是,如果想要最佳化程式碼或者重構程式碼,有單測的時候,可以極大的減少 bug。

不要過度依賴 mock

目前很多同學搞單測,其實都是為了單測覆蓋度,然後大量使用了 mock,因為這樣最容易跑完覆蓋度,但是這樣的單測其實並沒有真正達到單測的目的。所以單測的時候,建議儘可能的減少 mock,讓單測跑最真實的程式碼只有外部依賴的情況下才使用 mock。這裡可以參考 千萬不要過度依賴於 MOCK!,過度使用 Mock 可能帶來以下三個問題:

  • • 讓測試程式碼更難以理解

  • • 測試用例更難維護

  • • 測試用例無法保證程式碼能正常工作

適合 mock 的場景

如下這些場景的情況下,比較適合使用 mock :

  • • 物件的狀態難以建立或者重現,比如網路錯誤或者檔案讀寫錯誤等,因為我們無法控制外部請求的狀況,因此比較適合 mock

  • • 物件方法上的執行太慢,比如在測試開始之前初始化資料庫,而我們的單測執行的耗時要求是儘量的快,所以非常耗時的操作,我們可以 mock 掉。

  • • 該物件提供非確定性的結果,比如當前的時間,Go 裡面經常會用到 time 包,而這個就比較適合 mock。

Go 單測的基本準則

要寫合適並且有意義的單測

如果函式非常簡單比如就 2-3 行程式碼,或者說是一個行內函數,這種情況下,我們如果有絕對的自信,說這個函式不會出問題,那麼我們可以不寫這個函式對應的單測

對於一些業務核心的邏輯,必須要寫完整的單測。對於一些內部的計算邏輯、拼接邏輯,必須要寫單測。

寫單測的目的是為了讓我們的程式碼減少 bug,並且方面我們對程式碼最最佳化、做重構。如果我們寫一堆無用的單測,那麼就沒有任何意義,我們寫單測並不能只是為了跑單測覆蓋度,而是要真正的幫助我們提高程式碼質量。

合適的單測命令

如下的單測命令,可以列印詳細資訊,計算單測覆蓋率,同時透過 gcflags=all=-N -l' 來覆蓋所有在 GOPATH 中的包,並且禁用了內聯最佳化。

go test `go list ./... |grep -v api_test` -v -run='^Test' -covermode=count -gcflags=all=-l ./...

內聯最佳化一般用於能夠快速執行的函式,因為在這種情況下函式呼叫的時間消耗顯得更為突出,同時內聯體量小的函式也不會明顯增加編譯後的執行檔案佔用的空間。Go 中,函式體內包含:閉包呼叫,select ,for ,defer,go 關鍵字的的函式不會進行內聯。並且除了這些,還有其它的限制。

或者 :

go test -v  -covermode=count -coverprofile=cover.out.tmp -coverpkg=./... -gcflags='all=-N -l' ./...

單測的覆蓋率

在我們團隊,甚至包括整個公司,都對單測的覆蓋率有強要求。我們團隊都是透過 DevOps 流水線進行強制校驗,我們提交程式碼 MR 的時候,想要分支程式碼合入主幹,必須覆蓋率要達到指定的比例才能合入。這裡的覆蓋率包括:

  • • 全量覆蓋率,是指整個專案工程的所有的程式碼的覆蓋率,要達到 50%

  • • 增量覆蓋率,是指你這次提交的程式碼的覆蓋率,要達到 50%

單測也需要進行 Code Review

常規的,我們對程式碼的提交,肯定需要有 CR(Code Review)的過程,只有 CR 透過了,才能合入 master。但是大多數情況下,我們只會 CR 業務程式碼,不會 CR 單測,但是,單測也有必要 CR,要讓團隊大家寫單測的方式方法、準確性都保持統一。

如果團隊從沒有寫過單測,怎麼推動?

如果團隊從沒有寫過單測,那麼我們怎麼推動?我們最初,也是從無到有的過程,並且中間踩了很多坑,最初大家有很多怨言,到現階段,我們團隊已經都能夠很好的接受並且確實讓我們的程式碼質量有所提高。

為了避免大家踩同樣的坑,我的建議是:

  • • leader 首先要做好調研和宣講,包括單測的優勢、業內的使用情況

  • • 找 1-2 個高階別的同學帶頭,先進行嘗試

  • • 試點之後逐步推動到其他同學

  • • 定期做好覆盤,給出正向、反向的反饋,總結經驗

  • • 剛開始不要求單測覆蓋率一定達到多少,先把流程跑起來

  • • 後期大家都接受並且認可之後,根據團隊情況,規定合適的單測覆蓋度

其他常見的 mock 庫

資料庫相關操作採用 go-sqlmock 這個 mock 庫

針對資料庫的操作,推薦我們使用 sqlmock 這個庫來進行 mock。go-sqlmock 本質是一個實現了 sql/driver 介面的 mock 庫,它的設計目標是支援在測試中,模擬任何 sql driver 的行為,而不需要一個真正的資料庫連線。我們在單測過程中,不要直連真正的資料庫有如下幾個原因:

  • • 在單測的時候,可能根本就沒有許可權連線(比如,缺乏賬號密碼啥的)

  • • 即便連線上了,那麼也不應該真正運算元據庫,因為這個可能會對資料庫造成一些壓力甚至是髒資料,尤其是寫操作;再者,直連資料庫會導致單測耗時較長;

使用起來也比較簡單,示例如下,詳細的可以參考 裡面的詳細使用:

import (
    "fmt"
    "testing"

    "github.com/DATA-DOG/go-sqlmock"
)

// a successful case
func TestShouldUpdateStats(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("an error '%s' was not expected when opening a stub database connection", 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()

    // now we execute our method
    if err = recordStats(db, 2, 3); err != nil {
        t.Errorf("error was not expected while updating stats: %s", err)
    }

    // we make sure that all expectations were met
    if err := mock.ExpectationsWereMet(); err != nil {
        t.Errorf("there were unfulfilled expectations: %s", err)
    }
}

http 相關操作採用 httptest 這個 mock 庫

httptest 是 Go 官方提供的 專門用於進行 http Web 開發測試的包。同理,我們在單測過程中,也不要直連真正的 web server,具體原因和資料庫類似,可以參考上面說的原因。

使用起來同樣很簡單,一個示例如下,更詳細的說明最好是檢視官方庫httptest 的使用介紹:

func TestUpperCaseHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/upper?word=abc", nil)
    w := httptest.NewRecorder()
    upperCaseHandler(w, req)
    res := w.Result()
    defer res.Body.Close()
    data, err := ioutil.ReadAll(res.Body)
    if err != nil {
        t.Errorf("expected error to be nil got %v", err)
    }
    if string(data) != "ABC" {
        t.Errorf("expected ABC got %v", string(data))
    }
}

說明一下,httptest.NewRecorder() 可以獲得 httptest.ResponseRecorder 結構,而此結構實現了 http.ResponseWriter 介面。

從程式碼設計層面為單測做準備

合理的對外部依賴做好介面封裝

合理的對外部依賴做好介面封裝,因為外部依賴我們基本上期望的方式就是透過 mock 來實現單測的目的,這裡就要求我們對外部依賴的裡面做好方法的封裝。具體做法就是定義一個 interface,然後把外部依賴的介面都統一定義並封裝好,這樣的程式碼設計,既方便寫單測,整體程式碼又比較優雅。

Go 協程出去的怎麼做單測 (不在單測裡面 sleep)

Go 語言裡面,我們經常會 go 一個協程出去做非同步的事情,這個非同步的事情是不影響主邏輯的,從業務流程上是可以失敗的,因此 go 一個協程出去執行是 ok 的,業務場景下,也有挺多這樣的業務訴求。那麼針對這樣的程式碼,go 出去的非同步邏輯,我們要怎麼單測呢 ?如果直接執行,那麼 go 出去的程式碼可能根本就來不及執行,整個單測的邏輯就結束了,所以就導致 go 出去的非同步邏輯就無法執行到單測了,而且有時候也會導致執行單測的時候直接 panic 。因此,這裡的核心點在於我們在單測的時候要保證 go 出去的也能執行完畢。

早期的時候,我會用一個比較粗暴的方式(相信很多同學也有類似的經歷),就是在單測裡面 sleep 2 秒中,這樣可以讓 go 出去的協程也能被執行到,並且不會因為 leaving in fight goroutine 從而大致單測執行的時候會 panic。但是,這個方式其實是不推薦的,因為,我們針對單測,還有一個非常重要的關鍵點,那就是單測的執行要儘可能的快,因此不要在單測裡面 sleep。

那麼針對 go 出去的邏輯,要怎麼單測呢?這裡的核心點在於我們在單測的時候要保證 go 出去的也能執行完畢。為此,我們可以自己封裝一個 MyGo,把原生的 go 封裝起來,這樣方便我們做單測:

封裝如下:

package  testgo

var testGoImpl TestGo

type TestGo struct {
    ignoreGo bool
}

// 在單測裡面要執行這個,用來忽略 go
func IgnoreGo() {
    testGoImpl.ignoreGo = true
}

// 不忽略 go
func RecoverGo() {
    testGoImpl.ignoreGo = false
}


func Go(f func()) {
    if testGoImpl.ignoreGo {
        // 如果忽略,啥事不幹,直接 return, 這樣的話,單測的時候,就可以執行到並且不會 panic
        return
    }   
    
    // 正常的業務邏輯,還是正常 go 一個協程出去執行業務邏輯
    go f()

}

這個時候,在業務程式碼裡面,我們就不能再用官方的 go 關鍵字來 go 一個協程了,需要用 testgo.Go 來替換了。

而我們執行單測的時候,只需要在執行前,呼叫一下 testgo.IgnoreGo() ,執行後再呼叫一下 testgo.RecoverGo() 就可以完美解決

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2933410/,如需轉載,請註明出處,否則將追究法律責任。

相關文章