golang单元测试

本文讲述golang单元测试相关基础。

测试分为4个层次,单元测试只是第一个层次,见如下的测试金字塔:
测试金字塔
分别为:

  • 单元测试:对代码进行测试
  • 集成测试:对一个服务的接口测试
  • 端到端测试(链路测试):从一个链路的入口输入测试用例,验证输出的系统的结果
  • UI测试

常犯的错误: 没有断言。没有断言的单测是没有灵魂的。如果只是 print 出结果,单测是没有意义的。 不接入持续集成。单测不应该是本地的 run once ,而应该接入到研发的整个流程中,合并代码,发布上线都应该触发单测执行,并且可以重复执行。 粒度过大。单测粒度应该尽量小,不应该包含过多计算逻辑,尽量只有输入,输出和断言。

单测的特征: A:(Automatic,自动化):单元测试应该是全自动执行的,并且非交互式的。 I:(Independent,独立性):为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。 R:(Repeatable,可重复):单元测试通常会被放到持续集成中,每次有代码 check in 时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。 排除外部依赖:一般不允许有任何外部依赖(文件依赖,网络依赖,数据库依赖等),我们不会在测试代码中去连接数据库,调用api等。这些外部依赖在执行测试的时候需要被模拟(mock/stub)

测试框架: GoStub GoConvey GoMock
Monkey
Gomonkey
sqlmock

各框架的优缺点和对比详见主流的golang测试框架 (opens new window)。
推荐的技术框架选型为: GoConvey(支持测试用例的嵌套、支持断言) + Gomonkey(支持全局变量、函数、方法、接口等任意场景等打桩) + sqlmock(提供数据库层的模拟)。

Monkey和Gomonkey是同类框架,使用方法也比较接近,Gomonkey是后来新出的项目,所以推荐。
GoMock只能测试interface接口,所以若工程并不是面向接口编程,则无法使用该框架。除非重写源码,所以适用性较差。

# 衡量单元测试代码的成本

# 打桩

参考https://zhuanlan.zhihu.com/p/343300926

在单元测试中,通常可以将所涉及的对象分为两种,主要测试对象和次要测试对象。
对于次要测试对象,我们通常只会关注主要测试对象和次要测试对象之间的交互,比如是否被调用、调用参数、调用的次数、调用的结果等,至于次要测试对象是如何执行的,这些细节过程我们并不关注。
我们常常选择使用一个模拟对象来替换次要测试对象,以此来模拟真实场景,对主要测试对象进行测试。而“使用一个模拟对象来替换次要测试对象”这个行为,我们通常称之为“打桩”。因此,“打桩”的作用就是在单元测试中让我们从次要测试对象的繁琐依赖中解脱出来,进而能够聚焦于对主要测试对象的测试上。

Mock和Stub我们不仅可以排除外部依赖,还可以模拟一些异常行为(如数据库服务不可用,没有文件的访问权限等)。

打桩的目的如下:

  • 隔离
    是指将测试任务从产品项目中分离出来,使之能够独立编译、链接,并独立运行。隔离的基本方法就是打桩,将测试任务之外的,并且与测试任务相关的代码,用桩来代替,从而实现分离测试任务。例如函数A调用了函数B,函数B又调用了函数C和D,如果函数B用桩来代替,函数A就可以完全割断与函数C和D的关系。
  • 补齐
    是指用桩来代替未实现的代码,例如,函数A调用了函数B,而函数B由其他程序员编写,且未实现,那么,可以用桩来代替函数B,使函数A能够运行并测试。补齐在并行开发中很常用。
  • 控制
    是指在测试时,人为设定相关代码的行为,使之符合测试需求。

用于实现隔离和补齐的桩函数一般比较简单,只需把原函数的声明拷过来,加一个空的实现,能通过编译链接就行了。 比较复杂的是实现控制功能的桩函数,要根据测试的需要,输出合适的数据

测试数据库操作: 2种做法。

newDB = MysqlDB.ModelTable(c, &Basexxx{}, c.AppID()).Where("type = ?", libType).Limit(limit).Offset(offset).Order("created_at desc").Find(&libxxxs)
  _, mock, _ = sqlmock.NewWithDSN("sqlmock_db")
  MysqlDB.DB, _ = gorm.Open("sqlmock", "sqlmock_db")

使用mock的场景如下:

  • IO类型的,本地文件,数据库,网络API,RPC等
  • 依赖的服务还没有开发好,这时候我们自己可以模拟一个服务,加快开发进度提升开发效率
  • 压力性能测试的时候屏蔽外部依赖,专注测试本模块
  • 依赖的内部函数非常复杂,要构造数据非常不方便

stub与mock的区别: stub和mock是两种最常见的打桩手段,它们都能够用来替换次要测试对象,从而实现对一些复杂依赖的隔离,但是它们在实现和关注点上又有所区别。

Mock:在测试包中创建一个结构体,满足某个外部依赖的接口 interface{}
Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法

GoStub支持为全局变量打桩。虽然也支持函数和方法的打桩,但是不够友好且有很多限制,所以推荐使用monkey patch为函数和方法打桩。

GoStub虽然也支持打桩方法,但对源代码有侵入性,即被打桩方法必须赋值给一个变量,只有以这种形式定义的方法才能别打桩必须赋值给一个变量,只有以这种形式定义的方法才能被打桩。

Mock与Stub相结合
通过将mock与stub结合,不仅能在测试方法中动态的更改实现,还追踪方法的调用情况。详见https://juejin.cn/post/6844903853528186894#heading-15。该文章使用最原始的方法实现了Mock与Stub相结合的方式,开发和维护工作量大。
实际工作中推荐使用专用的框架gomock来实现,gomock让我们既能使用mock与stub结合的强大功能,又不需要手动维护这些mock对象。详见gomock-mock与stub结合 (opens new window).

对源代码的要求:

  • 编写可mock的代码
    mock作用的是接口,因此将依赖抽象为接口,而不是直接依赖具体的类。这样才能通过Mock方式替换(模拟)依赖。
  • 不直接依赖的实例,而是使用依赖注入降低耦合性

相关教程详见使用依赖注入传递接口 (opens new window)或如何编写可 mock 的代码 (opens new window)

# gomonkey

gomonkey支持任何场景的打桩,具体如下:

  • 为一个函数打一个桩
  • 为一个成员方法打一个桩
  • 为一个全局变量打一个桩
  • 为一个函数变量打一个桩
  • 为一个接口打一个桩
  • 为一个函数打一个特定的桩序列
  • 为一个成员方法打一个特定的桩序列
  • 为一个函数变量打一个特定的桩序列
  • 为一个接口打一个特定的桩序列
Gomonkey

注意事项:

go test -v -gcflags=all=-l ./...

仅run模式下需要该参数, debug模式增加该参数反而会报错。

# Mock方案对比

与gomock相比,gomonkey主要有以下特点:

  • gomonkey支持任何场景的打桩,而gomock仅支持对interface的打桩
  • gomonkey的使用不需要通过工具来生成桩代码
  • gomonkey是通过在运行时改写可执行程序来改变调用目标的,而gomock则是先生成桩代码,然后直接去调用桩代码来实现mock,也就是在编译期间
  • gomonkey1.0支持的关键字比gomock少很多,不过2.0增加了许多扩充(dsl库)

# 断言框架选型

# 为全局变量打桩

GoStub

若不对全局变量打桩,那么写出来的测试代码可能长成这样:

func TestGlobalVal(t *testing.T) {
 val1 := global.Val1
 val2 := global.Val2
 val3 := global.Val3
 global.Val1 = 5
 global.Val2 = 7
 global.Val3 = 6
 ... // 测试用例代码
 global.Val1 = val1
 global.Val2 = val2
 global.Val3 = val3
}
func TestGlobalVal(t *testing.T) {
 // 对全局变量进行打桩
 stub := gostub.Stub(&global.Val1, 5).
  Stub(&global.Val2, 7).
  Stub(&global.Val3, 6)
 ... // 测试用例代码,这里使用这3个全局变量时的值分别为5,7,6
 // 对全局变量进行复原
 stub.Reset()
}

# 为函数打桩

为函数/方法打桩

# 为方法打桩

使用monkey patch为方法进行打桩的用法为monkey.PatchInstanceMethod(, , ),其中type通过reflect.TypeOf获得,type必须跟方法定义的接收者类型一致。
monkey patch并不支持对包私有(首字母小写)的函数/方法进行打桩。

# 为接口打桩

有2种方法: 1. 自己创建一个实现了某接口的桩对象stub; 2. 使用框架(如gomock)生成实现了某接口的桩对象,解放双手。
gomock支持为接口打桩。GoMock测试框架包含了GoMock包和mockgen工具两部分,其中GoMock包完成对桩对象生命周期的管理,mockgen工具用来生成interface对应的Mock类源文件。

# 安装gomock

go get -u github.com/golang/mock/gomock
go get -u github.com/golang/mock/mockgen

# 准备源文件

// db.go
type DB interface {
	Get(key string) (int, error)
}

func GetFromDB(db DB, key string) int {
	if value, err := db.Get(key); err == nil {
		return value
	}

	return -1
}

# 生成mock文件

mockgen -source=./db.go -destination=./db_mock.go -package=main
使用方式介绍
  • Source模式
    是从源文件产生mock的interfaces文件。 使用-source参数即可。和这个模式配套使用的参数常有-imports和-aux_files。
mockgen -source=foo.go [other options]
  • Reflect模式
    是通过反射的方式来生成mock interfaces。它只需要两个非标志性参数:import路径和需要mock的interface列表,列表使用逗号分割。
mockgen database/sql/driver Conn,Driver

# 写测试用例

func TestGetFromDB(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish() // 断言 DB.Get() 方法是否被调用

	m := NewMockDB(ctrl)
	m.EXPECT().Get(gomock.Eq("Tom")).Return(100, errors.New("not exist"))

	if v := GetFromDB(m, "Tom"); v != -1 {
		t.Fatal("expected -1, but got", v)
	}
}
EXPECTEXCEPT编写单元测试函数

# 运行测试代码

go test -v
(base) wangshibiao@bogon unit-test % go test -v

=== RUN   TestGetFromDB2
-1
--- PASS: TestGetFromDB2 (0.00s)
PASS
ok      unit-test       0.113s
(base) wangshibiao@bogon unit-test %
-run
go test -v -run TestAdd ./... -gcflags=all=-l
goconvey
(base) wangshibiao@bogon unit-test % goconvey 
2021/06/02 09:39:12 goconvey.go:61: Initial configuration: [host: 127.0.0.1] [port: 8080] [poll: 250ms] [cover: true]
2021/06/02 09:39:12 tester.go:19: Now configured to test 10 packages concurrently.
2021/06/02 09:39:12 goconvey.go:178: Serving HTTP at: http://127.0.0.1:8080
2021/06/02 09:39:12 integration.go:122: File system state modified, publishing current folders... 0 4867691922
2021/06/02 09:39:12 goconvey.go:118: Received request from watcher to execute tests...
2021/06/02 09:39:12 goconvey.go:105: Launching browser on 127.0.0.1:8080
2021/06/02 09:39:13 goconvey.go:113: 
2021/06/02 09:39:13 executor.go:69: Executor status: 'executing'
2021/06/02 09:39:13 coordinator.go:46: Executing concurrent tests: unit-test
2021/06/02 09:39:14 parser.go:24: [passed]: unit-test
2021/06/02 09:39:14 executor.go:69: Executor status: 'idle'

# GoConvey

GoConvey能够方便清晰地体现和管理测试用例,断言能力丰富(golang自带testing包没有断言功能)。
Convey语句可以无限嵌套,以体现测试用例之间的关系。需要注意的是,只有最外层的Convey需要传入*testing.T类型的变量t。

# BDD行为驱动开发

Convey属于BDD风格(即Given-when-then方式)的测试框架。示例如下:

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语句是并行执行的,但同一个convey中的So语句是串行执行的。

# 断言

Convey提供了Convey和So两个重要的关键字,还提供了 Shouldxxx等一系列很好用的方法。

package package_name

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

func TestIntegerStuff(t *testing.T) {
	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)
			})
		})
	})
}

# Web界面

go get github.com/smartystreets/goconvey
(base) wangshibiao@localhost apiproject % go get github.com/smartystreets/goconvey
go: github.com/smartystreets/goconvey upgrade => v1.6.4
(base) wangshibiao@localhost apiproject % 

启动web服务:

(base) wangshibiao@localhost apiproject % goconvey
2021/06/01 16:06:25 goconvey.go:61: Initial configuration: [host: 127.0.0.1] [port: 8080] [poll: 250ms] [cover: true]
2021/06/01 16:06:26 goconvey.go:232: Could not find or create the coverage report directory (at: '/Users/wangshibiao/workspace/gopath/pkg/mod/github.com/smartystreets/goconvey@v1.6.4/web/client/reports'). You probably won't see any coverage statistics...
2021/06/01 16:06:26 tester.go:19: Now configured to test 10 packages concurrently.
2021/06/01 16:06:26 goconvey.go:178: Serving HTTP at: http://127.0.0.1:8080
2021/06/01 16:06:26 goconvey.go:105: Launching browser on 127.0.0.1:8080
2021/06/01 16:06:26 integration.go:122: File system state modified, publishing current folders... 0 149182279624
2021/06/01 16:06:26 goconvey.go:118: Received request from watcher to execute tests...
2021/06/01 16:06:26 goconvey.go:113: 
goconvey
case编辑器

# 测试用例

测试用例的基本原则:

  • 每个测试用例只关注一个问题,不要写大而全的测试用例
  • 测试用例是黑盒的
  • 测试用例之间彼此独立,每个用例要保证自己的前置和后置完备
  • 测试用例要对产品代码非入侵

针对同一个函数的测试用例,应该考虑如下场景:

  • 一般性
  • 边界性
  • 异常
Table Driven

# 测试覆盖率

go test -v -cover -coverprofile=cover.outgo tool cover -html=cover.out

可以使用converpkg参数将代码覆盖率限制在某一层,如:

go test  -coverpkg xxx/controllers/... -coverprofile=report/coverage.out ./...
go tool cover -html=report/coverage.out -o report/coverage.html

Convey converpkg

# 运行测试代码

...
(base) wangshibiao@bogon unit-test % go test -v /Users/wangshibiao/workspace/project/wangshibiao/unit-test/...
=== RUN   TestAdd

  将两数相加 ✔


1 total assertion

--- PASS: TestAdd (0.00s)
=== RUN   TestSubtract

  将两数相减 ✔


2 total assertions

--- PASS: TestSubtract (0.00s)
=== RUN   TestMultiply

  将两数相乘 ✔


3 total assertions

--- PASS: TestMultiply (0.00s)
=== RUN   TestDivision

  将两数相除 
    除以非 0 数 ✔✔
    除以 0 ✔


6 total assertions

--- PASS: TestDivision (0.00s)
PASS
ok      unit-test/unit_test_stub_func   (cached)
(base) wangshibiao@bogon unit-test %

# 参考教程

https://zhuanlan.zhihu.com/p/343300926
https://zhuanlan.zhihu.com/p/267341653

# 示例工程

unit-test