如何有效地測試 Go 代碼

單元測試

一個沒有 UT 的項目,它的代碼質量與工程保證是堪憂的。但在實際開發工作中,很多程序員往往並不寫測試代碼,他們的開發週期可能如下圖所示。

而做了充分 UT 的程序員,他們的項目開發週期更大概率如下。

項目開發中,不寫 UT 也許能使代碼交付更快,但是我們無法保證寫出來的代碼真的能夠正確地執行。寫 UT 可以減少後期解決 bug 的時間,也能讓我們放心地使用自己寫出來的代碼。從長遠來看,後者更能有效地節省開發時間。

既然 UT 這麼重要,是什麼原因在阻止開發人員寫 UT 呢?這是因爲除了開發人員的惰性習慣之外,編寫 UT 代碼同樣存在難點。

  1. 代碼耦合度高,缺少必要的抽象與拆分,以至於不知道如何寫 UT。

  2. 存在第三方依賴,例如依賴數據庫連接、HTTP 請求、數據緩存等。

可見,編寫可測試代碼的難點就在於解耦依賴

接口與 Mock

對於難點 1,我們需要面向接口編程。在《接口 Interface——塑造健壯與可擴展的 Go 應用程序》一文中,我們討論了使用接口給代碼帶來的靈活解耦與高擴展特性。接口是對一類對象的抽象性描述,表明該類對象能提供什麼樣的服務,它最主要的作用就是解耦調用者和實現者,這成爲了可測試代碼的關鍵。

對於難點 2,我們可以通過 Mock 測試來解決。Mock 測試就是在測試過程中,對於某些不容易構造或者不容易獲取的對象,用一個虛擬的對象來創建以便測試的測試方法。

如果我們的代碼都是面向接口編程,調用方與服務方將是松耦合的依賴關係。在測試代碼中,我們就可以 Mock 出另一種接口的實現,從而很容易地替換掉第三方的依賴。

測試工具

  1. 自帶測試庫:testing

在介紹 Mock 測試之前,先看一下 Go 中最簡單的測試單元應該如何寫。假設我們在math.go文件下有以下兩個函數,現在我們需要對它們寫測試案例。

1package math
2
3func Add(x, y int) int {
4    return x + y
5}
6
7func Multi(x, y int) int {
8    return x * y
9}

如果我們的 IDE 是 Goland,它有一個非常好用的一鍵測試代碼生成功能。

如上圖所示,光標置於函數名之上,右鍵選擇 Generate,我們可以選擇生成整個 package、當前 file 或者當前選中函數的測試代碼。以 Tests for selection 爲例,Goland 會自動在當前 math.go 同級目錄新建測試文件math_test.go,內容如下。

 1package math
 2
 3import "testing"
 4
 5func TestAdd(t *testing.T) {
 6    type args struct {
 7        x int
 8        y int
 9    }
10    tests := []struct {
11        name string
12        args args
13        want int
14    }{
15        // TODO: Add test cases.
16    }
17    for _, tt := range tests {
18        t.Run(tt.name, func(t *testing.T) {
19            if got := Add(tt.args.x, tt.args.y); got != tt.want {
20                t.Errorf("Add() = %v, want %v", got, tt.want)
21            }
22        })
23    }
24}

可以看到,在 Go 測試慣例中,單元測試的默認組織方式就是寫在以 _test.go 結尾的文件中,所有的測試方法也都是以 Test 開頭並且只接受一個 testing.T 類型的參數。同時,如果我們要給函數名爲 Add 的方法寫單元測試,那麼對應的測試方法一般會被寫成 TestAdd

當測試模板生成之後,我們只需將測試案例添加至 TODO 即可。

 1        {
 2            "negative + negative",
 3            args{-1, -1},
 4            -2,
 5        },
 6        {
 7            "negative + positive",
 8            args{-1, 1},
 9            0,
10        },
11        {
12            "positive + positive",
13            args{1, 1},
14            2,
15        },

此時,運行測試文件,可以發現所有測試案例,均成功通過。

1=== RUN   TestAdd
2--- PASS: TestAdd (0.00s)
3=== RUN   TestAdd/negative_+_negative
4    --- PASS: TestAdd/negative_+_negative (0.00s)
5=== RUN   TestAdd/negative_+_positive
6    --- PASS: TestAdd/negative_+_positive (0.00s)
7=== RUN   TestAdd/positive_+_positive
8    --- PASS: TestAdd/positive_+_positive (0.00s)
9PASS
2. 斷言庫:testify

簡單瞭解了 Go 內置 testing 庫的測試寫法後,推薦一個好用的斷言測試庫:testify。testify 具有常見斷言和 mock 的工具鏈,最重要的是,它能夠與內置庫 testing 很好地配合使用,其項目地址位於 https://github.com/stretchr/testify。

如果採用 testify 庫,需要引入"github.com/stretchr/testify/assert"。之外,上述測試代碼中以下部分

1            if got := Add(tt.args.x, tt.args.y); got != tt.want {
2                t.Errorf("Add() = %v, want %v", got, tt.want)
3            }

更改爲如下斷言形式

1     assert.Equal(t, Add(tt.args.x, tt.args.y), tt.want, tt.name)

testify 提供的斷言方法幫助我們快速地對函數的返回值進行測試,從而減少測試代碼工作量。它可斷言的類型非常豐富,例如斷言 Equal、斷言 NIl、斷言 Type、斷言兩個指針是否指向同一對象、斷言包含、斷言子集等。

不要小瞧這一行代碼,如果我們在測試案例中,將 "positive + positive" 的期望值改爲 3,那麼測試結果中會自動提供報錯信息。

 1...
 2=== RUN   TestAdd/positive_+_positive
 3    math_test.go:36: 
 4            Error Trace:    math_test.go:36
 5            Error:          Not equal: 
 6                            expected: 2
 7                            actual  : 3
 8            Test:           TestAdd/positive_+_positive
 9            Messages:       positive + positive
10    --- FAIL: TestAdd/positive_+_positive (0.00s)
11
12
13Expected :2
14Actual   :3
15...
3. 接口 mock 框架:gomock

介紹完基本的測試方法的寫法後,我們需要討論基於接口的 Mock 方法。在 Go 語言中,最通用的 Mock 手段是通過 Go 官方的 gomock 框架來自動生成其 Mock 方法。該項目地址位於 https://github.com/golang/mock。

爲了方便讀者理解,本文舉一個小明玩手機的例子。小明喜歡玩手機,他每天都需要通過手機聊微信、玩王者、逛知乎,如果某天沒有幹這些事情,小明就沒辦法睡覺。在該情景中,我們可以將手機抽象成接口如下。

1// mockDemo/equipment/phone.go
2type Phone interface {
3    WeiXin() bool
4    WangZhe() bool
5    ZhiHu() bool
6}

小明手上有一部非常老的 IPhone6s,我們爲該手機對象實現 Phone 接口。

 1// mockDemo/equipment/phone6s.go
 2type Iphone6s struct {
 3}
 4
 5func NewIphone6s() *Iphone6s {
 6    return &Iphone6s{}
 7}
 8
 9func (p *Iphone6s) WeiXin() bool {
10    fmt.Println("Iphone6s chat wei xin!")
11    return true
12}
13
14func (p *Iphone6s) WangZhe() bool {
15    fmt.Println("Iphone6s play wang zhe!")
16    return true
17}
18
19func (p *Iphone6s) ZhiHu() bool {
20    fmt.Println("Iphone6s read zhi hu!")
21    return true
22}

接着,我們定義Person對象用來表示小明,並定義Person對象的生活函數dayLife和入睡函數goSleep

 1// mockDemo/person.go
 2type Person struct {
 3    name  string
 4    phone equipment.Phone
 5}
 6
 7func NewPerson(name string, phone equipment.Phone) *Person {
 8    return &Person{
 9        name:  name,
10        phone: phone,
11    }
12}
13
14func (x *Person) goSleep() {
15    fmt.Printf("%s go to sleep!", x.name)
16}
17
18func (x *Person) dayLife() bool {
19    fmt.Printf("%s's daily life:\n", x.name)
20    if x.phone.WeiXin() && x.phone.WangZhe() && x.phone.ZhiHu() {
21        x.goSleep()
22        return true
23    }
24    return false
25}
 1//mockDemo/main.go
 2func main() {
 3    phone := equipment.NewIphone6s()
 4    xiaoMing := NewPerson("xiaoMing", phone)
 5    xiaoMing.dayLife()
 6}
 7
 8// output
 9xiaoMing's daily life:
