当前位置: 首页 > 工具软件 > Testify > 使用案例 >

Testify Mock 单元测试

云弘壮
2023-12-01

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)
}

使用 Testify mock 功能

我们声明一个 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 类型。

从起初的单测,到最后的单测,函数的方法声明被修改了。假设我们要处理的是线上代码,这样的改动其实破坏了代码的稳定性,为了单测,我们还需要重新对代码做回归测试,确保改动不会对线上环境产生影响。但这种改动也可以总结为一种模式,只要按照固定的模式做代码调整就可以了:

  1. 针对返回结果不确定的方法 ,封装独立的接口声明。
  2. 使用 Testify Mock 重新实现这个接口
  3. 使用 Testify Mock 确定性的实现来替代原来的方法。前提是将之前直接调用方法的地方,修改为接口调用。

所以,这种情况其实不利于存量代码的覆盖率测试,单测最好做到是不要侵入老代码,避免引入不必要风险。但在增量代码上,我们可以使用这种模式,提前按照接口的模式去做功能实现,也就是测试驱动的意思。

在开发代码之前,先想好单测的实现,代码设计上多了一个维度的考量,也会让代码写的更加有扩展性。

库的其他 mock 方法

观察下面的代码,它用到了更多 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 的示例,不过原文章讲的过于详细

 类似资料: