go-tip
在网上搜索 Go单元测试,我们能找到各种开源工具和方法技巧,也可以照葫芦画瓢、快速地写出示例test case。但回到具体的工程项目里,当我们面对代码里的各种CRUD、接口与实现、内外部依赖时,往往发现很难写出有效的单元测试,空有一身技巧却无从下手。
我也被这个问题困扰许久,也反复在多个项目里折腾,发现要将单元测试落地到项目中,有一条被忽视的gap
。下面我分享一下个人的思路。
Go单元测试的具体语法,本文会一笔带过,想了解细节的同学可以自行搜索。
本文暂不讨论工具类项目,而是聚焦于结构相对复杂的业务类项目。
偏基础工具类的代码库,写单元测试的逻辑会比较直观,也更注重性能等场景。
业务项目通常会进行分层,本文以一个简化后的三层结构为例:
很多复杂的分层可认为是上面的的一种变体。
写Go单元测试的具体语法,本文会一笔带过,想了解细节的同学可以自行搜索。
在业务开发时,有句玩笑话:如果你坚持写单测,最终会变成Postman工程师。虽然这话带有戏谑的色彩,但我们不妨想想它背后的逻辑:
一个项目中的代码是层层调用的,我们以一个满足上述分层的服务为例:
从调用栈来看,写一个顶层函数的单测,既能包括本层代码、又能覆盖下面各层,在最上层(Controller)写单元测试似乎成了最优解。这时,开发者会遇到一个常见问题 - 代码的层层调用,很难屏蔽外部的依赖项,尤其是MySQL/Redis等中间件和自研服务。
接下来是我的经历,相信能引起不少人的共鸣:
阶段一:依赖测试环境的服务写单测,立杆见影地跑通单测、覆盖率也不错。
我的想法:“巧妙的变通” 虽说从单元测试的定义来说,不应依赖外部服务,但不妨把这当作是一种变通,又快又方便。
阶段二:外部服务引入的问题越来越多,严格检查结果的单测很难通过,只能不断删减检查项,导致单测的质量和覆盖率越来越差。
我的想法:对外部环境不得已的“妥协” 外部服务既不稳定,又往往是有状态的,很难支撑单元测试里的各种case。单测能跑通总比跑不通好,单测质量下降并不是我偷懒,而是外部因素的不可控。
阶段三:单测能发现的问题越来越少,还不如用Postman手动请求并观察结果来得有效。食之无味,弃之可惜,单测就只作为评估绩效的指标了。
我的想法:复杂业务项目里的单元测试没什么价值,就仅仅作为一个绩效指标算了。 对项目来说,单测失去了发现问题的能力;对开发者来说,那就只是应付性地去达成单测覆盖率的指标了。
所以,为了保证单测的价值长期有效,我们要 尽可能地屏蔽外部系统的依赖;而对外部依赖的测试,尽可能地交由更高层面的接口测试、功能测试、系统联调等途径去保障。
屏蔽外部依赖,业界主要有两种解法:
第一个解法比较取巧,本质上仍是依赖外部服务,只是由单元测试掌控它们的生命周期。这种方案对于验证中间件相关的功能确实非常方便,但长期维护的成本不低,慎用。(后文会再次提及)
第二个解法是单元测试最为推荐的方式,即常说的 mock/打桩。mock的具体方案依赖编程语言、框架以及对应的生态。例如在Spring
里写单测很方便,包括:
而Go语言在这块并没有得天独厚的优势。下面,我分享一个社区中比较推荐的解法。
图中的重点内容如下:
IoC
的设计原则Mock实现无需依赖外部,我们利用面向对象的特性轻松地解决了这个问题。在复杂的工程中,还应注意两点:
DI是一个非常重要的解耦手段,但Go语言的框架无法强限制,往往只能靠“制定规范”, 如 Kratos。
service层
package service
type Reader struct {
// 依赖dao层的接口
dao.DaoReader
}
// 依赖注入
func InitBook(reader dao.DaoReader) *Reader {
return &Reader{reader}
}
dao层
package dao
// 接口
type DaoReader interface {}
// 业务实现
type MyReader DaoReader
func NewMyReader() DaoReader {
return &MyReader{}
}
mock_dao层(建议另起一个目录)
# 生成mock的示例命令
# 从dao/reader.go中的interface生成
mockgen -source=dao/reader.go -destination=mock_dao/reader.go
// 以下代码为自动生成,并进行了简化
package mock_dao
type MockDaoReader struct {}
func NewMockDaoReader(ctrl *gomock.Controller) *MockDaoReader{
return &MockDaoReader{}
}
于是,就有了正常情况下与单元测试情况下的依赖注入:
package service
// 正常的注入
reader := InitBook(dao.NewMyReader())
// 单元测试的注入
mockReader := mock_dao.NewMockDaoReader(gomock.NewController(t))
reader := InitBook(mockReader)
在开发过程中,上层代码对下层的代码调用往往有具有限制,如限制了传参的类型、数量、范围。以下面的代码为例:
// 上层
func Sum(a, b int) int {
s, _ := sum(a,b)
return s
}
// 下层
func sum(a, b interface{}) (int, error) {
// case1 - a/b 为int
// case2 - a/b 为float
// case3 - a/b 为string
// ...
}
此时,上层Sum
函数的入参限制,会导致下层sum
中被调用到的代码很有限。因此,在上层Sum
进行单元测试,会导致下层sum
函数的测试不完全。
这个代码覆盖率的问题是不可规避的。我们不难得出,在分层场景下,要使某层代码的覆盖率最高,尽量在同层编写单元测试。那么,如果要让整个项目的代码覆盖率达到100%,每层的单测都得写,相信没几个公司经得起这样的投入。
时间有限,我们该如何寻找“最有价值”的单元测试呢?
一个业务项目的代码,最重要的自然是保障业务逻辑。从前面三个分层的职责来看,Service层是我们要聚焦的重点,它的代码测试覆盖度无疑是要优先保障的。
于是,我们优先在Service层写了完整的单测,覆盖率也很高,但回头看到Controller/Dao层代码的覆盖率很低:
既然我们的核心目标是 保障业务逻辑,那么,我们不妨从分层的角度分析一下:Controller层与Dao层的代码对核心业务逻辑的影响有多大?
我们先看看这两层的主要功能:
这两层都具备一个共同特征:高度重复性的基础工作,非常适合建设公共的工具库。于是,Controller/Dao层的建设思路往往会分两步走:
在这种模式下,Controller与Dao层发生的问题可以得到有效控制:
第2点中的工具库设计很重要,建议多考虑一下设计模式与Go语言强类型的特点,能提高用户体验: 比如说,工具库里要传一个时间类型的参数,可以将入参设计为
duration int
(参数类型只有数字),但更好的方式是duration time.Duration
(参数类型同时包含了数字+单位)。
所以,对不熟悉框架的同学,在早期可以投入一些时间写写Controller/Dao层的单测,了解相关工具库的实现;而随着经验的积累,Controller/Dao层会专注于做2件事:
随着项目的迭代,Controller/Dao层会变得越来越“薄”,投入单测的意义就没那么大了。
至此,我们明确了以 保障核心业务逻辑 为单元测试的目标,并以 业务领域层 作为核心的单元测试覆盖对象,项目单元测试覆盖率指标也相对明确了,如:
# 指定service路径下的所有文件,来计算单测覆盖率
go test ./service/... -coverprofile=profile.cov
go tool cover -html=profile.cov -o coverage.html
之后,就是一个不断迭代的过程了。整体的业务项目与工具库呈现如下:
我们先看两段代码:
标准HTTP的handler
package controller
func FooHandler(w http.ResponseWriter, req *http.Request) {
service.Foo(w, req)
}
package service
func Foo(w http.ResponseWriter, req *http.Request) {
fmt.Fprint(w, "my response")
}
Gin框架的Handler
package controller
func FooHandler(c *gin.Context) {
service.Foo(c)
}
package service
func Foo(c *gin.Context) {
c.JSON(200, resp)
}
这两种代码都将协议相关的数据结构http.ResponseWriter
、http.Request
、 gin.Context
传递到了业务领域层。从功能开发来说完全正确,但大幅提升了业务领域层Foo
函数单测的难度。
再看protobuf
方案,通过预定义的接口文档与代码生成技术,让controller层的定义变成了如下形式:
func Foo(ctx context.Context, req *proto.FooRequest) (resp *proto.FooResponse, err error) {
}
业务自定义的Controller层已经实现了与协议解耦,开发者无需关心协议是HTTP
还是gRPC
,数据格式是json
还是form
等。Controller层的这个优势,自然保证了Service层与协议无关。
所以,protobuf
为代表的的IDL方案,对业务领域层的单测更为友好。
在理想状态,Dao层出现问题的概率很小,但实际情况中有诸多限制:
当你评估Dao层的单测会给整个项目带来足够的收益时,自然可以添加Dao层的单测。这时,对于外部依赖的问题,有如下2种方式:
testing.Main
的特性来创建和销毁(类似python中的setUp
和tearDown
)defer
的特性去清理单测产生的数据长期维护这两个方案,都比较费时费力。
VSCode
/Goland
go genereate
特性自动生成本文讨论的业务代码是以对象为最小维度的。如果对象内部涉及到goroutine
、channel
等特性,就需要在该对象的单测设计时有更多的考量,但不会影响整体项目的框架。
无论是框架分层、代码抽象,还是工具库的建设,单元测试都是高度依赖Go项目框架与规范的。良好的代码测试覆盖率是必须要框架适配的,生搬硬套往往让自己写单测写得很疲惫,也会让单元测试慢慢失去价值。
在Go项目中,要保证核心代码的高测试覆盖率,难度往往比需求开发高 - 往往过程性思维的CRUD,就能满足完成需求,而优秀的单元测试则为了保证测试的完备性,需要相当的抽象能力,并且持续重构。
道阻且长,行则将至。
Github: https://github.com/Junedayday/code_reading Blog: http://junes.tech/ Bilibili: https://space.bilibili.com/293775192 公众号: golangcoding