likes
comments
collection
share

golang-单元测试和mock框架的介绍和推荐

作者站长头像
站长
· 阅读数 11

背景介绍:探索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 地址

gls godoc

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 结合示例

golang-单元测试和mock框架的介绍和推荐

1.3 不推荐使用的原因

主要是局限性太多: gostub 由于方法的mock 还必须声明出 variable 才能进行mock,即使是 interface method 也需要这么来定义,不是很方便

另外,如果需要mock 的方法,入参和返回的 数量都是长度不固定的数组类型,可能就没法定义mock 了

最后,同一个方法,如果需要mock 多种入参出参场景,gostub 也无法实现。这就非常麻烦,mock 不同的参数场景应该算是mock 的基本功能了

2、gomock

官方维护的 mock 框架,只要是对象 + 接口的数据结构,基本都能通过gomock 来直接编写 不同场景的mock。 之前写过一篇关于 gomock 如何使用的基本介绍,总体来说,是比较适用于框架场景的,比如 通过 protobuf 定义并生成的对外对象和接口,如果能自动生成 gomock 代码,对开发就比较方便了。但是对业务代码 并不是特别适合,因为业务内部往往还要定义非常多的对象,每个对象都要生成mock 还是有点麻烦的。

参考博客-Golang 单元测试详尽指引

3、gomonkey(推荐)

参考博客-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 给全局变量打桩

golang-单元测试和mock框架的介绍和推荐 用法和gostub 的Stub 方法类似,不多赘述了。

另外还有什么 ApplyMethod (为对象的指定方法打桩)、ApplyMethodSeq 等,用法依然是和ApplyFunc 很类似了。详细可以看参考博客,或者直接看源码中的测试例子。

四、总结和展望

这里介绍了单测、mock 的几个通用框架的使用,并总结出 testify + gomonkey 是比较直观好用的框架。 我会在下一篇博客中 介绍这两个测试框架 如何更好地结合实际项目,编写完整的、含mock 的单元测试。