如何有效地測試Go代碼

單元測試

如果把開發(fā)程序比作蓋房子泻拦,那么我們必須確保所有的用料都是合格的,否則蓋起來的房子就會存在問題。對于程序而言窒朋,我們可以將蓋房子的磚頭搀罢、鋼筋、水泥等當做一個個功能單元侥猩,如果每個單元是合格的榔至,我們將有信心認為程序是健壯的。單元測試(Unit Test,UT)就是檢驗功能單元是否合格的工具欺劳。

一個沒有UT的項目唧取,它的代碼質(zhì)量與工程保證是堪憂的。但在實際開發(fā)工作中划提,很多程序員往往并不寫測試代碼枫弟,他們的開發(fā)周期可能如下圖所示。

1.png

而做了充分UT的程序員鹏往,他們的項目開發(fā)周期更大概率如下淡诗。

2.png

項目開發(fā)中,不寫UT也許能使代碼交付更快伊履,但是我們無法保證寫出來的代碼真的能夠正確地執(zhí)行韩容。寫UT可以減少后期解決bug的時間,也能讓我們放心地使用自己寫出來的代碼唐瀑。從長遠來看宙攻,后者更能有效地節(jié)省開發(fā)時間。

既然UT這么重要介褥,是什么原因在阻止開發(fā)人員寫UT呢座掘?這是因為除了開發(fā)人員的惰性習慣之外,編寫UT代碼同樣存在難點柔滔。

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

  2. 存在第三方依賴形真,例如依賴數(shù)據(jù)庫連接、HTTP請求超全、數(shù)據(jù)緩存等咆霜。

可見,編寫可測試代碼的難點就在于解耦依賴嘶朱。

接口與Mock

對于難點1蛾坯,我們需要面向接口編程。在《接口Interface——塑造健壯與可擴展的Go應用程序》一文中疏遏,我們討論了使用接口給代碼帶來的靈活解耦與高擴展特性脉课。接口是對一類對象的抽象性描述救军,表明該類對象能提供什么樣的服務,它最主要的作用就是解耦調(diào)用者和實現(xiàn)者倘零,這成為了可測試代碼的關鍵唱遭。

對于難點2,我們可以通過Mock測試來解決呈驶。Mock測試就是在測試過程中拷泽,對于某些不容易構(gòu)造或者不容易獲取的對象,用一個虛擬的對象來創(chuàng)建以便測試的測試方法袖瞻。

如果我們的代碼都是面向接口編程跌穗,調(diào)用方與服務方將是松耦合的依賴關系。在測試代碼中虏辫,我們就可以Mock 出另一種接口的實現(xiàn)蚌吸,從而很容易地替換掉第三方的依賴。

3.png

測試工具

1. 自帶測試庫:testing

在介紹Mock測試之前砌庄,先看一下Go中最簡單的測試單元應該如何寫羹唠。假設我們在math.go文件下有以下兩個函數(shù),現(xiàn)在我們需要對它們寫測試案例娄昆。

// math.go
package math

func Add(x, y int) int {
    return x + y
}

func Multi(x, y int) int {
    return x * y
}

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

4.png

如上圖所示萌焰,光標置于函數(shù)名之上哺眯,右鍵選擇 Generate,我們可以選擇生成整個package扒俯、當前file或者當前選中函數(shù)的測試代碼奶卓。以 Tests for selection 為例,Goland 會自動在當前 math.go 同級目錄新建測試文件math_test.go撼玄,內(nèi)容如下夺姑。

// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    type args struct {
        x int
        y int
    }
    tests := []struct {
        name string
        args args
        want int
    }{
        // TODO: Add test cases.
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Add(tt.args.x, tt.args.y); got != tt.want {
                t.Errorf("Add() = %v, want %v", got, tt.want)
            }
        })
    }
}

可以看到,在Go測試慣例中掌猛,單元測試的默認組織方式就是寫在以 _test.go 結(jié)尾的文件中盏浙,所有的測試方法也都是以 Test 開頭并且只接受一個 testing.T 類型的參數(shù)。同時荔茬,如果我們要給函數(shù)名為 Add 的方法寫單元測試废膘,那么對應的測試方法一般會被寫成 TestAdd

