解析 Golang 测试(9)- 一篇文章搞懂 testify

时间:2022-9-2     作者:smarteng     分类: Go语言


写在前面

眨眼间,我们的【解析 Golang 测试】系列已经到第九篇了,一开始写的时候只是想补齐自己对于测试的方法论和工具的理解,一两篇文章也就够了,越写到后来发现越有意思,便一直更新下来。

单测其实也是正常要交付的代码,保持敬畏之心会帮助我们在设计技术方案,开发业务代码时思考更多。一个只靠历史PRD 和大脑记忆的项目一定是不稳的,重构时我们也很难放心。好的单测在第一版上线时作用可能并不明显,但时间越长,收益越大。到底单测好不好,设计也不好,慢慢就显露出来了。

所以,我们这个系列还会继续,工具篇逐渐收尾,但概念,方法论,规范的部分还有很多没有涉及的,后续我们会更新相关的文章。感谢大家关注。

开篇

好了,进入我们的正题,今天我们的主角是【testify】,相信很多 Gophers 都或多或少用过。个人使用体会上来说,testify 几乎是除了官方的标准库,gomock 之外,使用最多的测试工具库了。

但是具体 testify 能干什么,我们除了基础的 assert 之外还能做什么事,想必很多同学都没有仔细看过。今天我们就来学习一下。

testify

A toolkit with common assertions and mocks that plays nicely with the standard library

testify 对自己的定位是:一个包含了常见的断言,以及mock能力的测试工具箱,能够和 Golang 标准库完美契合。项目本身诞生已经有 10年之久,几乎可以说随着 Golang 初版诞生就已经有了 testify,在测试方面可以说是经典的选择。

目前社区的维护者们还在收集大家的反馈,规划 testify v2 新版本的设计,如果在使用中有问题,大家也可以到这个链接提反馈:

ℹ️ We are working on testify v2 and would love to hear what you'd like to see in it, have your say here: cutt.ly/testify

简单来说,testify 提供了三类能力:

  1. assertion:断言,这也是我们用的最多的能力;

  2. mock:也就是我们常说的 Test Double,我们需要使用 mockery 这个工具进行代码生成;

  3. suite:测试套件的能力,提供了各种钩子函数。

下面我们会在各个小节里分别介绍这三个功能,相关源码我们依然会放到 go-test-demo 库中。

记得测试前先添加 testify 的依赖:

go get github.com/stretchr/testify
复制代码

注意 testify 支持 Go 1.13 及之后的版本,建议大家本地学习开发的时候尽量保持 Golang 升级到最新,这样很多新的 feature 都可以提前体验。

assertion

assertion 的部分主要是支持我们指定条件来进行断言,很多时候我们需要校验某个函数/方法输出的结果是否符合预期,如果用原生的标准库支持,我们需要做类似这样的比对:

package yours

import (
    "testing"
)

func TestSomething(t *testing.T) {
    expectResult := int(5)
    result := dummyFn()
    if result != expectResult {
        t.Errorf("dummyFn return wrong result, expect:%d, got:%d", expectResult, result)
    }
}
复制代码

而有了 testify/assert,我们就可以用一行来解决问题:

package testifyexp

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestSomething(t *testing.T) {
expectResult := int(5)
result := dummyFn()
assert.Equal(t, expectResult, result)
}

func dummyFn() int {
return 7
}
复制代码

这样好处在于:1.语义清晰,我就是要断言,二者是否相等;2.省代码,简洁。

记得测试文件命名格式是有要求的,需要是 xxxx_test.go ,如果不符合格式,比如 test.go ,运行 go test -v 之后是不会执行测试的。

=== RUN   TestSomething
    dummy_test.go:12: 
                Error Trace:    /Users/ag9920/go/src/github.com/ag9920/go-test-demo/testifyexp/dummy_test.go:12
                Error:          Not equal: 
                                expected: 5
                                actual  : 7
                Test:           TestSomething
--- FAIL: TestSomething (0.00s)
FAIL
exit status 1
FAIL    github.com/ag9920/go-test-demo/testifyexp       2.286s
复制代码

这里输出的错误信息也更清晰,不用我们自己费劲儿地在 t.Errorf 里面表达了。这里有 Error Trace,有测试名称,也有具体的不匹配原因。用起来很方便。

底层支撑我们做 assert.Equal 的函数实现如下:

func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
if err := validateEqualArgs(expected, actual); err != nil {
return Fail(t, fmt.Sprintf("Invalid operation: %#v == %#v (%s)",
expected, actual, err), msgAndArgs...)
}

if !ObjectsAreEqual(expected, actual) {
diff := diff(expected, actual)
expected, actual = formatUnequalValues(expected, actual)
return Fail(t, fmt.Sprintf("Not equal: \n"+
"expected: %s\n"+
"actual  : %s%s", expected, actual, diff), msgAndArgs...)
}
return true
}
复制代码

这里有一个核心的接口需要说明,关注一下入参 TestingT,这其实是个接口:

// TestingT is an interface wrapper around *testing.T
type TestingT interface {
Errorf(format string, args ...interface{})
}
复制代码

类似我们常用的 fmt.Errorf ,收到一个 format 和一组参数,不过这里没有出参。

事实上,我们一直依赖的 testing.T 就实现了这个接口:

// T is a type passed to Test functions to manage test state and support formatted test logs.
//
// A test ends when its Test function returns or calls any of the methods
// FailNow, Fatal, Fatalf, SkipNow, Skip, or Skipf. Those methods, as well as
// the Parallel method, must be called only from the goroutine running the
// Test function.
//
// The other reporting methods, such as the variations of Log and Error,
// may be called simultaneously from multiple goroutines.
type T struct {
common
isParallel bool
isEnvSet   bool
context    *testContext // For running tests and subtests.
}

// Errorf is equivalent to Logf followed by Fail.
func (c *common) Errorf(format string, args ...any) {
c.checkFuzzFn("Errorf")
c.log(fmt.Sprintf(format, args...))
c.Fail()
}

// Fail marks the function as having failed but continues execution.
func (c *common) Fail() {
if c.parent != nil {
c.parent.Fail()
}
c.mu.Lock()
defer c.mu.Unlock()
// c.done needs to be locked to synchronize checks to c.done in parent tests.
if c.done {
panic("Fail in goroutine after " + c.name + " has completed")
}
c.failed = true
}
复制代码

可以看到,testing.T 包含的 common 结构,实现了 testify 这里定义的 TestingT 接口。其实本质就是打了个 log,通过 fmt.Sprintf(format, args...) 来拼出来一个包含错误信息的字符串。

有一点注意,Errorf 这里实现的最后调用了 Fail 方法,这里只是标记函数失败,但是不终止执行。

断言函数

上面我们大体了解了 assert.Equal 的作用和实现,其实作为开发者我们不太关心具体原理,重点在于函数签名是否能够满足我们的场景。

assert 下面有非常多很便捷的函数来支撑各个场景的诉求,基本上参数都是先一个 TestingT 接口,然后是 expect 预期的值,最后是 actual 实际目前拿到的值。

这里我们列举一下常用到的一些函数,没有覆盖到的大家也可以看看 API 文档,找到符合业务场景的断言函数:

True

判断传入的值是否为 true,后面的 msgAndArgs 仅仅是为了在断言失败的时候打印错误信息。

// True asserts that the specified value is true.
//
//    assert.True(t, myBool)
func True(t TestingT, value bool, msgAndArgs ...interface{}) bool 
复制代码

Error

这个用的很多,Golang 经典的 resutl, err := fn() 模式催生了很多 if err != nil 的写法。

很多时候我们拿到一个带 error 响应的函数,需要校验一下是否为 nil,就可以用下面两个函数。NoError 断言这个返回的 error 为 nil。

