解析 Golang 测试(3)- goconvey 实战

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


开篇

今天我们继续 Golang 测试之旅,在此前的文章中我们介绍了 Golang 标准库对测试的原生支持,以及经典的 golang/gomock。而我们今天的主角则是另一个经典的开源库:goconvey

goconvey 是一套单元测试框架,比原生的go testing 好用很多。goconvey提供了很多好用的功能:

  • 多层级嵌套单测

  • 丰富的断言

  • 清晰的单测结果

  • 酷炫的webui

  • 支持原生go test

你可以在【浏览器】或者【命令行】查看运行测试 case 结果。下面我们就来看看 goconvey 是怎么帮助 Gophers 解决单测问题的。

上手操练

为了方便理解,我们操练起来,直接创建一个 demo 项目。本次文章所有源码可以通过 go-test-demo 查看。

业务逻辑

这里我们先填充一些非常简单的业务场景。假设我们针对 Student 实体,希望实现两个函数:

  • func GetSumScore(students []Student) int64 返回这些学生的分数总和

  • func GetMinimumScore(student []Student) int32 返回最低分

在 go-test-demo 代码库中新建 biz package,添加一个 student.go 文件,填充以下代码:

package biz

import "github.com/ahmetb/go-linq/v3"

type Student struct {
ID    int64
Name  string
Age   int8
Major string
Score int32
}

// 返回这些学生的分数总和
func GetSumScore(students []Student) int64 {
return linq.From(students).SelectT(
func(s Student) int32 {
return s.Score
}).SumInts()
}

// 返回最低分
func GetMinimumScore(student []Student) int32 {
return linq.From(student).SelectT(
func(s Student) int32 {
return s.Score
}).Min().(int32)

}
复制代码

这里逻辑非常简单,用了 linq 的能力来处理这组对象,方便我们测试即可。

添加 goconvey 依赖

一个 go get 就解决问题,很简单。截止发文时的 goconvey 版本是 v1.7.2

$ go get github.com/smartystreets/goconvey
复制代码

浅析 goconvey 用法

单测需要干什么呢?

其实我们的函数干的事情无非是三点:

  • 接收 Input;

  • 执行一组动作;

  • 提供 Output。

这三点全是可选的。根据具体的业务场景来定。

我们来看下官方给的示例是怎样的:

package package_name

import (
    "testing"
    . "github.com/smartystreets/goconvey/convey"
)

func TestSpec(t *testing.T) {

// Only pass t into top-level Convey calls
Convey("Given some integer with a starting value", t, func() {
x := 1

Convey("When the integer is incremented", func() {
x++

Convey("The value should be greater by one", func() {
So(x, ShouldEqual, 2)
})
})
})
}
复制代码

这里可以看到 func TestXXX(t *testing.T) 的函数签名和我们平常的用法并没有两样。详细的代码解析我们待会儿会聊,这里先从上手的层面看一下,要点有三个:

第一步,你需要 import . "github.com/smartystreets/goconvey/convey" 进来,官方建议直接 import . 就是为了方便大家直接使用 goconvey 中的各种定义,无需再加前缀。

第二步,使用 Convey 来构建你的测试场景。

  • Convey 确定了你的场景范围,这个边界非常重要,我们把这个范围称为 scope;

  • 每个 scope 都包含一个 description 字段用来描述当前场景,以及一个 func() 函数用来嵌套其他对于 Convey,Reset,说断言的调用。

  • Convey 是可以嵌套的,这样我们就可以构造出来一条条测试的场景路径。

等一下,我看 Convey 的签名很奇怪,为什么是 func Convey(items ...interface{})

这里就是设计者为了保持灵活性,用了一些反射的能力,直接看就不太清晰了,这里约定如下:

  • 最上层的 Convey 需要遵守这样的规则:

    Convey(description string, t *testing.T, action func())

  • 其他层级的嵌套 Convey 不需要传入 *testing.T,只需要按照下面规范即可:

    Convey(description string, action func())

跟其他把反射玩得很溜的框架一样,Convey 这种及其灵活的 items ...interface{} 入参决定了只能在运行时校验你的传参逻辑是否正确。

所以,如果你传错了,它会直接 panic。。。。。Be aware!

除此之外,你还可以指定 FailureMode,但实际用的不是很多,这里我们先上手,感兴趣的同学参考官方说明了解一下:

