一、前言
单元测试一直是一个研发过程中老生常谈的话题,能够把单元测试做的比较好的公司也寥寥可数。最近同事开玩笑说最不喜欢的两件事情”接手的代码没有单测和别人让我写单测“,也能看得出大家对单测是又爱又恨。但真实情况是单测确实能够提高质量,一般公司架构团队或TL会要求业务研发有单测指标,但很容易因为 ”成本“ 问题最终以失败收尾,那怎么能够降低单测成本又能享受到单测带来和好处就是本文的”目的“了。
想要实现一个低成本的单测基本要从以下问题入手:
- 代码可测性
- 低成本mock
- 逻辑断言工具
资料汇总:
- 引用:https://mp.weixin.qq.com/s/5ebKsHQm2BjKULv6K0mzPA
- monkey 原理解读
二、【新手入门】单元测试解决什么问题?
单元测试(unit test)是最小、最简单的软件测试形式、这些测试用来评估某一个独立的软件单元,比如一个类,或者一个函数的正确性。这些测试不考虑包含该软件单元的整体系统的正确定。单元测试同时也是一种规范,用来保证某个函数或者模块完全符合系统对其的行为要求。单元测试经常被用来引入测试驱动开发的概念。
在*_test.go文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数:
测试单数 | 函数名前缀为Test | 测试程序的一些逻辑行为是否正确 |
基准函数 | 函数名前缀为Benchmark | 测试函数的性能 |
示例函数 | 函数名前缀为Example | 提供示例 |
大家可以看到下面这张图,从最上面依次我们可以理解为“黑盒端到端测试”、“单服务接口测试”以及“方法级别的单元测试”,他们三者的会有两个维度的不同那就是成本和频率,黑盒UI测试一次迭代基本上完整的回归也就1~2次,每次测试按天计数,服务测试可以理解为借口自动化测试脚本,每次测试按环境发布次数计数,频率最高运行成本最低的就是单测,每次提交代码都可以运行一次单测检测。
PS: 单测还有一大成本就是本文提到的写单测的成本,如果这个成本和研发接口的成本差不多这就是一个糟糕的单测,如果单测只有一个接口研发的20%的成本那是非常值得做的。
三、你编码的时候考虑单测可测性了吗?
首先我们可以看看一些主流项目是如何写单测的:
- https://github.com/kubernetes/kubernetes/blob/master/pkg/proxy/service_test.go
- https://github.com/gogs/gogs/blob/main/internal/db/actions_test.go
- https://github.com/gohugoio/hugo/blob/master/source/fileInfo_test.go
tests := []struct {
name string
message string
want []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:方法替换改写
- https://github.com/bouk/monkey (作者不在更新)
- https://github.com/agiledragon/gomonkey (已经支持arm和全部go版本)
- 注意:monkey不支持内联函数,在测试的时候需要通过命令行参数 -gcflags=-l 关闭Go语言的内联优化。
- monkey不是线程安全的,所以不要把它用到并发的单元测试中。
- 解决方案:https://github.com/go-kiss/monkey
- sqlmock:通过中间件底层链接进行mock
- github.com/DATA-DOG/go-sqlmock
- github.com/go-redis/redismock
- github.com/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: "https://pic.cdn.sunmi.com/CountriesICON/chn.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 = _DB
rs, _ := MapHandelr.GetCountriesList()
fmt.Println(rs)
So(rs[0].Zh, ShouldEqual, "中国")
})
}
五、单测工具推荐
断言是单测的灵魂,市面上大多数工具都主要提供的是更好的断言能力。
主流断言工具 github.com/stretchr/testify
testify 绝大多数github开源软件都在使用testify
go get github.com/stretchr/testify/assert
func TestSomething(t *testing.T) {
assert := assert.New(t)
// assert equality
assert.Equal(123, 123, "they should be equal")
// assert inequality
assert.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 errors
assert.Equal("Something", object.Value)
}
}
【强烈推荐】流程化单测工具:github.com/smartystreets/goconvey
文档:https://github.com/smartystreets/goconvey/wiki
了解单测的小伙伴一定听说过 ”表格驱动测试“,先定义一堆输入,然后循环测试方法,这里介绍到的goconvey可以称作 ”逻辑驱动测试“,编写单测可以和业务逻辑结合使用goconvey编写一颗逻辑树来覆盖不同的代码分支逻辑。
并且goconvey也有丰富的So断言也支持自定义断言:https://github.com/smartystreets/goconvey/wiki/Assertions
package package_name
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
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)
})
})
})
}
goconvey自带命令行和可视化工具,项目下执行 “goconvey” 命令回自动打开页面并执行单测
点击目录查看具体代码覆盖率情况:
其他各种包: