白話 Golang 單元測(cè)試
最近學(xué)習(xí)某個(gè) Golang 單元測(cè)試的課程,發(fā)現(xiàn)其中推薦使用 gomonkey[1] 這種黑科技,讓人略感意外,畢竟在軟件開(kāi)發(fā)領(lǐng)域,諸如依賴(lài)注入之類(lèi)的概念已經(jīng)流傳了幾十年了,本文希望通過(guò)一個(gè)例子的演化過(guò)程,來(lái)總結(jié)出 Golang 單元測(cè)試的最佳實(shí)戰(zhàn)。
既然是白話,那么我們得想一個(gè)通俗易懂的例子,就拿普通人來(lái)說(shuō)吧:活著是為了什么,好好學(xué)習(xí),買(mǎi)房,結(jié)婚,任意一個(gè)環(huán)節(jié)出現(xiàn)意外,整個(gè)人生就會(huì)偏離軌道。下面我用 Golang 代碼來(lái)描述活著的過(guò)程,其中好好學(xué)習(xí),買(mǎi)房,結(jié)婚都可能受到不可控外界因素的影響,比如好好學(xué)習(xí)遇上教培跑路,買(mǎi)房遇上銀行限貸,結(jié)婚遇上彩禮漲價(jià)。
下面問(wèn)題來(lái)了:請(qǐng)為「Live」編寫(xiě)單元測(cè)試,要求覆蓋率達(dá)到 100%。
package main
import (
"errors"
"math/rand"
)
// Live 活著
func Live(money1, money2, money3 int64) error {
if err := GoodGoodStudy(money1); err != nil {
return err
}
if err := BuyHouse(money2); err != nil {
return err
}
if err := Marry(money3); err != nil {
return err
}
return nil
}
// GoodGoodStudy 好好學(xué)習(xí)
func GoodGoodStudy(money int64) error {
if rand.Intn(100) > 0 {
return errors.New("error")
}
_ = money
return nil
}
// BuyHouse 買(mǎi)房
func BuyHouse(money int64) error {
if rand.Intn(100) > 0 {
return errors.New("error")
}
_ = money
return nil
}
// Marry 結(jié)婚
func Marry(money int64) error {
if rand.Intn(100) > 0 {
return errors.New("error")
}
_ = money
return nil
}
既然單元測(cè)試要求達(dá)到 100% 的覆蓋率,那么我們就必須測(cè)試每一個(gè)可能的分支:
GoodGoodStudy 異常 GoodGoodStudy 正常;BuyHouse 異常 GoodGoodStudy 正常;BuyHouse 正常;Marry 異常 GoodGoodStudy 正常;BuyHouse 正常;Marry 正常
第一版單元測(cè)試
對(duì) Live 而言,GoodGoodStudy,BuyHouse 和 Marry 都屬于外部依賴(lài),通過(guò)使用 gomonkey,我們可以在運(yùn)行時(shí)動(dòng)態(tài)替換掉他們的實(shí)現(xiàn),從而確保流程進(jìn)入預(yù)定分支。在斷言部分我們使用了 testify[2],它比直接使用標(biāo)準(zhǔn)庫(kù)中的 testing[3] 包方便很多。
package main
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
. "github.com/agiledragon/gomonkey/v2"
)
func Test_Live1(t *testing.T) {
patches := NewPatches()
// GoodGoodStudy error
patches.ApplyFunc(GoodGoodStudy, func(int64) error {
return errors.New("error")
})
assert.Error(t, Live(100, 100, 100))
patches.Reset()
// BuyHouse error
patches.ApplyFunc(GoodGoodStudy, func(int64) error {
return nil
})
patches.ApplyFunc(BuyHouse, func(int64) error {
return errors.New("error")
})
assert.Error(t, Live(100, 100, 100))
patches.Reset()
// Marry error
patches.ApplyFunc(GoodGoodStudy, func(int64) error {
return nil
})
patches.ApplyFunc(BuyHouse, func(int64) error {
return nil
})
patches.ApplyFunc(Marry, func(int64) error {
return errors.New("error")
})
assert.Error(t, Live(100, 100, 100))
patches.Reset()
// ok
patches.ApplyFunc(GoodGoodStudy, func(int64) error {
return nil
})
patches.ApplyFunc(BuyHouse, func(int64) error {
return nil
})
patches.ApplyFunc(Marry, func(int64) error {
return nil
})
assert.NoError(t, Live(100, 100, 100))
patches.Reset()
}
第一版單元測(cè)試存在的問(wèn)題:原始代碼十幾行,單元測(cè)試代碼幾十行。在大話西游中,至尊寶在夢(mèng)中叫了晶晶的名字 98 次,叫了紫霞的名字 784 次。而在我們的單元測(cè)試中,GoodGoodStudy 正常的狀態(tài)寫(xiě)了三次,BuyHouse 正常的狀態(tài)寫(xiě)了兩次,雖然遠(yuǎn)比至尊寶重復(fù)的次數(shù)少,但重復(fù)始終是個(gè)壞味道。
第二版單元測(cè)試
通過(guò)使用 OutputCell,我們可以一次性控制多個(gè)狀態(tài)變化,從而去除重復(fù)的壞味道:
package main
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
. "github.com/agiledragon/gomonkey/v2"
)
func Test_Live2(t *testing.T) {
patches := NewPatches()
defer patches.Reset()
output := []OutputCell{
{Values: Params{errors.New("error")}, Times: 1},
{Values: Params{nil}, Times: 3},
}
patches.ApplyFuncSeq(GoodGoodStudy, output)
output = []OutputCell{
{Values: Params{errors.New("error")}, Times: 1},
{Values: Params{nil}, Times: 2},
}
patches.ApplyFuncSeq(BuyHouse, output)
output = []OutputCell{
{Values: Params{errors.New("error")}, Times: 1},
{Values: Params{nil}, Times: 1},
}
patches.ApplyFuncSeq(Marry, output)
// GoodGoodStudy error
assert.Error(t, Live(100, 100, 100))
// BuyHouse error
assert.Error(t, Live(100, 100, 100))
// Marry error
assert.Error(t, Live(100, 100, 100))
// ok
assert.NoError(t, Live(100, 100, 100))
}
第二版單元測(cè)試存在的問(wèn)題:原始代碼邏輯中不同分支是有層次感的,瀏覽代碼的時(shí)候可以很自然的看出流程的走向,但是在單元測(cè)試代碼中,這種層次感消失了,如果不寫(xiě)注釋?zhuān)瑔渭兛磾嘌源a,那么我們很可能搞不清楚自己在干什么。
第三版單元測(cè)試
雖然 testify 的斷言很強(qiáng)大,但是在表達(dá)的層次感上卻是無(wú)力的,此時(shí)我們可以考慮用 goconvey[4] 取代 testfy,它支持嵌套,這正是我們想要得到的層次感。
package main
import (
"errors"
"testing"
. "github.com/agiledragon/gomonkey/v2"
. "github.com/smartystreets/goconvey/convey"
)
func Test_Live3(t *testing.T) {
patches := NewPatches()
defer patches.Reset()
output := []OutputCell{
{Values: Params{errors.New("error")}, Times: 1},
{Values: Params{nil}, Times: 3},
}
patches.ApplyFuncSeq(GoodGoodStudy, output)
output = []OutputCell{
{Values: Params{errors.New("error")}, Times: 1},
{Values: Params{nil}, Times: 2},
}
patches.ApplyFuncSeq(BuyHouse, output)
output = []OutputCell{
{Values: Params{errors.New("error")}, Times: 1},
{Values: Params{nil}, Times: 1},
}
patches.ApplyFuncSeq(Marry, output)
Convey("Live", t, func() {
t.Log("LOG: Live")
Convey("GoodGoodStudy error", func() {
t.Log("LOG: GoodGoodStudy error")
So(Live(100, 100, 100), ShouldBeError)
})
Convey("GoodGoodStudy ok", func() {
t.Log("LOG: GoodGoodStudy ok")
Convey("BuyHouse error", func() {
t.Log("LOG: BuyHouse error")
So(Live(100, 100, 100), ShouldBeError)
})
Convey("BuyHouse ok", func() {
t.Log("LOG: BuyHouse ok")
Convey("Marry error", func() {
t.Log("LOG: Marry error")
So(Live(100, 100, 100), ShouldBeError)
})
Convey("Marry ok", func() {
t.Log("LOG: Marry ok")
So(Live(100, 100, 100), ShouldBeNil)
})
})
})
})
}
補(bǔ)充說(shuō)明:如果你沒(méi)看過(guò) goconvey 的文檔,那么很可能會(huì)誤解其運(yùn)行機(jī)制,我在代碼里加了很多 t.Log,大家不妨猜猜它們的輸出順序是什么樣的。了解這一點(diǎn)對(duì)實(shí)現(xiàn) setup,teardown 很重要,篇幅所限,本文就不深入討論了,有興趣的朋友請(qǐng)自行查閱。
第三版單元測(cè)試存在的問(wèn)題:雖然 gomonkey 可以通過(guò) OutputCell 一次性控制多個(gè)狀態(tài)變化,但是這些狀態(tài)卻是靜態(tài)的,被替換方法的參數(shù)和返回值沒(méi)有關(guān)聯(lián)。
關(guān)于 Gomonkey 的原罪
在單元測(cè)試領(lǐng)域,關(guān)于如何替換掉外部依賴(lài),主要有兩種技術(shù),分別是 mock 和 stub:mock 通過(guò)接口可以動(dòng)態(tài)調(diào)整外部依賴(lài)的返回值,而 stub 只能在運(yùn)行時(shí)靜態(tài)調(diào)整外部依賴(lài)的返回值,可以說(shuō) mock 包含了 stub,或者說(shuō) stub 是 mock 的子集,從本質(zhì)上講,gomonkey 屬于 stub 技術(shù),它存在諸多缺點(diǎn),比如:
它違反了開(kāi)閉原則。 運(yùn)行時(shí)必須關(guān)閉內(nèi)連「go test -gcflags=all=-l」。 運(yùn)行時(shí)需要很高的權(quán)限,并且不同的硬件需要不同的黑科技[5]實(shí)現(xiàn)。
對(duì) gomonkey 來(lái)說(shuō),我的看法很明確:雖然黑科技很神奇,但是能不用就不用!一旦發(fā)現(xiàn)不得不用,那么多半意味著你的代碼設(shè)計(jì)本身存在問(wèn)題。
最終版單元測(cè)試
很多人買(mǎi)電腦的時(shí)候?yàn)榱耸″X(qián)買(mǎi)了集成顯卡的電腦,結(jié)果等到需要換顯卡的時(shí)候才發(fā)現(xiàn)可拔插性的重要性,如果上天再給他們一次機(jī)會(huì),我猜他們一定會(huì)買(mǎi)獨(dú)立顯卡的電腦。
Golang 崇尚接口,有了接口,我們就可以很自然的使用 mock 技術(shù),而不是 stub 技術(shù)。在這里,mock 就相當(dāng)于獨(dú)立顯卡,而 stub 就相當(dāng)于集成顯卡。
下面讓我們通過(guò)接口重構(gòu)原始代碼,其中使用 gomock[6] 生成了 mock 對(duì)象:
package main
//go:generate mockgen -package main -source foo.go -destination=foo_mock.go
// Life 人生
type Life interface {
// GoodGoodStudy 好好學(xué)習(xí)
GoodGoodStudy(money int64) error
// BuyHouse 買(mǎi)房
BuyHouse(money int64) error
// Marry 結(jié)婚
Marry(money int64) error
}
// Person 普通人
type Person struct {
life Life
}
// Live 活著
func (p *Person) Live(money1, money2, money3 int64) error {
if err := p.life.GoodGoodStudy(money1); err != nil {
return err
}
if err := p.life.BuyHouse(money2); err != nil {
return err
}
if err := p.life.Marry(money3); err != nil {
return err
}
return nil
}
有了 mock 對(duì)象以后,我們就好像置身在元宇宙中一樣,不再有 stub 的限制:
package main
import (
"errors"
"testing"
gomock "github.com/golang/mock/gomock"
. "github.com/smartystreets/goconvey/convey"
)
func Test_Live(t *testing.T) {
ctrl := gomock.NewController(t)
life := NewMockLife(ctrl)
handler := func(money int64) error {
if money <= 0 {
return errors.New("error")
}
return nil
}
life.EXPECT().GoodGoodStudy(gomock.Any()).AnyTimes().DoAndReturn(handler)
life.EXPECT().BuyHouse(gomock.Any()).AnyTimes().DoAndReturn(handler)
life.EXPECT().Marry(gomock.Any()).AnyTimes().DoAndReturn(handler)
Convey("Live", t, func() {
person := &Person{
life: life,
}
Convey("GoodGoodStudy error", func() {
So(person.Live(0, 100, 100), ShouldBeError)
})
Convey("GoodGoodStudy ok", func() {
Convey("BuyHouse error", func() {
So(person.Live(100, 0, 100), ShouldBeError)
})
Convey("BuyHouse ok", func() {
Convey("Marry error", func() {
So(person.Live(100, 100, 0), ShouldBeError)
})
Convey("Marry ok", func() {
So(person.Live(100, 100, 100), ShouldBeNil)
})
})
})
})
}
最后讓我們討論一下到底哪些依賴(lài)需要 mock,哪些不需要 mock。簡(jiǎn)單點(diǎn)說(shuō):所有可能出現(xiàn)不可控情況的依賴(lài)都需要 mock,這里的不可控主要分兩種:
一種是運(yùn)行時(shí)間的不可控:比如一個(gè)高 CPU 任務(wù),單次執(zhí)行需要一分鐘,但是有一百個(gè)測(cè)試用例要跑,此時(shí)就需要 mock。 一種是運(yùn)行結(jié)果的不可控:比如 mysql,redis 之類(lèi)的 IO 請(qǐng)求,雖然它們可能運(yùn)行的很快,但是因?yàn)榫W(wǎng)絡(luò)本身的限制有可能失敗,此時(shí)需要 mock。
不過(guò) mock 雖好,但不要貪杯,千萬(wàn)不要手里拿著錘子,看哪都像釘子。舉個(gè)例子:Golang 里最流行的配置工具 Viper[7],其最常用的使用方式都是靜態(tài)調(diào)用,比如:「viper.GetXxx」,并沒(méi)有使用接口,自然 mock 也就無(wú)從談起,不過(guò)我們可以通過(guò)「viper.Set」很簡(jiǎn)單的替換方法的返回值,此時(shí) mock 與否也就不再重要了。
參考資料
gomonkey: https://github.com/agiledragon/gomonkey
[2]testify: https://github.com/stretchr/testify
[3]testing: https://pkg.go.dev/testing
[4]goconvey: https://github.com/smartystreets/goconvey
[5]黑科技: https://github.com/agiledragon/gomonkey/releases/tag/v2.2.0
[6]gomock: https://github.com/golang/mock
[7]Viper: https://github.com/spf13/viper
推薦閱讀