// NoError asserts that a function returned no error (i.e. `nil`).
//
//   actualObj, err := SomeFunction()
//   if assert.NoError(t, err) {
//   assert.Equal(t, expectedObj, actualObj)
//   }
func NoError(t TestingT, err error, msgAndArgs ...interface{}) bool 

// Error asserts that a function returned an error (i.e. not `nil`).
//
//   actualObj, err := SomeFunction()
//   if assert.Error(t, err) {
//   assert.Equal(t, expectedError, err)
//   }
func Error(t TestingT, err error, msgAndArgs ...interface{}) bool 
复制代码

除了简单判断是否 err == nil 外,assertion 还提供了一些很有用的函数,类比到我们在标准库见过的 As , Is 这样操作。

// ErrorAs asserts that at least one of the errors in err's chain matches target, and if so, sets target to that error value.
// This is a wrapper for errors.As.
func ErrorAs(t TestingT, err error, target interface{}, msgAndArgs ...interface{}) bool

// ErrorIs asserts that at least one of the errors in err's chain matches target.
// This is a wrapper for errors.Is.
func ErrorIs(t TestingT, err, target error, msgAndArgs ...interface{}) bool
复制代码

Equal

前面已经提到了,校验两个对象是否相等。如果是指针变量比较,会找到所指的值对象来比对。函数比较不支持,会永远失败。

// Equal asserts that two objects are equal.
//
//    assert.Equal(t, 123, 123)
//
// Pointer variable equality is determined based on the equality of the
// referenced values (as opposed to the memory addresses). Function equality
// cannot be determined and will always fail.
func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
复制代码

注意,什么是相等呢?

相等同时包含两个语义:1. 类型一致;2. 值相等。

简单说,如果你基于某个类型定义了一个新类型,那么即便值相同,你拿这两个类型用 Equal 来断言也永远会返回 false。为此,assertion 还提供了一个 EqualValues 用来忽略类型,只看值是否相同(底层用了 reflect.DeepEqual 的能力)

// EqualValues asserts that two objects are equal or convertable to the same types
// and equal.
//
//    assert.EqualValues(t, uint32(123), int32(123))
func EqualValues(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
复制代码

Contains

可以用来判断:1.字符串;2.数组(切片);3.map 这三类是否包含对应的子串 或 元素。

// Contains asserts that the specified string, list(array, slice...) or map contains the
// specified substring or element.
//
//    assert.Contains(t, "Hello World", "World")
//    assert.Contains(t, ["Hello", "World"], "World")
//    assert.Contains(t, {"Hello": "World"}, "Hello")
func Contains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool 
复制代码

当然,有 Contains 就有 NotContains,只是取了个反

// NotContains asserts that the specified string, list(array, slice...) or map does NOT contain the
// specified substring or element.
//
//    assert.NotContains(t, "Hello World", "Earth")
//    assert.NotContains(t, ["Hello", "World"], "Earth")
//    assert.NotContains(t, {"Hello": "World"}, "Earth")
func NotContains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool 
复制代码

Zero

校验是否为对应类型的零值(比如 int 的零值为 0,bool 的零值为 false)

// Zero asserts that i is the zero value for its type.
func Zero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool 

// NotZero asserts that i is not the zero value for its type.
func NotZero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool 
复制代码

Nil

校验目标是否为 nil,这个用的也非常多。跟 Zero 函数对比,一个是校验零值,一个是看是否为 nil,注意区分场景。

// Nil asserts that the specified object is nil.
//
//    assert.Nil(t, err)
func Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool 
复制代码

Empty

校验是否为 Empty,这个有点虚,其实所谓 Empty 包含语义很广,参考函数注释,简单说,nil + 零值都算 Empty,算是 Zero 和 Nil 的结合体。

当然,空的 slice 和 map 也是 Empty,这是最常用的场景。

// Empty asserts that the specified object is empty.  I.e. nil, "", false, 0 or either
// a slice or a channel with len == 0.
//
//  assert.Empty(t, obj)
func Empty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool 
复制代码

Regexp

校验是否匹配正则表达式

// Regexp asserts that a specified regexp matches a string.
//
//  assert.Regexp(t, regexp.MustCompile("start"), "it's starting")
//  assert.Regexp(t, "start...$", "it's not starting")
func Regexp(t TestingT, rx interface{}, str interface{}, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}

match := matchRegexp(rx, str)

if !match {
Fail(t, fmt.Sprintf("Expect \"%v\" to match \"%v\"", str, rx), msgAndArgs...)
}

return match
}
复制代码

