Testify 提供了单测方便的断言能力,这里的断言是将对代码实际返回的断言,代码的实际输出和预期是否一致。下面是 gin-gonic/gin 代码库的单测代码,Testify 还提供了很多其他的方法:
assert.Equal(t, "admin", user)
assert.True(t, found)
单元测试中也会存在不稳定的代码,我们的入参虽然保持不变,但每次单测的结果可能会发生变化。比如说,我们会调用第三方的接口,而第三方的接口可能会发生变化。再比如,代码中有通过 time.Now()
获取最近7天内的用户订单,这个返回结果本身就是随当前时间变化的。
当然,我们肯定不希望每次单测都手动调整,来“迎合”这类不稳定的代码,这样不仅疲于奔命,还效率不高。测试上使用 Mock 就可以解决这类问题。这里主要看看如何使用 Testify 的 mock 功能,从下面这个简单的例子出发:
package main
import (
"math/rand"
"time"
)
func DivByRand(numerator int) int {
rand.Seed(time.Now().Unix())
return numerator / int(rand.Intn(10))
}
DivByRand
中除以一个随机数,导致结果是随机的,不可预测的,我们该如何对它进行单测呢?特别强调下,rand.Seed
方法调用是必须的,如果不随机初始化 seed,rand.Intn
每次返回的结果都是相同的。随机数都是基于某个 seed 的随机数,seed 不变,预期的随机数就是固定不变的。
我们针对随机方法的部分,做一个接口声明,以及接口实现,来替代代码中随机的被除数。这里mock需要对原函数做代码改造,我们一起来看一下调整过程:
import (
"github.com/stretchr/testify/mock"
"testing"
)
// 声明随机接口
type randNumberGenerator interface {
randomInt(max int) int
}
// 声明接口的实现
type standardRand struct{}
func (s standardRand) randomInt(max int) int {
rand.Seed(time.Now().Unix())
return rand.Intn(max)
}
// 修改原有的方法,已经修改了原函数的声明
func DivByRand(numerator int, r randNumberGenerator) int {
return numerator / r.randomInt(10)
}
我们声明一个 mock 结构体,匿名嵌套 mock.Mock
。通过嵌套 Mock,结构体就具备了注册方法,返回预期结果的能力。其中,randomInt 的实现对应了我们预期的输入和输出关系。
type mockRand struct {
mock.Mock
}
func newMockRand() *mockRand { return &mockRand{} }
func (m *mockRand) randomInt(max int) int {
args := m.Called(max)
return args.Int(0)
}
最终,我们的单测就变成了下面的样子,其中的 On 用来对结构体的方法 randomInt 做设置,Return 对应了 args.Int(0)。我们执行下面的单测,返回的结果是恒定的。
func TestDivByRand(t *testing.T) {
m := newMockRand()
m.On("randomInt", 10).Return(6)
t.Log(DivByRand(6, m))
}
Testify Mock 方法实现的核心就是 On 和 Return 方法了,它对应的是我们接口的实现。m.Called 函数的返回值类型为 Arguments,包含了函数的返回值信息,args.Int(0)
表示获取 Called 函数的第一个返回值。On 用来给函数设置入参,Return 用来给函数设置出参。
randomInt 方法只返回了一个结果,一般的函数还会额外返回 error 信息,来标志函数执行是否出现异常。这种情况可以通过 args.Error(1)
来获取,表示第二个出参是 error 类型。
从起初的单测,到最后的单测,函数的方法声明被修改了。假设我们要处理的是线上代码,这样的改动其实破坏了代码的稳定性,为了单测,我们还需要重新对代码做回归测试,确保改动不会对线上环境产生影响。但这种改动也可以总结为一种模式,只要按照固定的模式做代码调整就可以了:
所以,这种情况其实不利于存量代码的覆盖率测试,单测最好做到是不要侵入老代码,避免引入不必要风险。但在增量代码上,我们可以使用这种模式,提前按照接口的模式去做功能实现,也就是测试驱动的意思。
在开发代码之前,先想好单测的实现,代码设计上多了一个维度的考量,也会让代码写的更加有扩展性。
观察下面的代码,它用到了更多 testify 库提供的方法,包括 MatchedBy、Once、AssertNumberOfCalls。不过就数 AssertNumberOfCalls 最简单了,用来断言方法的调用次数。
mock.MatchedBy(reqSlothFacts)
本来应该是传递函数 ListAnimalFacts 入参的,现在传递了 mock.MatchedBy 函数的执行结果。我们详细来看一下这几个方法。
func TestGetSlothsFavoriteSnackOnPage2(t *testing.T) {
c := newMockClient()
c.On("ListAnimalFacts", mock.MatchedBy(reqSlothFacts)).
Return(&page1, nil).
Once()
c.On("ListAnimalFacts", mock.MatchedBy(reqSlothFacts)).
Return(&page2, nil).
Once()
favSnack, err := getSlothsFavoriteSnack(c)
if err != nil {
t.Fatalf("got error getting sloths' favorite snack: %v", err)
}
if favSnack != "hibiscus flowers" {
t.Errorf(
"expected favorite snack to be hibiscus flowers, got %s",
favSnack,
)
}
c.AssertNumberOfCalls(t, "ListAnimalFacts", 2)
}
Once 和 sync.Once 要表示的含义是一致的,表示只能执行一次,但多次执行 sync.Once 也是没有问题的,只不过只有第一次生效而已。但 mock 中的 Once 执行两次是会报错的。如果只是用来限定方法的执行次数,想一想,也没啥好单测的。沿用上面的代码,我们稍微做些改动
func TestDivByRand(t *testing.T) {
m := newMockRand()
m.On("randomInt", 10).Return(6).Once()
m.randomInt(10)
m.randomInt(10)
}
执行上面的单测,会发生 panic,提示信息中声明了:assert: mock: The method has been called over 1 times.
。就目前来说,难道除了 panic 就没有什么更好的方式了?
其实 Once 还有另一个特别有用的功能,就是设置函数不同的返回值,还拿 randomInt 函数来说,如果我们期望,同样的入参,第一次调用 randomInt 返回6, 第二次调用 randomInt 返回 5 怎么处理。注意,是相同的入参。我们可以通过这样的方式就能输出 6、5。
func TestDivByRand(t *testing.T) {
m := newMockRand()
m.On("randomInt", 10).Return(6).Once()
m.On("randomInt", 10).Return(5).Once()
t.Log(m.randomInt(10))
t.Log(m.randomInt(10))
}
当然,如果不使用 Once,用下面这种不同的入参,也能达到相同的效果。改动点主要是 On 方法的第二个参数,以及 Return 方法的返回值。
func TestDivByRand(t *testing.T) {
m := newMockRand()
m.On("randomInt", 10).Return(6)
m.On("randomInt", 9).Return(5)
t.Log(m.randomInt(10))
t.Log(m.randomInt(9))
}
最后,我们来看看 MatchedBy 的用法,可以用来校验方法的入参。这个方法的使用约束比较多,参数需要是一个函数,函数的返回只是 bool 类型,校验成功返回 true,校验失败返回 false。函数的入参就是方法的入参类型,且只能处理一个参数,如果方法有多个参数,需要声明多个 MatchedBy。
我们用例子来看一下,我们校验 randomInt 的参数必须等于10,如果不等于10,单测又会抛出 panic。整体来看,MatchedBy 的效用不是特别大。
func TestDivByRand(t *testing.T) {
m := newMockRand()
m.On("randomInt", mock.MatchedBy(func(num int) bool {
return num == 10
})).Return(6)
t.Log(m.randomInt(10))
}
本文主要是参照 Mocks in Go tests with Testify Mock 的示例,不过原文章讲的过于详细