當測試模板生成之后慕蔚,我們只需將測試案例添加至 TODO 即可丐黄。

        {
            "negative + negative",
            args{-1, -1},
            -2,
        },
        {
            "negative + positive",
            args{-1, 1},
            0,
        },
        {
            "positive + positive",
            args{1, 1},
            2,
        },

此時,運行測試文件坊萝,可以發(fā)現(xiàn)所有測試案例孵稽,均成功通過许起。

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestAdd/negative_+_negative
    --- PASS: TestAdd/negative_+_negative (0.00s)
=== RUN   TestAdd/negative_+_positive
    --- PASS: TestAdd/negative_+_positive (0.00s)
=== RUN   TestAdd/positive_+_positive
    --- PASS: TestAdd/positive_+_positive (0.00s)
PASS
2. 斷言庫:testify

簡單了解了Go內(nèi)置 testing 庫的測試寫法后十偶,推薦一個好用的斷言測試庫:testify菩鲜。testify具有常見斷言和mock的工具鏈,最重要的是惦积,它能夠與內(nèi)置庫 testing 很好地配合使用接校,其項目地址位于https://github.com/stretchr/testify

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

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

更改為如下斷言形式

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

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

不要小瞧這一行代碼蘸炸,如果我們在測試案例中躬络,將"positive + positive"的期望值改為3,那么測試結(jié)果中會自動提供報錯信息搭儒。

...
=== RUN   TestAdd/positive_+_positive
    math_test.go:36: 
            Error Trace:    math_test.go:36
            Error:          Not equal: 
                            expected: 2
                            actual  : 3
            Test:           TestAdd/positive_+_positive
            Messages:       positive + positive
    --- FAIL: TestAdd/positive_+_positive (0.00s)


Expected :2
Actual   :3
...
3. 接口mock框架:gomock

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

為了方便讀者理解稀拐,本文舉一個小明玩手機的例子火邓。小明喜歡玩手機,他每天都需要通過手機聊微信德撬、玩王者铲咨、逛知乎,如果某天沒有干這些事情蜓洪,小明就沒辦法睡覺纤勒。在該情景中,我們可以將手機抽象成接口如下隆檀。

// mockDemo/equipment/phone.go
type Phone interface {
    WeiXin() bool
    WangZhe() bool
    ZhiHu() bool
}

小明手上有一部非常老的IPhone6s摇天,我們?yōu)樵撌謾C對象實現(xiàn)Phone接口粹湃。

// mockDemo/equipment/phone6s.go
type Iphone6s struct {
}

func NewIphone6s() *Iphone6s {
    return &Iphone6s{}
}

func (p *Iphone6s) WeiXin() bool {
    fmt.Println("Iphone6s chat wei xin!")
    return true
}

func (p *Iphone6s) WangZhe() bool {
    fmt.Println("Iphone6s play wang zhe!")
    return true
}

func (p *Iphone6s) ZhiHu() bool {
    fmt.Println("Iphone6s read zhi hu!")
    return true
}

接著,我們定義Person對象用來表示小明泉坐,并定義Person對象的生活函數(shù)dayLife和入睡函數(shù)goSleep为鳄。

// mockDemo/person.go
type Person struct {
    name  string
    phone equipment.Phone
}

func NewPerson(name string, phone equipment.Phone) *Person {
    return &Person{
        name:  name,
        phone: phone,
    }
}

func (x *Person) goSleep() {
    fmt.Printf("%s go to sleep!", x.name)
}

func (x *Person) dayLife() bool {
    fmt.Printf("%s's daily life:\n", x.name)
    if x.phone.WeiXin() && x.phone.WangZhe() && x.phone.ZhiHu() {
        x.goSleep()
        return true
    }
    return false
}

最后,我們把小明和iphone6s對象實例化出來腕让,并開啟他一天的生活孤钦。

//mockDemo/main.go
func main() {
    phone := equipment.NewIphone6s()
    xiaoMing := NewPerson("xiaoMing", phone)
    xiaoMing.dayLife()
}

// output
xiaoMing's daily life:
Iphone6s chat wei xin!
Iphone6s play wang zhe!
Iphone6s read zhi hu!
xiaoMing go to sleep!

由于小明每天必須刷完手機才能睡覺,即Person.goSleep纯丸,那么小明能否睡覺依賴于手機偏形。

5.png