10Iphone6s chat wei xin!
11Iphone6s play wang zhe!
12Iphone6s read zhi hu!
13xiaoMing go to sleep!
14
1GO111MODULE=on go get github.com/golang/mock/mockgen
1mockgen -destination equipment/mock_iphone.go -package equipment -source equipment/phone.go
1.
2├── equipment
3│   ├── iphone6s.go
4│   └── phone.go
5├── go.mod
6├── go.sum
7├── main.go
8└── person.go

執行mockgen命令之後,在equipment/phone.go的同級目錄,新生成了測試文件 mock_iphone.go(它的代碼自動生成功能,是通過 Go 自帶 generate 工具完成的,感興趣的讀者可以閱讀《Go 工具之 generate》一文),其部分內容如下

 1...
 2// MockPhone is a mock of Phone interface
 3type MockPhone struct {
 4    ctrl     *gomock.Controller
 5    recorder *MockPhoneMockRecorder
 6}
 7
 8// MockPhoneMockRecorder is the mock recorder for MockPhone
 9type MockPhoneMockRecorder struct {
10    mock *MockPhone
11}
12
13// NewMockPhone creates a new mock instance
14func NewMockPhone(ctrl *gomock.Controller) *MockPhone {
15    mock := &MockPhone{ctrl: ctrl}
16    mock.recorder = &MockPhoneMockRecorder{mock}
17    return mock
18}
19
20// EXPECT returns an object that allows the caller to indicate expected use
21func (m *MockPhone) EXPECT() *MockPhoneMockRecorder {
22    return m.recorder
23}
24
25// WeiXin mocks base method
26func (m *MockPhone) WeiXin() bool {
27    m.ctrl.T.Helper()
28    ret := m.ctrl.Call(m, "WeiXin")
29    ret0, _ := ret[0].(bool)
30    return ret0
31}
32
33// WeiXin indicates an expected call of WeiXin
34func (mr *MockPhoneMockRecorder) WeiXin() *gomock.Call {
35    mr.mock.ctrl.T.Helper()
36    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WeiXin", reflect.TypeOf((*MockPhone)(nil).WeiXin))
37}
38...

此時,我們的person.go中的 Person.dayLife 方法就可以測試了。

 1func TestPerson_dayLife(t *testing.T) {
 2    type fields struct {
 3        name  string
 4        phone equipment.Phone
 5    }
 6
 7  // 生成mockPhone對象
 8    mockCtl := gomock.NewController(t)
 9    mockPhone := equipment.NewMockPhone(mockCtl)
10  // 設置mockPhone對象的接口方法返回值
11    mockPhone.EXPECT().ZhiHu().Return(true)
12    mockPhone.EXPECT().WeiXin().Return(true)
13    mockPhone.EXPECT().WangZhe().Return(true)
14
15    tests := []struct {
16        name   string
17        fields fields
18        want   bool
19    }{
20        {"case1", fields{"iphone6s", equipment.NewIphone6s()}, true},
21        {"case2", fields{"mocked phone", mockPhone}, true},
22    }
23    for _, tt := range tests {
24        t.Run(tt.name, func(t *testing.T) {
25            x := &Person{
26                name:  tt.fields.name,
27                phone: tt.fields.phone,
28            }
29            assert.Equal(t, tt.want, x.dayLife())
30        })
31    }
32}

對接口進行 Mock,可以讓我們在未實現具體對象的接口功能前,或者該接口調用代價非常高時,也能對業務代碼進行測試。而且在開發過程中,我們同樣可以利用 Mock 對象,不用因爲等待接口實現方實現相關功能,從而停滯後續的開發。

