51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

golang单元测试

# golang单元测试 {#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接口,所以若工程并不是面向接口编程,则无法使用该框架。除非重写源码,所以适用性较差。

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

详见Costs and Benefits (opens new window)

# 打桩 {#打桩}

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

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

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

打桩的目的如下:

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

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

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

  • 使用真实数据库
    通过setup 和 teardown做数据库的配置和清理操作。

  • mock数据库
    我们持久层的框架是gorm。可以考虑2种方法进行mock。

    • 使用gomonkey对gorm的函数进行mock
      但碰到下图所示的sql语句,如果使用gomonkey的话需要对连续调用的gorm函数都进行mock,过于繁杂。

      newDB = MysqlDB.ModelTable(c, &Basexxx{}, c.AppID()).Where("type = ?", libType).Limit(limit).Offset(offset).Order("created_at desc").Find(&libxxxs)

    • 选用sqlmock(推荐 )
      用sqlmock的话只需匹配对应的sql语句即可。

      _, mock, _ = sqlmock.NewWithDSN("sqlmock_db")
      MysqlDB.DB, _ = gorm.Open("sqlmock", "sqlmock_db")
      

    缺点:sqlmock不会检查sql语法错误。

使用mock的场景如下:

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

实现Mock的常见方案 : 详见Mock的常见方式 (opens new window)

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}

GoConvey和gomonkey结合的项目示例 (opens new window) [gomonkey教程]](https://www.ancii.com/az6a54bmn/)

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

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

基础用法请参考Golang单元测试实践方案 (opens new window)Gomonkey章节。
为方法和成员函数打桩的示例: gomonkey为方法和成员函数打桩 (opens new window)
gomonkey基础教程 (opens new window)

monkey库有2个,分别为monkey (opens new window)gomonkey (opens new window)。 GoMonkey比monkey写法更简单,推荐GoMonkey。

注意事项:

  • 打桩的方法在run模式下启用
    默认情况下, 如果单元测试代码以run模式运行,那么打桩的函数打桩的方法会无效,即并不会看到打桩的效果。
    官网 (opens new window)有提到解决方法: 运行时增加参数-gcflags=all=-l即可。

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

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

# Mock方案对比 {#mock方案对比}

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

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

# 断言框架选型 {#断言框架选型}

# 为全局变量打桩 {#为全局变量打桩}

推荐采用GoStub框架。参考教程GoStub基础教程 (opens new window)

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

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对函数进行打桩的API很简单,形式如下monkey.Patch( , ),需要注意的是,在用例结束之后,记得调用monkey.UnpatchAll来解除打桩,避免影响其他用例。 详见 golang单元测试 (opens new window)中的 为函数/方法打桩章节。

# 为方法打桩 {#为方法打桩}

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

# 为接口打桩 {#为接口打桩}

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

GoMock框架基础用法 (opens new window) 该章节的示例代码摘抄于GoMock基础教程与入门示例 (opens new window),请前往查看详细教程。

# 安装gomock {#安装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文件 {#生成mock文件}

mockgen -source=./db.go -destination=./db_mock.go -package=main

mockgen有2种操作模式(详见gomock (opens new window)中的使用方式介绍章节):

  • 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)
	}
}

gomock通过EXPECT来打桩(模拟源代码中的某个函数),EXCEPT用法详见Golang单元测试实践方案编写单元测试函数章节。

# 运行测试代码 {#运行测试代码}

  • 使用Golang自带跑测试代码的命令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
    执行命令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}

参考教程Goconvey (opens new window)

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

GoConvey使用教程 (opens new window)

GoConvey示例大全 (opens new window)

# BDD行为驱动开发 {#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界面 {#web界面}

安装web服务:
首先执行命令go get github.com/smartystreets/goconvey安装goconvey web服务(提供web界面): 。

(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命令后,浏览器会自动打开web界面。

  • web界面提供了case编辑器
    通过case编辑器使得我们可以按如下流程写测试用例:先考虑流程和断言,生成代码框架,然后再去代码框架中填写具体的逻辑。 Convey case编辑器
    详见教程Convey case编辑器 (opens new window)

  • 很赞的测试用例结果显示页面
    哪个case错误,哪个断言问题,都很清楚显示出来。
    Convey 测试用例结果显示页面

  • 监控文件变化,则自动跑测试代码
    提高了测试用例编写的效率。

# 测试用例 {#测试用例}

测试用例的基本原则:

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

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

  • 一般性
  • 边界性
  • 异常

推荐采用Table Driven的方式写测试用例,否则每种情况都手工写一次代码的话,会很繁琐。

# 测试覆盖率 {#测试覆盖率}

输出到文件: go test -v -cover -coverprofile=cover.out
若想以html方式浏览,则执行如下命令:go 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

# 运行测试代码 {#运行测试代码-2}

  • 运行某个目录下的所有测试用例
    命令格式:go test -v 目录位置/...
    示例如下:

    (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

# 示例工程 {#示例工程}

详见我github上的示例工程unit-test

赞(3)
未经允许不得转载:工具盒子 » golang单元测试