Additionally, you may explicitly obtain access to the Convey context by doing:
Convey(description string, action func(c C))
You may need to do this if you want to pass the context through to a
goroutine, or to close over the context in a handler to a library which
calls your handler in a goroutine (httptest comes to mind).
All Convey()-blocks also accept an optional parameter of FailureMode which sets
how goconvey should treat failures for So()-assertions in the block and
nested blocks. See the constants in this file for the available options.
By default it will inherit from its parent block and the top-level blocks
default to the FailureHalts setting.
This parameter is inserted before the block itself:
Convey(description string, t *testing.T, mode FailureMode, action func())
Convey(description string, mode FailureMode, action func())
See the examples package for, well, examples.

第三步,使用 So 来对 SUT(System Under Test,即待测系统)进行断言。So 的函数体其实非常简单:

func So(actual interface{}, assert Assertion, expected ...interface{}) {
mustGetCurrentContext().So(actual, assert, expected...)
}
复制代码

无需在意这个 mustGetCurrentContext,大家只需要记住 So 的函数签名。

参数一:SUT 提供的实际值;
参数二:断言条件;
参数三:预期值。

goconvey 提供了一系列【断言条件】供大家使用,大部分都是类似 ShouldXXX 格式,来比对前后两个参数之间的关系。

注意,这个 expected 预期值的类型是一个 ...interface{},也就意味着可能传多个,根据具体你的【断言条件】来确定。

如果断言失败,goconvey 底层会调用 t.Fail() 方法来告诉 Golang,你的 go test 命令就会失败,切记如果使用了 goconvey,就不要在自己的代码里再手动调用 t.Fail 了。

好了,目前为止我们已经基本了解了 goconvey 的基本语法,下面我们再来回顾官方给出的 demo:

package package_name

import (
    "testing"
    . "github.com/smartystreets/goconvey/convey"
)

func TestSpec(t *testing.T) {

// Only pass t into top-level Convey calls
Convey("Given some integer with a starting value", t, func() {
x := 1

Convey("When the integer is incremented", func() {
x++

Convey("The value should be greater by one", func() {
So(x, ShouldEqual, 2)
})
})
})
}
复制代码
  • 第一个 Convey 作为最上层的函数,加上了 t 这个 *testing.T 参数,先提供了变量 x,初始化为 1;

  • 嵌套的 Convey 基于变量 x 进一步给出限定条件,把这个 integer 自增 1;

  • 最内层 Convey 只包含一个 So 断言,要求 x 等于 2.

用 description 来描述场景,Convey 嵌套来构建出一条测试路径,最后 So 断言,这样的设计还是非常有意思的。

填写单测代码

好了,现在我们知道 goconvey 怎么玩了,开始基于我们的 Student 两个函数,依葫芦画瓢,写一个我们自己的测试case。

在源码目录下,建立 conveyexp(实验goconvey功能)的包,新建两个文件:

  • min_test.go(用来测试 GetMinimumScore 函数);
package conveyexp

import (
"testing"

"github.com/ag9920/go-test-demo/biz"
. "github.com/smartystreets/goconvey/convey"
)

func TestMin(t *testing.T) {
// Only pass t into top-level Convey calls
Convey("Given some students with scores", t, func() {
students := []biz.Student{
{
ID:    1,
Name:  "join",
Score: 12,
},
{
ID:    2,
Name:  "michelle",
Score: 13,
},
{
ID:    3,
Name:  "kelly",
Score: 5,
},
}
initialMin := biz.GetMinimumScore(students)

Convey("The result of GetMinimumScore should be the minimum score", func() {
So(5, ShouldEqual, initialMin)
})

Convey("When a new score becomes the minimum", func() {
students[0].Score = 3

Convey("The minimum score should be updated", func() {
newMin := biz.GetMinimumScore(students)
So(newMin, ShouldEqual, students[0].Score)
})
})
})
}
复制代码
  • sum_test.go(用来测试 GetSumScore 函数)
package conveyexp

import (
"testing"

"github.com/ag9920/go-test-demo/biz"
. "github.com/smartystreets/goconvey/convey"
)

func TestGetSumScore(t *testing.T) {

// Only pass t into top-level Convey calls
Convey("Given some students with scores", t, func() {
students := []biz.Student{
{
ID:    1,
Name:  "join",
Score: 12,
},
{
ID:    2,
Name:  "michelle",
Score: 13,
},
{
ID:    3,
Name:  "kelly",
Score: 5,
},
}
initialSum := biz.GetSumScore(students)

Convey("The result of GetSumScore should be equal to the sum of scores", func() {
So(30, ShouldEqual, initialSum)
})

Convey("When any of the score is incremented", func() {
students[0].Score++

Convey("The sum should be greater by one", func() {
newSum := biz.GetSumScore(students)
So(newSum, ShouldEqual, initialSum+1)
})
})

Convey("When any of the score is decremented", func() {
students[1].Score--

Convey("The sum should be less by one", func() {
newSum := biz.GetSumScore(students)
So(newSum, ShouldEqual, initialSum-1)
})
})
})
}
复制代码

