文章目录
背景介绍:探索golang 的单元测试框架,看一下哪种框架是结合业务体验更好的。
推荐 和 不推荐 使用的框架,我都会在标题中 标注出来,没有标注的表示体验一般,但也没有特别的缺点,观望态度
一、单元测试框架介绍
1、原生testing
1.1 示例
func TestModifyArr(t *testing.T) {
arr := [3]int{0, 1, 2}
modifyArr(arr)
if 112233 == arr[0] {
t.Logf("[TestModifyArr] 测试修改数组元素成功!")
} else if 0 == arr[0] {
t.Errorf("[TestModifyArr] 测试修改数组元素失败!元素未修改")
} else {
t.Errorf("[TestModifyArr] 测试修改数组元素失败!未知元素: %d", arr[0])
}
}
注意:使用 t.Errorf 的同时,单测也会被置为失败(但是测试不会马上停止,用 FailedNow 或者 Fatalf 才会)
1.2 扩展:Table-Driven 设计思想
其实就是将多个测试用例封装到数组中,依次执行相同的测试逻辑
即使是用其他测试框架,这个设计思想也是挺有用的,用例多的时候可以简化代码量
示例:
var (
powTests = []struct {
base float64
power float64
expected float64
}{
{1, 5, 1},
{2, 4, 16},
{3, 3, 27},
{5, 0, 1},
}
)
// 测试一些math 包的计算方法
func TestMathPkgMethodByTesting(t *testing.T) {
for index, currentTest := range powTests {
if currentTest.expected != math.Pow(currentTest.base, currentTest.power) {
t.Errorf("[TestMathPkgMethod] %d th test: %.2f the power of %.2f is not expected: %.2f",
index, currentTest.base, currentTest.power, currentTest.expected)
}
}
t.Logf("[TestMathPkgMethod] All test passed!")
}
1.3 并行测试
使用方式:在测试代码中执行:t.Parallel(),该测试方法就可以和其他测试用例一起并行执行。
场景:一般在 多个用例需要同时执行,比如测试生产和消费的时候才需要用到。
但是个人不建议这么做,因为这有点违背“单测”的概念:一个单测就测试一个功能。类似的场景也可以通过 单测中设置 channel 多协程来实现。
2、goconvey
2.1 示例
引入方式:
go get github.com/smartystreets/goconvey/convey
import 方式:
import (
. "github.com/smartystreets/goconvey/convey"
)
// 提醒:诸如 goconvey、gomonkey 这些工具类 最好都用这种import方式,减少使用其内部方法的代码长度,让代码更加简洁
func TestMathPkgMethodByConvey(t *testing.T) {
Convey("Convey test pow", t, func() {
for _, currentTest := range powTests {
So(math.Pow(currentTest.base, currentTest.power), ShouldEqual, currentTest.expected)
}
})
}
So 这个方法结构对一开始接触 GoConvey 的同学可能有点不太好理解,这里结合源码简单说明一下:
// source code: github.com\smartystreets\goconvey@v1.6.4\convey\context.go
type assertion func(actual interface{}, expected ...interface{}) string
......
func (ctx *context) So(actual interface{}, assert assertion, expected ...interface{}) {
if result := assert(actual, expected...); result == assertionSuccess {
ctx.assertionReport(reporting.NewSuccessReport())
} else {
ctx.assertionReport(reporting.NewFailureReport(result))
}
}
关键是对So 参数的理解,总共有三个参数:
actual: 输入
assert:断言
expected:期望值
assert 断言看定义,其实也是一个方法,但其实Convey 包已经帮我们定义了大部分的基础断言了:
// source code: github.com\smartystreets\goconvey@v1.6.4\convey\assertions.go
var (
ShouldEqual = assertions.ShouldEqual
ShouldNotEqual = assertions.ShouldNotEqual
ShouldAlmostEqual = assertions.ShouldAlmostEqual
ShouldNotAlmostEqual = assertions.ShouldNotAlmostEqual
ShouldResemble = assertions.ShouldResemble
ShouldNotResemble = assertions.ShouldNotResemble
.....
诸如 判断相等、大于小于 这些判断方法都是可以直接拿来用的。
2.2 双层嵌套
func TestMathPkgMethodByConvey(t *testing.T) {
// 双层嵌套
Convey("Convey test multiple test", t, FailureHalts, func() {
Convey("Failed test", func() {
So(math.Pow(5, 2), ShouldEqual, 26)
log.Printf("[test] 5^3 = 125? to execute!")
So(math.Pow(5, 3), ShouldEqual, 125)
})
Convey("Success test", func() {
log.Printf("[test] 5^2 = 25? to execute!")
So(math.Pow(5, 2), ShouldEqual, 25)
})
})
}
注意:内层的Convey 不再需要加上 testing 对象
注意:子Convey 的执行策略是并行的,因此前面的子Convey 执行失败,不会影响后面的Convey 执行。但是一个Convey 下的子 So,执行是串行的。
2.3 跳过测试
如果有的测试在本次提交 还没有测试完全,可以先用 TODO + 跳过测试的方式,先备注好,下次commit 的时候再完善
SkipConvey:跳过当前Convey 下的所有测试
SkipSo:跳过当前断言
2.4 设置失败后的执行策略
默认 一个Convey 下的多个 So 断言,是失败后就终止的策略。如果想要调整,在Convey 参数中加上 失败策略即可,比如设置 失败后继续,就用 FailureContinues
// source code: github.com\smartystreets\goconvey@v1.6.4\convey\doc.go
const (
......
FailureContinues FailureMode = "continue"
......
FailureHalts FailureMode = "halt"
......
FailureInherits FailureMode = "inherits"
)
但是要注意:这里的失败后策略是针对 一个Convey 下的多个So 断言来说的,而不是一个Convey 下的多个子Convey。所以接下来会讲到Convey 的执行机制:是并行的。
2.5 子 Convey 并发执行的原理简述
GoConvey 底层是借助了 jtolds/gls 这个库实现了 goroutine 的管理,也实现了 多个子Convey 的并发执行。
// source code: github.com\smartystreets\goconvey@v1.6.4\convey\context.go
func (ctx *context) Convey(items ...interface{}) {
......
if inner_ctx.shouldVisit() {
ctxMgr.SetValues(gls.Values{nodeKey: inner_ctx}, func() {
// entry.Func 就是实际的测试方法
inner_ctx.conveyInner(entry.Situation, entry.Func)
})
}
}
// source code: github.com\jtolds\gls@v4.20.0+incompatible\context.go
func (m *ContextManager) SetValues(new_values Values, context_call func()) {
......
// 该方法会判断 是否满足并发执行的条件
EnsureGoroutineId(func(gid uint) {
...... // 解析传入的 context 参数
context_call()
})
}
了解有限,这里不会展开讲 gls 库的原理,借助一些文档,了解到gls 实际就是通过 go 底层的api 对 GPM 模型进行管理,在满足一定条件的时候,会将子Convey 提交到子协程中执行(默认)
对gls 库感兴趣,想了解其 底层 是怎么管理协程的话,可以参考:
gls 官方github 地址
3、testify(推荐)
其实Testify的用法 和 原生的testing 的用法差不多,都是比较清晰的断言定义。
它提供 assert 和 require 两种用法,分别对应失败后的执行策略,前者失败后继续执行,后者失败后立刻停止。 但是它们都是单次断言失败,当前Test 就失败。
func TestGetStudentById(t *testing.T) {
currentMock := gomonkey.ApplyFunc(dbresource.NewDBController, dbresource.NewDBMockController)
defer currentMock.Reset()
schoolService := schoolservice.NewSchoolService()
student := schoolService.GetStudentById("1")
assert.NotEqual(t, "", student.Name)
require.Equal(t, studentsql.TEST_STUDENT_NAME, student.Name)
}
4、测试框架总结
这里简单总结一下几个测试框架:个人觉得 GoConvey 的语法 对业务代码侵入有点严重,而且理解它本身也需要一些时间成本,比如 testify 逻辑清晰。单元测试逻辑本身就要求比较简单,综上,还是更推荐用testify。
二、mock框架介绍
1、gostub(不推荐)
1.1 基本使用
go get github.com/prashantv/gostub
func TestGetLocalIp(t *testing.T) {
// 给变量打桩
varStub := Stub(&testGlobalInt, 100)
defer varStub.Reset()
log.Printf("[test mock] mock var: %d", testGlobalInt)
// 给方法打桩
var getIpFunc = system.GetOutboundIP
funcStub := StubFunc(&getIpFunc, "1.2.3.4")
defer funcStub.Reset()
}
1.2 和 GoConvey 结合示例
1.3 不推荐使用的原因
主要是局限性太多:
gostub 由于方法的mock 还必须声明出 variable 才能进行mock,即使是 interface method 也需要这么来定义,不是很方便
另外,如果需要mock 的方法,入参和返回的 数量都是长度不固定的数组类型,可能就没法定义mock 了
最后,同一个方法,如果需要mock 多种入参出参场景,gostub 也无法实现。这就非常麻烦,mock 不同的参数场景应该算是mock 的基本功能了
2、gomock
官方维护的 mock 框架,只要是对象 + 接口的数据结构,基本都能通过gomock 来直接编写 不同场景的mock。
之前写过一篇关于 gomock 如何使用的基本介绍,总体来说,是比较适用于框架场景的,比如 通过 protobuf 定义并生成的对外对象和接口,如果能自动生成 gomock 代码,对开发就比较方便了。但是对业务代码 并不是特别适合,因为业务内部往往还要定义非常多的对象,每个对象都要生成mock 还是有点麻烦的。
3、gomonkey(推荐)
import "github.com/agiledragon/gomonkey/v2"
3.1 给方法打桩
func TestGetAbsolutePath(t *testing.T) {
// 打桩方法
funcStub := ApplyFunc(config.GetAbsolutePath, testGetAbsolutePath)
defer funcStub.Reset()
log.Printf("config path: %s", config.GetAbsolutePath())
}
总体来说,和 gostub 的使用方法非常类似,也是要通过变量单独指定方法,并设置mock。执行 ApplyFunc 方法
不同的地方在于 StubFunc 直接定义方法的出参(行为结果),但是 ApplyFunc 还需要定义 方法具体的动作(行为本身)
3.2 给方法打序列桩
func TestGetAbsolutePath(t *testing.T) {
// 方法序列打桩
retArr := []OutputCell{
{Values: Params{"./testpath1"}},
{Values: Params{"./testpath2"}},
{Values: Params{"./testpath3"}, Times: 2},
}
ApplyFuncSeq(config.GetAbsolutePath, retArr)
log.Printf("config path: %s", config.GetAbsolutePath())
log.Printf("config path: %s", config.GetAbsolutePath())
log.Printf("config path: %s", config.GetAbsolutePath())
log.Printf("config path: %s", config.GetAbsolutePath())
}
3.3 给全局变量打桩
用法和gostub 的Stub 方法类似,不多赘述了。
另外还有什么 ApplyMethod (为对象的指定方法打桩)、ApplyMethodSeq 等,用法依然是和ApplyFunc 很类似了。详细可以看参考博客,或者直接看源码中的测试例子。
四、总结和展望
这里介绍了单测、mock 的几个通用框架的使用,并总结出 testify + gomonkey 是比较直观好用的框架。
我会在下一篇博客中 介绍这两个测试框架 如何更好地结合实际项目,编写完整的、含mock 的单元测试。