Assertion 对象

很多时候我们需要多次 assert,每次都要传 *testing.T 也挺费劲的,所以 assert 包提供了一个封装。我们可以这样写:

func TestSomethingV2(t *testing.T) {
assert := assert.New(t)
expectResult := int(5)
result := dummyFn()
assert.Equal(expectResult, result)
}
复制代码

此处 assert := assert.New(t) 就是生成了一个 assert.Assertions 对象

// Assertions provides assertion methods around the
// TestingT interface.
type Assertions struct {
t TestingT
}

// New makes a new Assertions object for the specified TestingT.
func New(t TestingT) *Assertions {
return &Assertions{
t: t,
}
}
复制代码

此时我们再用 assert.Equal 就已经在依赖这个 Assertions 对象的 Equal 方法了

func (a *Assertions) Equal(expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool
复制代码

一个简单的封装,却可以省去一些重复工作,建议使用。

require

require 提供了和 assert 同样的接口,但是遇到错误时, require 直接终止测试,而 assert 返回 false

你需要 import 进来这个包: "github.com/stretchr/testify/require"

func TestSomethingRequire(t *testing.T) {
require := require.New(t)
expectResult := int(5)
result := dummyFn()
require.Equal(expectResult, result)
}
复制代码

用法上和 assert 完全一致,注意,最后这行 require.Equal 是没有返回值的。底层依赖的函数实现如下:

// Equal asserts that two objects are equal.
//
//    assert.Equal(t, 123, 123)
//
// Pointer variable equality is determined based on the equality of the
// referenced values (as opposed to the memory addresses). Function equality
// cannot be determined and will always fail.
func Equal(t TestingT, expected interface{}, actual interface{}, msgAndArgs ...interface{}) {
if h, ok := t.(tHelper); ok {
h.Helper()
}
if assert.Equal(t, expected, actual, msgAndArgs...) {
return
}
t.FailNow()
}
复制代码

前面都一样,重点在于最后的这个 t.FailNow() , 参考官方文档 ,这里我们也贴下 testing.T 这个方法的实现:

// FailNow marks the function as having failed and stops its execution
// by calling runtime.Goexit (which then runs all deferred calls in the
// current goroutine).
// Execution will continue at the next test or benchmark.
// FailNow must be called from the goroutine running the
// test or benchmark function, not from other goroutines
// created during the test. Calling FailNow does not stop
// those other goroutines.
func (c *common) FailNow() {
c.checkFuzzFn("FailNow")
c.Fail()

// Calling runtime.Goexit will exit the goroutine, which
// will run the deferred functions in this goroutine,
// which will eventually run the deferred lines in tRunner,
// which will signal to the test loop that this test is done.
//
// A previous version of this code said:
//
//c.duration = ...
//c.signal <- c.self
//runtime.Goexit()
//
// This previous version duplicated code (those lines are in
// tRunner no matter what), but worse the goroutine teardown
// implicit in runtime.Goexit was not guaranteed to complete
// before the test exited. If a test deferred an important cleanup
// function (like removing temporary files), there was no guarantee
// it would run on a test failure. Because we send on c.signal during
// a top-of-stack deferred function now, we know that the send
// only happens after any other stacked defers have completed.
c.mu.Lock()
c.finished = true
c.mu.Unlock()
runtime.Goexit()
}
复制代码

简单说,FailNow 最后会调用 runtime.Goexit() 函数来退出(当然这里会先执行 defer 的函数调用)。

大家使用的时候记得根据自己的场景,看是用 require 直接退出,还是 assert。

断言小结

其实 assertion 库存在的意义就在于把绝大部分场景下,我们需要做的断言都做好封装,业务直接用就 ok。这里虽然我们列举了一些,但一定注意,他们不是全集,assertion 的函数是非常丰富的。

不仅仅包括我们上面列出的很多【正断言】,还包括很多【逆向断言】,类似 NotEqual , NotContains 这样的函数。

建议大家在任何时候需要断言,都看看 assertion 中是否有你需要的函数,用好【正向断言】,【逆向断言】,就不需要自己来写运算符了,语义更清晰。

另外也要注意区分 assert 和 require,看好你的场景,是否需要终止。

mock

testify 的 mock 其实用的不多,一般我们还是直接用 gomock 里面基于 interface 生成的能力。我们这里只是简单给个示例,感兴趣的同学可以看一下 mock 的文档。

mock 包提供了一个 Mock 对象,它能够追踪其他对象的活动,我们通常将它嵌入到一个【待测】的对象中。类似这样:

type MyTestObject struct {
  // add a Mock object instance
  mock.Mock

  // other fields go here as normal
}
复制代码

我们实现方法的时候,需要调用这个嵌入对象的 Mock.Called(args...) 方法,并返回指定的值。

func (o *MyTestObject) SavePersonDetails(firstname, lastname string, age int) (int, error) {
  args := o.Called(firstname, lastname, age)
  return args.Int(0), args.Error(1)
}
复制代码

Int , Error , Bool 方法都是强类型的 Getter,它们能够根据 index 位置来获取值。比如我们有

(12, true, "Something")
复制代码

这样的参数列表。那么就可以用下面的语句来读出:

args.Int(0)
args.Bool(1)
args.String(2)
复制代码

如果不是基本类型,比如我们自定义了一些类型,可以用一些类型转换的方法来达到类似效果:

return args.Get(0).(*MyObject), args.Get(1).(*AnotherObjectOfMine)
复制代码

这里可能有些同学会有问题 我的方法自然有我的实现,如果我用 mock.Mock.Called 这一套,我自己的实现怎么办?二者怎么跑起来?

这里会要求我们用一些【依赖注入】的手段,在跑单测的时候,用我们这个 TestObject 来替代原来线上的业务实现。这个不是我们今天的重点,感兴趣的同学可以了解一下 Google 的 wire,Facebook 的 injectgo,Uber 的 dig 等方案。

当然,社区里也有配合 testify mock 的代码生成工具,这样就可以提供一个 interface,自动生成 mock 实现。不需要我们每次写大量的代码了,感兴趣的同学可以了解一下:mockery tool

suite

所谓 suite 英文语义是【套件】,我们这里 testify 的 suite 也是非常强大的。你可以将一系列钩子函数,单测都挂在到同一个结构体上,然后继续用 go test 来执行。这里是完整的 suite 包 API 文档.

我们直接看例子

// Basic imports
import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/suite"
)

// Define the suite, and absorb the built-in basic suite
// functionality from testify - including a T() method which
// returns the current testing context
type ExampleTestSuite struct {
    suite.Suite
    VariableThatShouldStartAtFive int
}

// Make sure that VariableThatShouldStartAtFive is set to five
// before each test
func (suite *ExampleTestSuite) SetupTest() {
    suite.VariableThatShouldStartAtFive = 5
}

// All methods that begin with "Test" are run as tests within a
// suite.
func (suite *ExampleTestSuite) TestExample() {
    assert.Equal(suite.T(), 5, suite.VariableThatShouldStartAtFive)
}

// In order for 'go test' to run this suite, we need to create
// a normal test function and pass our suite to suite.Run
func TestExampleTestSuite(t *testing.T) {
    suite.Run(t, new(ExampleTestSuite))
}
复制代码

这里我们 import 进来 suite 包,并将 suite.Suite 内嵌到我们的【测试对象】中,下来就可以用一些约定的签名来实现钩子函数,如这里的 SetupTest。

在 SetupTest 之后,就会进入我们的真正的单测函数 TestExample 。这里的 TestExampleTestSuite 里调用 suite.Run 传入我们的【测试对象】,就可以将这条链路串起来。

光说不练假把式,我们直接来跑一下 go test 验证:

