白话 Golang 单元测试

2022年6月26日 323点热度 0人点赞 0条评论

最近学习某个 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(100100100))
 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(100100100))
 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(100100100))
 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(100100100))
 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(100100100))
 // BuyHouse error
 assert.Error(t, Live(100100100))
 // Marry error
 assert.Error(t, Live(100100100))
 // ok
 assert.NoError(t, Live(100100100))
}

第二版单元测试存在的问题:原始代码逻辑中不同分支是有层次感的,浏览代码的时候可以很自然的看出流程的走向,但是在单元测试代码中,这种层次感消失了,如果不写注释,单纯看断言代码,那么我们很可能搞不清楚自己在干什么。

第三版单元测试

虽然 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(100100100), ShouldBeError)
  })
  Convey("GoodGoodStudy ok"func() {
   t.Log("LOG: GoodGoodStudy ok")
   Convey("BuyHouse error"func() {
    t.Log("LOG: BuyHouse error")
    So(Live(100100100), ShouldBeError)
   })
   Convey("BuyHouse ok"func() {
    t.Log("LOG: BuyHouse ok")
    Convey("Marry error"func() {
     t.Log("LOG: Marry error")
     So(Live(100100100), ShouldBeError)
    })
    Convey("Marry ok"func() {
     t.Log("LOG: Marry ok")
     So(Live(100100100), 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(0100100), ShouldBeError)
  })
  Convey("GoodGoodStudy ok"func() {
   Convey("BuyHouse error"func() {
    So(person.Live(1000100), ShouldBeError)
   })
   Convey("BuyHouse ok"func() {
    Convey("Marry error"func() {
     So(person.Live(1001000), ShouldBeError)
    })
    Convey("Marry ok"func() {
     So(person.Live(100100100), ShouldBeNil)
    })
   })
  })
 })
}

最后让我们讨论一下到底哪些依赖需要 mock,哪些不需要 mock。简单点说:所有可能出现不可控情况的依赖都需要 mock,这里的不可控主要分两种:

  • 一种是运行时间的不可控:比如一个高 CPU 任务,单次执行需要一分钟,但是有一百个测试用例要跑,此时就需要 mock。
  • 一种是运行结果的不可控:比如 mysql,redis 之类的 IO 请求,虽然它们可能运行的很快,但是因为网络本身的限制有可能失败,此时需要 mock。

不过 mock 虽好,但不要贪杯,千万不要手里拿着锤子,看哪都像钉子。举个例子:Golang 里最流行的配置工具 Viper[7],其最常用的使用方式都是静态调用,比如:「viper.GetXxx」,并没有使用接口,自然 mock 也就无从谈起,不过我们可以通过「viper.Set」很简单的替换方法的返回值,此时 mock 与否也就不再重要了。

参考资料

[1]

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


推荐阅读

福利
我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

图片

9120白话 Golang 单元测试

root

这个人很懒,什么都没留下

文章评论