最近学习某个 Golang 单元测试的课程,发现其中推荐使用 gomonkey[1] 这种黑科技,让人略感意外,毕竟在软件开发领域,诸如依赖注入之类的概念已经流传了几十年了,本文希望通过一个例子的演化过程,来总结出 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% 的覆盖率,那么我们就必须测试每一个可能的分支:
-
GoodGoodStudy 异常 -
GoodGoodStudy 正常;BuyHouse 异常 -
GoodGoodStudy 正常;BuyHouse 正常;Marry 异常 -
GoodGoodStudy 正常;BuyHouse 正常;Marry 正常
第一版单元测试
对 Live 而言,GoodGoodStudy,BuyHouse 和 Marry 都属于外部依赖,通过使用 gomonkey,我们可以在运行时动态替换掉他们的实现,从而确保流程进入预定分支。在断言部分我们使用了 testify[2],它比直接使用标准库中的 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()
}
第一版单元测试存在的问题:原始代码十几行,单元测试代码几十行。在大话西游中,至尊宝在梦中叫了晶晶的名字 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[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)
})
})
})
})
}
补充说明:如果你没看过 goconvey 的文档,那么很可能会误解其运行机制,我在代码里加了很多 t.Log,大家不妨猜猜它们的输出顺序是什么样的。了解这一点对实现 setup,teardown 很重要,篇幅所限,本文就不深入讨论了,有兴趣的朋友请自行查阅。
第三版单元测试存在的问题:虽然 gomonkey 可以通过 OutputCell 一次性控制多个状态变化,但是这些状态却是静态的,被替换方法的参数和返回值没有关联。
关于 Gomonkey 的原罪
在单元测试领域,关于如何替换掉外部依赖,主要有两种技术,分别是 mock 和 stub:mock 通过接口可以动态调整外部依赖的返回值,而 stub 只能在运行时静态调整外部依赖的返回值,可以说 mock 包含了 stub,或者说 stub 是 mock 的子集,从本质上讲,gomonkey 属于 stub 技术,它存在诸多缺点,比如:
-
它违反了开闭原则。 -
运行时必须关闭内连「go test -gcflags=all=-l」。 -
运行时需要很高的权限,并且不同的硬件需要不同的黑科技[5]实现。
对 gomonkey 来说,我的看法很明确:虽然黑科技很神奇,但是能不用就不用!一旦发现不得不用,那么多半意味着你的代码设计本身存在问题。
最终版单元测试
很多人买电脑的时候为了省钱买了集成显卡的电脑,结果等到需要换显卡的时候才发现可拔插性的重要性,如果上天再给他们一次机会,我猜他们一定会买独立显卡的电脑。
Golang 崇尚接口,有了接口,我们就可以很自然的使用 mock 技术,而不是 stub 技术。在这里,mock 就相当于独立显卡,而 stub 就相当于集成显卡。
下面让我们通过接口重构原始代码,其中使用 gomock[6] 生成了 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,这里的不可控主要分两种:
-
一种是运行时间的不可控:比如一个高 CPU 任务,单次执行需要一分钟,但是有一百个测试用例要跑,此时就需要 mock。 -
一种是运行结果的不可控:比如 mysql,redis 之类的 IO 请求,虽然它们可能运行的很快,但是因为网络本身的限制有可能失败,此时需要 mock。
不过 mock 虽好,但不要贪杯,千万不要手里拿着锤子,看哪都像钉子。举个例子:Golang 里最流行的配置工具 Viper[7],其最常用的使用方式都是静态调用,比如:「viper.GetXxx」,并没有使用接口,自然 mock 也就无从谈起,不过我们可以通过「viper.Set」很简单的替换方法的返回值,此时 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
推荐阅读
文章评论