最近学习某个 Golang 单元测试的课程,发现其中推荐使用 gomonkey 这种黑科技,让人略感意外,毕竟在软件开发领域,诸如依赖注入之类的概念已经流传了几十年了,本文希望通过一个例子的演化过程,来总结出 Golang 单元测试的最佳实战。
既然是白话,那么我们得想一个通俗易懂的例子,就拿普通人来说吧:活着是为了什么,好好学习,买房,结婚,任意一个环节出现意外,整个人生就会偏离轨道。下面我用 Golang 代码来描述活着的过程,其中好好学习,买房,结婚都可能受到不可控外界因素的影响,比如好好学习遇上教培跑路,买房遇上银行限贷,结婚遇上彩礼涨价。
下面问题来了:请为「Live」编写单元测试,要求覆盖率达到 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 好好学习 func GoodGoodStudy(money int64) error { if rand.Intn(100) > 0 { return errors.New("error") } _ = money return nil } // BuyHouse 买房 func BuyHouse(money int64) error { if rand.Intn(100) > 0 { return errors.New("error") } _ = money return nil } // Marry 结婚 func Marry(money int64) error { if rand.Intn(100) > 0 { return errors.New("error") } _ = money return nil }
既然单元测试要求达到 100% 的覆盖率,那么我们就必须测试每一个可能的分支:
对 Live 而言,GoodGoodStudy,BuyHouse 和 Marry 都属于外部依赖,通过使用 gomonkey,我们可以在运行时动态替换掉他们的实现,从而确保流程进入预定分支。在断言部分我们使用了 testify,它比直接使用标准库中的 testing 包方便很多。
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() }
第一版单元测试存在的问题:原始代码十几行,单元测试代码几十行。在大话西游中,至尊宝在梦中叫了晶晶的名字 98 次,叫了紫霞的名字 784 次。而在我们的单元测试中,GoodGoodStudy 正常的状态写了三次,BuyHouse 正常的状态写了两次,虽然远比至尊宝重复的次数少,但重复始终是个坏味道。
通过使用 OutputCell,我们可以一次性控制多个状态变化,从而去除重复的坏味道:
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)) }
第二版单元测试存在的问题:原始代码逻辑中不同分支是有层次感的,浏览代码的时候可以很自然的看出流程的走向,但是在单元测试代码中,这种层次感消失了,如果不写注释,单纯看断言代码,那么我们很可能搞不清楚自己在干什么。
虽然 testify 的断言很强大,但是在表达的层次感上却是无力的,此时我们可以考虑用 goconvey 取代 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) }) }) }) }) }
补充说明: 如果你没看过 goconvey 的文档,那么很可能会误解其运行机制,我在代码里加了很多 t.Log,大家不妨猜猜它们的输出顺序是什么样的。了解这一点对实现 setup,teardown 很重要,篇幅所限,本文就不深入讨论了,有兴趣的朋友请自行查阅。
第三版单元测试存在的问题:虽然 gomonkey 可以通过 OutputCell 一次性控制多个状态变化,但是这些状态却是静态的,被替换方法的参数和返回值没有关联。
在单元测试领域,关于如何替换掉外部依赖,主要有两种技术,分别是 mock 和 stub:mock 通过接口可以动态调整外部依赖的返回值,而 stub 只能在运行时静态调整外部依赖的返回值,可以说 mock 包含了 stub,或者说 stub 是 mock 的子集,从本质上讲,gomonkey 属于 stub 技术,它存在诸多缺点,比如:
对 gomonkey 来说,我的看法很明确:虽然黑科技很神奇,但是能不用就不用!一旦发现不得不用,那么多半意味着你的代码设计本身存在问题。
很多人买电脑的时候为了省钱买了集成显卡的电脑,结果等到需要换显卡的时候才发现可拔插性的重要性,如果上天再给他们一次机会,我猜他们一定会买独立显卡的电脑。
Golang 崇尚接口,有了接口,我们就可以很自然的使用 mock 技术,而不是 stub 技术。在这里,mock 就相当于独立显卡,而 stub 就相当于集成显卡。
下面让我们通过接口重构原始代码,其中使用 gomock 生成了 mock 对象:
package main //go:generate mockgen -package main -source foo.go -destination=foo_mock.go // Life 人生 type Life interface { // GoodGoodStudy 好好学习 GoodGoodStudy(money int64) error // BuyHouse 买房 BuyHouse(money int64) error // Marry 结婚 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 对象以后,我们就好像置身在元宇宙中一样,不再有 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) }) }) }) }) }
最后让我们讨论一下到底哪些依赖需要 mock,哪些不需要 mock。简单点说:所有可能出现不可控情况的依赖都需要 mock,这里的不可控主要分两种:
不过 mock 虽好,但不要贪杯,千万不要手里拿着锤子,看哪都像钉子。举个例子:Golang 里最流行的配置工具 Viper,其最常用的使用方式都是静态调用,比如:「viper.GetXxx」,并没有使用接口,自然 mock 也就无从谈起,不过我们可以通过「viper.Set」很简单的替换方法的返回值,此时 mock 与否也就不再重要了。