按照當前代碼,如果小明的手機壞了觉鼻,或者小明換了一個手機俊扭,那他就沒辦法睡覺了,這肯定是萬萬不行的坠陈。因此我們需要把小明對某特定手機的依賴Mock掉萨惑,這個時候 gomock 框架排上了用場。

如果沒有下載gomock庫畅姊,則執(zhí)行以下命令獲取

GO111MODULE=on go get github.com/golang/mock/mockgen

通過執(zhí)行以下命令對phone.go中的Phone接口Mock

mockgen -destination equipment/mock_iphone.go -package equipment -source equipment/phone.go

在執(zhí)行該命令前咒钟,當前項目的組織結(jié)構(gòu)如下

.
├── equipment
│   ├── iphone6s.go
│   └── phone.go
├── go.mod
├── go.sum
├── main.go
└── person.go

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

...
// MockPhone is a mock of Phone interface
type MockPhone struct {
    ctrl     *gomock.Controller
    recorder *MockPhoneMockRecorder
}

// MockPhoneMockRecorder is the mock recorder for MockPhone
type MockPhoneMockRecorder struct {
    mock *MockPhone
}

// NewMockPhone creates a new mock instance
func NewMockPhone(ctrl *gomock.Controller) *MockPhone {
    mock := &MockPhone{ctrl: ctrl}
    mock.recorder = &MockPhoneMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockPhone) EXPECT() *MockPhoneMockRecorder {
    return m.recorder
}

// WeiXin mocks base method
func (m *MockPhone) WeiXin() bool {
    m.ctrl.T.Helper()
    ret := m.ctrl.Call(m, "WeiXin")
    ret0, _ := ret[0].(bool)
    return ret0
}

// WeiXin indicates an expected call of WeiXin
func (mr *MockPhoneMockRecorder) WeiXin() *gomock.Call {
    mr.mock.ctrl.T.Helper()
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WeiXin", reflect.TypeOf((*MockPhone)(nil).WeiXin))
}
...

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

func TestPerson_dayLife(t *testing.T) {
    type fields struct {
        name  string
        phone equipment.Phone
    }

  // 生成mockPhone對象
    mockCtl := gomock.NewController(t)
    mockPhone := equipment.NewMockPhone(mockCtl)
  // 設置mockPhone對象的接口方法返回值
    mockPhone.EXPECT().ZhiHu().Return(true)
    mockPhone.EXPECT().WeiXin().Return(true)
    mockPhone.EXPECT().WangZhe().Return(true)

    tests := []struct {
        name   string
        fields fields
        want   bool
    }{
        {"case1", fields{"iphone6s", equipment.NewIphone6s()}, true},
        {"case2", fields{"mocked phone", mockPhone}, true},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            x := &Person{
                name:  tt.fields.name,
                phone: tt.fields.phone,
            }
            assert.Equal(t, tt.want, x.dayLife())
        })
    }
}

對接口進行Mock隙疚,可以讓我們在未實現(xiàn)具體對象的接口功能前壤追,或者該接口的調(diào)用代價非常高時,也能對業(yè)務代碼進行測試供屉。而且在開發(fā)過程中行冰,我們同樣可以利用Mock對象,不用因為等待接口實現(xiàn)方實現(xiàn)相關功能伶丐,從而停滯后續(xù)的開發(fā)悼做。

在這里我們能夠體會到在Go程序中接口對于測試的重要性。沒有接口的Go代碼哗魂,單元測試會非常難寫肛走。所以,如果一個稍大型的項目中录别,沒有任何接口朽色,那么該項目的質(zhì)量一定是堪憂的邻吞。

4. 常見三方mock依賴庫

在上文中提到,因為存在某些存在第三方依賴葫男,會讓我們的代碼難以測試抱冷。但其實已經(jīng)有一些比較成熟的mock依賴庫可供我們使用。由于篇幅原因腾誉,以下列出的一些mock庫將不再貼出示例代碼徘层,詳細信息可通過對應的項目地址進行了解峻呕。

  • go-sqlmock

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

  • httpmock

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

  • gripmock

它用于模擬gRPC服務的服務器桶癣,通過使用.proto文件生成對gRPC服務的實現(xiàn),其項目地址為 https://github.com/tokopedia/gripmock娘锁。

  • redismock

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

5. 猴子補赌选:monkey patch