在我们的源码目录里新增 suite_test.go,将上面代码粘贴进来。执行

go test -v -run TestExampleTestSuite

========================================

=== RUN   TestExampleTestSuite
=== RUN   TestExampleTestSuite/TestExample
--- PASS: TestExampleTestSuite (0.00s)
    --- PASS: TestExampleTestSuite/TestExample (0.00s)
PASS
ok      github.com/ag9920/go-test-demo/testifyexp       4.018s
复制代码

注意,我们的 TestExample 里面的实现:

assert.Equal(suite.T(), 5, suite.VariableThatShouldStartAtFive)
复制代码

因为这个时候我们不再有 testing.T 了,此时 suite 包提供了一个机制来透传:

// Suite is a basic testing suite with methods for storing and
// retrieving the current *testing.T context.
type Suite struct {
*assert.Assertions
mu      sync.RWMutex
require *require.Assertions
t       *testing.T
}

// T retrieves the current *testing.T context.
func (suite *Suite) T() *testing.T {
suite.mu.RLock()
defer suite.mu.RUnlock()
return suite.t
}
复制代码

简单说,在 suite.Run 的时候,我们传入的参数 testing.T 会被透传,最后通过 suite.T() 可以获取到。

suite 会认两类函数签名:

  1. 匹配了suite钩子函数的签名(会在约定的时机被调用,参考文档);

  2. 以 TestXXX 开头的单测签名。

其他签名的函数会被忽略,不会在 go test 时被 testify 调用,但可以作为 helper method 存在。

记得完成逻辑的时候一定要在某个单测函数里,加上 suite.Run 来执行这个【测试对象】上的 suite 逻辑,否则 go test 也不会识别。

钩子函数

The most useful piece of this package is that you can create setup/teardown methods on your testing suites, which will run before/after the whole suite or individual tests (depending on which interface(s) you implement).

其实官方 suite 文档也提到了,这个包最大的能力就在于你可以在跑单测的前后来加钩子函数。

AfterTest

在测试结束后,拿到 suite 和 test case 名称,执行这个方法

type AfterTest interface {
AfterTest(suiteName, testName string)
}
复制代码

BeforeTest

在每个测试开始前执行,同样需要 拿到 suite 和 test case 名称,做一些个性化操作。注意跟 SetupTest 的区分,那里是拿不到名称的。

type BeforeTest interface {
BeforeTest(suiteName, testName string)
}
复制代码

SetupAllSuite

在整个 suite 所有单测执行前运行,可以做一些全局的初始化操作。

type SetupAllSuite interface {
SetupSuite()
}
复制代码

SetupTestSuite

也就是我们上面示例的方法,这会在每个 suite 中的 test 执行前运行。

type SetupTestSuite interface {
SetupTest()
}
复制代码

suite 小结

通过构造一个【测试对象】能够承接完整的声明周期,搭配各种钩子函数,同时保留了原有的测试能力,这一点还是非常厉害的。我觉得很多时候我们都可以用 suite 的方式来推进测试一整个模块。有点类似于原生支持中的 TestMain 函数的加强版。大家可以体验一下。

结语

今天我们了解了 testify 的三大功能,其中 mock 用的比较少,我们这里也没有多介绍,感兴趣的同学可以看下官方文档。

其实 testify 最核心的功能还是 assertion,丰富的断言工具库让我们开发时可以最低成本来断言某个结果是否符合预期。大家可以多多尝试。

另外 suite 的能力也非常强大,这里你甚至还可以重新实现钩子逻辑,不过目前提供的能力我觉得已经能够覆盖大多数场景。建议大家多用 assertion,在复杂场景下可以借助 suite 的能力来收敛我们的初始化/清理资源等逻辑。

标签: 测试