这里我们不仅仅有嵌套的 Convey,还有并列的 Convey,通过这种关系来表达各个不同测试之间的关联关系。

代码本身并不复杂,大家结合代码理解一下,其实有了 description 参数,我们就可以建立起来类似 BDD (行为驱动测试)这样的语义:

  • Given【初始条件】

  • When 【一些动作】

  • Then 【发生的结果】

下面我们直接在 conveyexp 目录下运行 go test -v 来看看测试是否通过:

undefined

可以看到,不仅仅测试通过,goconvey 还很贴心的把我们的 description 都打印了出来,这样我们就能知道哪里过,哪里不过。

TestMain 改造

下面我们给测试 case 加上一个 TestMain 作为统一入口。

注意,TestMain 中需要加上 SuppressConsoleStatisticsPrintConsoleStatistics ,用于在TestMain 测试完成后输出测试结果。参照 函数说明

package conveyexp

import (
"os"
"testing"

. "github.com/smartystreets/goconvey/convey"
)

func TestMain(m *testing.M) {
// convey在TestMain场景下的入口
SuppressConsoleStatistics()
result := m.Run()
// convey在TestMain场景下的结果打印
PrintConsoleStatistics()
os.Exit(result)
}
复制代码

Reset

Reset 函数支持我们注册一个清理的函数进来,在同一个 Scope 的 Convey 执行过后就会由框架自行调用。你可以把它类比为 Golang 中的 defer。

func Reset(action func())
复制代码

示例:

func TestSingleScopeWithMultipleRegistrationsAndReset(t *testing.T) {
output := prepare()

Convey("reset after each nested convey", t, func() {
Convey("first output", func() {
output += "1"
})

Convey("second output", func() {
output += "2"
})

Reset(func() {
output += "a"
})
})

expectEqual(t, "1a2a", output)
}
复制代码

执行顺序

很多同学一开始不注意,以为会顺序执行各个 Convey,其实不是的。详细的分析参考 wiki

Convey A
    So 1
    Convey B
        So 2
    Convey C
        So 3
复制代码

比如上面这个顺序,它会怎样执行呢?

其实并不是 A1B2C3,注意,goconvey 最强大的地方就是它的树形结构。

实际的顺序是先 A1B2 然后 A1C3。这样可以省下来大量的代码,因为我是按照路径来构建测试case的,所以写 n 个case实际上用 log(n) 个语句即可,你不要大量重复的 setup 以及 clean 逻辑,控制好层级即可。

It allows you two write n tests with log(n) statements. This "tree-based" behavioral testing eliminates so much duplicated setup code and is so much easier to read for completeness (versus pages of unit tests) while still allowing for very well isolated tests (for each branch in the tree).

同理,对于这个测试:

Convey A
    So 1
    Convey B
        So 2
        Convey Q
        So 9
    Convey C
        So 3
复制代码

最后执行的顺序是:A1B2Q9 => A1C3

这就是 scope 和树形结构带来的便利。另一个大家需要关注的踩坑点是,变量的范围。一定要记住你的变量是在哪个 scope 声明,赋值的。

    Convey("Setup", func() {
        foo := &Bar{}
        Convey("This creates a new variable foo in this scope", func() {
            foo := &Bar{}
        }
        Convey("This assigns a new value to the previous declared foo", func() {
            foo = &Bar{}
        }
    }
复制代码

上面这种隔离执行(树形执行),不读文档去理解的话有违多数人的直觉,但理解到位了,挺有用处。能方便的进行 setup 和 teardown (Reset函数)。

但这种机制,虽然方便我们少写代码,也是有劣势的。树形结构,要多次跑父节点,这样会让单测时间变长。有些时候我们的的确确需要顺序执行,比如上面的 A1B2 如果是个很重的操作,我们不希望每次都从这个【根节点】重新出发再做一遍。针对这种顺序的场景,在目前 goconvey 不支持的情况下,可以用 Print 来代替 Convey 来检测 side effect,而不是每次都回根节点。

结语

今天我们学习了 goconvey 的基础用法,更多细节欢迎大家阅读 官方wiki,以及 更多示例

标签: 测试