如果上述的方案都不能很好的寫出測試代碼间雀,這時可以考慮使用猴子補丁。猴子補丁簡單而言就是屬性在運行時的動態(tài)替換镊屎,它在理論上可以替換運行時中的一切函數(shù)惹挟。這種測試方式在動態(tài)語言例如Python中比較合適。在Go中缝驳,monkey庫通過在運行時重寫正在運行的可執(zhí)行文件并插入跳轉(zhuǎn)到您要調(diào)用的函數(shù)來實現(xiàn)Monkey patching连锯。項目作者寫道:這個操作很不安全,不建議任何人在測試環(huán)境之外進行使用用狱。其項目地址為https://github.com/bouk/monkey运怖。

monkey庫的API比較簡單,例如可以通過調(diào)用 monkey.Patch(<target function>, <replacement function>)來實現(xiàn)對函數(shù)的替換齿拂,以下是操作示例驳规。

package main

import (
    "fmt"
    "os"
    "strings"

    "bou.ke/monkey"
)

func main() {
    monkey.Patch(fmt.Println, func(a ...interface{}) (n int, err error) {
        s := make([]interface{}, len(a))
        for i, v := range a {
            s[i] = strings.Replace(fmt.Sprint(v), "hell", "*bleep*", -1)
        }
        return fmt.Fprintln(os.Stdout, s...)
    })
    fmt.Println("what the hell?") // what the *bleep*?
}

需要注意的是,如果啟用了內(nèi)聯(lián)署海,則monkey有時無法進行patching吗购,因此医男,我們需要嘗試在禁用內(nèi)聯(lián)的情況下運行測試。例如以上例子捻勉,我們需要通過以下命令執(zhí)行镀梭。

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

總結(jié)

在項目開發(fā)中,單元測試是重要且必須的踱启。對于單元測試的兩大難點:解耦依賴报账,我們的代碼可以采用 **面向接口+mock依賴 **的方式進行組織,將依賴都做成可插拔的埠偿,那在單元測試里面隔離依賴就是一件水到渠成的事情透罢。

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

最后,在這些測試工具的使用上缩宜,本文的內(nèi)容也只是一些淺嘗輒止的介紹肘迎,希望讀者能夠在實際項目中多寫寫單元測試,深入體會TDD的開發(fā)思想脓恕。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末膜宋,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子炼幔,更是在濱河造成了極大的恐慌秋茫,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件乃秀,死亡現(xiàn)場離奇詭異肛著,居然都是意外死亡,警方通過查閱死者的電腦和手機跺讯,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門枢贿,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人刀脏,你說我怎么就攤上這事局荚。” “怎么了?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵耀态,是天一觀的道長轮傍。 經(jīng)常有香客問我,道長首装,這世上最難降的妖魔是什么创夜? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮仙逻,結(jié)果婚禮上驰吓,老公的妹妹穿的比我還像新娘。我一直安慰自己系奉,他們只是感情好檬贰,可當我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著喜最,像睡著了一般偎蘸。 火紅的嫁衣襯著肌膚如雪庄蹋。 梳的紋絲不亂的頭發(fā)上瞬内,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天,我揣著相機與錄音限书,去河邊找鬼虫蝶。 笑死,一個胖子當著我的面吹牛倦西,可吹牛的內(nèi)容都是我干的能真。 我是一名探鬼主播,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼扰柠,長吁一口氣:“原來是場噩夢啊……” “哼粉铐!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起卤档,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤蝙泼,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后劝枣,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體汤踏,經(jīng)...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年舔腾,在試婚紗的時候發(fā)現(xiàn)自己被綠了溪胶。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡稳诚,死狀恐怖哗脖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤才避,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布丘损,位于F島的核電站,受9級特大地震影響工扎,放射性物質(zhì)發(fā)生泄漏徘钥。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一肢娘、第九天 我趴在偏房一處隱蔽的房頂上張望呈础。 院中可真熱鬧,春花似錦橱健、人聲如沸而钞。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽臼节。三九已至,卻和暖如春珊皿,著一層夾襖步出監(jiān)牢的瞬間网缝,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工蟋定, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留粉臊,地道東北人。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓驶兜,卻偏偏與公主長得像扼仲,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子抄淑,可洞房花燭夜當晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內(nèi)容