三剑客“ gomock、monkey、sqlmock"/>
如何做好单元测试?Golang Mock”三剑客“ gomock、monkey、sqlmock
一、前言
单元测试一直是一个研发过程中老生常谈的话题,能够把单元测试做的比较好的公司也寥寥可数。最近同事开玩笑说最不喜欢的两件事情”接手的代码没有单测和别人让我写单测“,也能看得出大家对单测是又爱又恨。但真实情况是单测确实能够提高质量,一般公司架构团队或TL会要求业务研发有单测指标,但很容易因为 ”成本“ 问题最终以失败收尾,那怎么能够降低单测成本又能享受到单测带来和好处就是本文的”目的“了。
想要实现一个低成本的单测基本要从以下问题入手:
- 代码可测性
- 低成本mock
- 逻辑断言工具
资料汇总:
- 引用:
- monkey 原理解读
- .html
二、【新手入门】单元测试解决什么问题?
单元测试(unit test)是最小、最简单的软件测试形式、这些测试用来评估某一个独立的软件单元,比如一个类,或者一个函数的正确性。这些测试不考虑包含该软件单元的整体系统的正确定。单元测试同时也是一种规范,用来保证某个函数或者模块完全符合系统对其的行为要求。单元测试经常被用来引入测试驱动开发的概念。
在*_test.go文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数:
测试单数 | 函数名前缀为Test | 测试程序的一些逻辑行为是否正确 |
基准函数 | 函数名前缀为Benchmark | 测试函数的性能 |
示例函数 | 函数名前缀为Example | 提供示例 |
大家可以看到下面这张图,从最上面依次我们可以理解为“黑盒端到端测试”、“单服务接口测试”以及“方法级别的单元测试”,他们三者的会有两个维度的不同那就是成本和频率,黑盒UI测试一次迭代基本上完整的回归也就1~2次,每次测试按天计数,服务测试可以理解为借口自动化测试脚本,每次测试按环境发布次数计数,频率最高运行成本最低的就是单测,每次提交代码都可以运行一次单测检测。
PS: 单测还有一大成本就是本文提到的写单测的成本,如果这个成本和研发接口的成本差不多这就是一个糟糕的单测,如果单测只有一个接口研发的20%的成本那是非常值得做的。
三、你编码的时候考虑单测可测性了吗?
首先我们可以看看一些主流项目是如何写单测的:
- .go
- .go
- .go
tests := []struct {name stringmessage stringwant []string}{{name: "no match",message: "Hello world!",want: nil,},{name: "contains issue numbers",message: "#123 is fixed, and #456 is WIP",want: []string{"#123", " #456"},},{name: "contains full issue references",message: "#123 is fixed, and user/repo#456 is WIP",want: []string{"#123", " user/repo#456"},},}for _, test := range tests {t.Run(test.name, func(t *testing.T) {got := issueReferencePattern.FindAllString(test.message, -1)assert.Equal(t, test.want, got)})}
看完之后大家是不是都发现了同一个特点,阶段明准备数据->并发执行->断言结果,大家在看看自己开发的业务代码是否可以按照这种方式进行 “高比例覆盖” 呢?
如果可以证明你的可测性做的很不错,如果不行大家就要思考思考以下几个问题了:
- func 的职责是否清晰,是否一个func只做一件事,是否足够简单
- 是否有偷懒的入参出参,比较典型的就是一个超大的status向下传递,已经大大超出了这个方法原本需要的入参范围了
- 一个方法代码量是否在一屏以内,一个200行的代码想要单测是否非常困难的,每一个逻辑嵌套都会带来单测成倍的工作量
四、选择合适mock工具事半功倍
对于这类开源项目或开源组件一般不会有mock的烦恼,因为它们依赖的中间件非常有限,但是对于我们业务开发就不一样了,每一个中间件都是强依赖,比较典型的就是数据库、cache、MQ了,单测时我们又没有真正意义上的中间件环境,那在读取数据返回结果时要怎么办呢?
那就要请我们三大武林高手:gomock、monkey、sqlmock出山了:
- gomock:强依赖interface进行打桩
- monkey:方法替换改写
- (作者不在更新)
- (已经支持arm和全部go版本)
- 注意:monkey不支持内联函数,在测试的时候需要通过命令行参数 -gcflags=-l 关闭Go语言的内联优化。
- monkey不是线程安全的,所以不要把它用到并发的单元测试中。
- 解决方案:
- sqlmock:通过中间件底层链接进行mock
- github/DATA-DOG/go-sqlmock
- github/go-redis/redismock
- github/alicebob/miniredis/v2
mock三种主流方案对比:
评估项 | gomock | monkey | sqlmock |
---|---|---|---|
代码侵入性 | 强依赖interface,影响编码规范 | 无侵入 | 需要支持动态替换中间件实例 |
成本 | 高 | 低 | 一般 |
灵活性 | 每次增加方法都需要修改mock实现 | 按需mock | 工具受限、场景受限 |
在看例子之前一句话概括这三种不同mock工具适用的场景:
- gomock:适合于 业务代码分层互相依赖已经使用 interface 情况,或对第三方依赖是 interface 情况下使用
- monkey:万金油无论是对方法、变量都可以mock,甚至官方函数都行,但不支持并行测试,改写方法是全局生效的
- sqlmock:不太适合func的单测会增加单测范围以及反调用直觉,比较适合于一一个服务的全流程单测
gomock
func Test_GetCountriesList_ToGoMock(t *testing.T) {Convey("Countries_ToGoMock", t, func() {ctlCity := gomock.NewController(t)defer ctlCity.Finish()ctlCountries := gomock.NewController(t)defer ctlCountries.Finish()cityToolMock := mock_Model.NewMockCityTool(ctlCity)countriesMock := mock_Model.NewMockCountriesTool(ctlCountries)c := []model.Countries{{Id: "CN",Native: "中国",CallingCode: 86,OfficialId: "CHN",Region: "Asia",CountriesIcon: ".svg",Zh: "中国",En: "China",},}gomock.InOrder(countriesMock.EXPECT().GetMapListByType().Return(c, nil),)mapTool := NewMap(cityToolMock, countriesMock)rs, _ := mapTool.GetCountriesList()So(rs[0].Zh, ShouldEqual, "中国")})
}
monkey
func Test_GetCountriesList_ToMonker(t *testing.T) {Convey("err", t, func() {p := monkey.PatchInstanceMethod(reflect.TypeOf(&model.Countries{}), "GetMapListByType", func(_ *model.Countries) ([]model.Countries, error) {c := []model.Countries{{Zh: "中国",},}return c, nil})defer p.Unpatch()rs, _ := MapHandelr.GetCountriesList()So(rs[0].Zh, ShouldEqual, "中国")})}
sqlmock
func Test_GetCountriesList_SqlMock(t *testing.T) {Convey("error", t, func() {//把匹配器设置成相等匹配器,不设置默认使用正则匹配db, mock, err := sqlmock.New()if err != nil {panic(err)}rows := sqlmock.NewRows([]string{"zh"}).AddRow("中国")mock.ExpectQuery("^SELECT \\* FROM `countries`").WillReturnRows(rows)_DB, err := gorm.Open("mysql", db)model.MockDB = _DBrs, _ := MapHandelr.GetCountriesList()fmt.Println(rs)So(rs[0].Zh, ShouldEqual, "中国")})
}
五、单测工具推荐
断言是单测的灵魂,市面上大多数工具都主要提供的是更好的断言能力。
主流断言工具 github/stretchr/testify
testify 绝大多数github开源软件都在使用testify
go get github/stretchr/testify/assertfunc TestSomething(t *testing.T) {assert := assert.New(t)// assert equalityassert.Equal(123, 123, "they should be equal")// assert inequalityassert.NotEqual(123, 456, "they should not be equal")// assert for nil (good for errors)assert.Nil(object)// assert for not nil (good when you expect something)if assert.NotNil(object) {// now we know that object isn't nil, we are safe to make// further assertions without causing any errorsassert.Equal("Something", object.Value)}
}
【强烈推荐】流程化单测工具:github/smartystreets/goconvey
文档:
了解单测的小伙伴一定听说过 ”表格驱动测试“,先定义一堆输入,然后循环测试方法,这里介绍到的goconvey可以称作 ”逻辑驱动测试“,编写单测可以和业务逻辑结合使用goconvey编写一颗逻辑树来覆盖不同的代码分支逻辑。
并且goconvey也有丰富的So断言也支持自定义断言:
package package_nameimport ("testing". "github/smartystreets/goconvey/convey"
)func TestSpec(t *testing.T) {// Only pass t into top-level Convey callsConvey("Given some integer with a starting value", t, func() {x := 1Convey("When the integer is incremented", func() {x++Convey("The value should be greater by one", func() {So(x, ShouldEqual, 2)})})})
}
goconvey自带命令行和可视化工具,项目下执行 “goconvey” 命令回自动打开页面并执行单测
点击目录查看具体代码覆盖率情况:
其他各种包:
- github/frankban/quicktest
- gotest.tools/v3/assert
- go.uber/zap/zaptest
更多推荐
如何做好单元测试?Golang Mock”三剑客“ gomock、monkey、sqlmock
发布评论