白话Golang单元测试

最近学习某个 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% 的覆盖率,那么我们就必须测试每一个可能的分支:

  • GoodGoodStudy 异常
  • GoodGoodStudy 正常;BuyHouse 异常
  • GoodGoodStudy 正常;BuyHouse 正常;Marry 异常
  • GoodGoodStudy 正常;BuyHouse 正常;Marry 正常

第一版单元测试

对 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 一次性控制多个状态变化,但是这些状态却是静态的,被替换方法的参数和返回值没有关联。

关于 Gomonkey 的原罪

在单元测试领域,关于如何替换掉外部依赖,主要有两种技术,分别是 mock 和 stub:mock 通过接口可以动态调整外部依赖的返回值,而 stub 只能在运行时静态调整外部依赖的返回值,可以说 mock 包含了 stub,或者说 stub 是 mock 的子集,从本质上讲,gomonkey 属于 stub 技术,它存在诸多缺点,比如:

  • 它违反了开闭原则。
  • 运行时必须关闭内联「go test -gcflags=all=-l」。
  • 运行时需要很高的权限,并且不同的硬件需要不同的黑科技实现。

对 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,这里的不可控主要分两种:

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

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

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注