在這裏我們能夠體會到在 Go 程序中接口對於測試的重要性。沒有接口的 Go 代碼,單元測試會非常難寫。所以,如果一個稍大型的項目中,沒有任何接口,那麼該項目的質量一定是堪憂的。

4. 常見三方 mock 依賴庫

在上文中提到,因爲存在某些存在第三方依賴,會讓我們的代碼難以測試。但其實已經有一些比較成熟的 mock 依賴庫可供我們使用。由於篇幅原因,以下列出的一些 mock 庫將不再貼出示例代碼,詳細信息可通過對應的項目地址進行了解。

這是 Go 語言中用以測試數據庫交互的 SQL 模擬驅動庫,其項目地址爲 https://github.com/DATA-DOG/go-sqlmock。它而無需真正地數據庫連接,就能夠在測試中模擬 sql 驅動程序行爲,非常有助於維護測試驅動開發(TDD)的工作流程。

用於模擬外部資源的 http 響應,它使用模式匹配的方式匹配 HTTP 請求的 URL,在匹配到特定的請求時就會返回預先設置好的響應。其項目地址爲 https://github.com/jarcoal/httpmock 。

它用於模擬 gRPC 服務的服務器,通過使用. proto 文件生成對 gRPC 服務的實現,其項目地址爲 https://github.com/tokopedia/gripmock。

用於測試與 Redis 服務器的交互,其項目地址位於 https://github.com/elliotchance/redismock。

5. 猴子補丁:monkey patch

如果上述的方案都不能很好的寫出測試代碼,這時可以考慮使用猴子補丁。猴子補丁簡單而言就是屬性在運行時的動態替換,它在理論上可以替換運行時中的一切函數。這種測試方式在動態語言例如 Python 中比較合適。在 Go 中,monkey 庫通過在運行時重寫正在運行的可執行文件並插入跳轉到您要調用的函數來實現 Monkey patching。項目作者寫道:這個操作很不安全,不建議任何人在測試環境之外進行使用。其項目地址爲 https://github.com/bouk/monkey。

monkey 庫的 API 比較簡單,例如可以通過調用 monkey.Patch(<target function>, <replacement function>)來實現對函數的替換,以下是操作示例。

 1package main
 2
 3import (
 4    "fmt"
 5    "os"
 6    "strings"
 7
 8    "bou.ke/monkey"
 9)
10
11func main() {
12    monkey.Patch(fmt.Println, func(a ...interface{}) (n int, err error) {
13        s := make([]interface{}, len(a))
14        for i, v := range a {
15            s[i] = strings.Replace(fmt.Sprint(v)"hell""*bleep*", -1)
16        }
17        return fmt.Fprintln(os.Stdout, s...)
18    })
19    fmt.Println("what the hell?") // what the *bleep*?
20}

需要注意的是,如果啓用了內聯,則 monkey 有時無法進行 patching,因此,我們需要嘗試在禁用內聯的情況下運行測試。例如以上例子,我們需要通過以下命令執行。

1$ go build -o main -gcflags=-l main.go;./main
2what the *bleep*?

總結

在項目開發中,單元測試是重要且必須的。對於單元測試的兩大難點:解耦依賴,我們的代碼可以採用 **面向接口 + mock 依賴 **的方式進行組織,將依賴都做成可插拔的,那在單元測試裏面隔離依賴就是一件水到渠成的事情。

另外,本文討論了一些實用的測試工具,包括自帶測試庫 testing 的快速生成測試代碼,斷言庫 testify 的斷言使用,接口 mock 框架 gomock 如何 mock 接口方法和一些常見的三方依賴 mock 庫推薦,最後再介紹了測試大殺器猴子補丁,當然,不到萬不得已,不要使用猴子補丁。

最後,在這些測試工具的使用上,本文的內容也只是一些淺嘗輒止的介紹,希望讀者能夠在實際項目中多寫寫單元測試,深入體會 TDD 的開發思想。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DXCC0i1WXE6vpIwvFEzjpw