导语
在网上搜索 Go单元测试,我们能找到各种开源工具和方法技巧,也可以照葫芦画瓢、快速地写出示例test case。但回到具体的工程项目里,当我们面对代码里的各种CRUD、接口与实现、内外部依赖时,往往发现很难写出有效的单元测试,空有一身技巧却无从下手。
我也被这个问题困扰许久,也反复在多个项目里折腾,发现要将单元测试落地到项目中,有一条被忽视的gap
。下面我分享一下个人的思路。
Go单元测试的具体语法,本文会一笔带过,想了解细节的同学可以自行搜索。
0. 从业务项目的分层聊起
本文暂不讨论工具类项目,而是聚焦于结构相对复杂的业务类项目。
偏基础工具类的代码库,写单元测试的逻辑会比较直观,也更注重性能等场景。
业务项目通常会进行分层,本文以一个简化后的三层结构为例:
- 请求/响应处理层 - Controller
- 业务领域层 - Service(Domain/Logic)
- 数据访问层 - Dao(Model)
很多复杂的分层可认为是上面的的一种变体。
写Go单元测试的具体语法,本文会一笔带过,想了解细节的同学可以自行搜索。
1. 单元测试的外部依赖问题
在业务开发时,有句玩笑话:如果你坚持写单测,最终会变成Postman工程师。虽然这话带有戏谑的色彩,但我们不妨想想它背后的逻辑:
1.1 从“捷径”到放弃
一个项目中的代码是层层调用的,我们以一个满足上述分层的服务为例:
从调用栈来看,写一个顶层函数的单测,既能包括本层代码、又能覆盖下面各层,在最上层(Controller)写单元测试似乎成了最优解。这时,开发者会遇到一个常见问题 - 代码的层层调用,很难屏蔽外部的依赖项,尤其是MySQL/Redis等中间件和自研服务。
接下来是我的经历,相信能引起不少人的共鸣:
阶段一:依赖测试环境的服务写单测,立杆见影地跑通单测、覆盖率也不错。
我的想法:“巧妙的变通”
虽说从单元测试的定义来说,不应依赖外部服务,但不妨把这当作是一种变通,又快又方便。
阶段二:外部服务引入的问题越来越多,严格检查结果的单测很难通过,只能不断删减检查项,导致单测的质量和覆盖率越来越差。
我的想法:对外部环境不得已的“妥协”
外部服务既不稳定,又往往是有状态的,很难支撑单元测试里的各种case。单测能跑通总比跑不通好,单测质量下降并不是我偷懒,而是外部因素的不可控。
阶段三:单测能发现的问题越来越少,还不如用Postman手动请求并观察结果来得有效。食之无味,弃之可惜,单测就只作为评估绩效的指标了。
我的想法:复杂业务项目里的单元测试没什么价值,就仅仅作为一个绩效指标算了。
对项目来说,单测失去了发现问题的能力;对开发者来说,那就只是应付性地去达成单测覆盖率的指标了。
所以,为了保证单测的价值长期有效,我们要 尽可能地屏蔽外部系统的依赖;而对外部依赖的测试,尽可能地交由更高层面的接口测试、功能测试、系统联调等途径去保障。
1.2 如何屏蔽外部依赖
屏蔽外部依赖,业界主要有两种解法:
- 容器技术 - 将外部依赖转为内部项,跟随单元测试的生命周期
- 代码mock - 拦截对外部依赖的调用,获得可预期的返回结果
第一个解法比较取巧,本质上仍是依赖外部服务,只是由单元测试掌控它们的生命周期。这种方案对于验证中间件相关的功能确实非常方便,但长期维护的成本不低,慎用。(后文会再次提及)
第二个解法是单元测试最为推荐的方式,即常说的 mock/打桩。mock的具体方案依赖编程语言、框架以及对应的生态。例如在Spring
里写单测很方便,包括:
- 底层JVM强大的运行时能力
- Spring的依赖注入
- 社区中成熟的各中间件Mock
而Go语言在这块并没有得天独厚的优势。下面,我分享一个社区中比较推荐的解法。
1.3 适配Go语言的单测方案
图中的重点内容如下:
- 三个虚箭头
- Service层的对象依赖Dao层的接口
- Dao层接口的业务实现,由开发者自行编写代码
- Dao层接口的mock实现,由 gomock 自动生成
- 依赖注入DI
- 业务对象在初始化时注入想要的实现,遵循
IoC
的设计原则 - 正常情况下,注入业务实现;单元测试时,注入mock实现
- 一般可利用google的wire工具来自动化地生成依赖注入的代码
- 业务对象在初始化时注入想要的实现,遵循
Mock实现无需依赖外部,我们利用面向对象的特性轻松地解决了这个问题。在复杂的工程中,还应注意两点:
- DI应和业务的抽象结合起来,不要只当作单纯的一种解耦的工具。
- 业务领域层往往内部也会分为多层(参考DDD),优先梳理上下文关系,才能设计好DI的实现。
DI是一个非常重要的解耦手段,但Go语言的框架无法强限制,往往只能靠“制定规范”, 如 Kratos。
1.4 一个DI示例
service层
1 | package service |
dao层
1 | package dao |
mock_dao层(建议另起一个目录)
1 | 生成mock的示例命令 |
1 | // 以下代码为自动生成,并进行了简化 |
于是,就有了正常情况下与单元测试情况下的依赖注入:
1 | package service |
2. 在有限的时间内,探索“最有价值”的单元测试
2.1 一个代码覆盖率的问题
在开发过程中,上层代码对下层的代码调用往往有具有限制,如限制了传参的类型、数量、范围。以下面的代码为例:
1 | // 上层 |
此时,上层Sum
函数的入参限制,会导致下层sum
中被调用到的代码很有限。因此,在上层Sum
进行单元测试,会导致下层sum
函数的测试不完全。
这个代码覆盖率的问题是不可规避的。我们不难得出,在分层场景下,要使某层代码的覆盖率最高,尽量在同层编写单元测试。那么,如果要让整个项目的代码覆盖率达到100%,每层的单测都得写,相信没几个公司经得起这样的投入。
时间有限,我们该如何寻找“最有价值”的单元测试呢?
2.2 明确核心目标 - 保障业务逻辑
一个业务项目的代码,最重要的自然是保障业务逻辑。从前面三个分层的职责来看,Service层是我们要聚焦的重点,它的代码测试覆盖度无疑是要优先保障的。
于是,我们优先在Service层写了完整的单测,覆盖率也很高,但回头看到Controller/Dao层代码的覆盖率很低:
- 上层Controller的代码无法从Service层调用到,单测覆盖率为0
- 下层Dao层的里的业务实现代码也没有被调用(依赖注入的是mock实现),单测覆盖率也很低
既然我们的核心目标是 保障业务逻辑,那么,我们不妨从分层的角度分析一下:Controller层与Dao层的代码对核心业务逻辑的影响有多大?
2.3 Controller/Dao层的单元测试思路
我们先看看这两层的主要功能:
- Controller层是做的是协议解析和数据转化,如HTTP根据Header里的content-type解析到对应结构体
- Dao层主要负责的工作是数据持久化,比如MySQL里的CRUD
- 为了方便讨论,我们对Dao层做一下延伸,认为与外部应用的RPC交互也是一种Dao层操作
这两层都具备一个共同特征:高度重复性的基础工作,非常适合建设公共的工具库。于是,Controller/Dao层的建设思路往往会分两步走:
- 沉淀并维护公共的工具库 ,并保证其单元测试覆盖率
- Controller层的RPC框架
- Dao层的MySQL ORM/服务SDK
- Controller/Dao层主要工作就是去调用工具库,并适配其接口
在这种模式下,Controller与Dao层发生的问题可以得到有效控制:
- 工具库本身 - 引用优秀的开源库或自建,保证测试完备,自身很少出错(有问题就统一升级)
- 工具库的调用 - 依赖库设计的调用方式与使用者的经验
- Controller/Dao层自身代码 - 只做简单的工具库调用与数据结构的转换
第2点中的工具库设计很重要,建议多考虑一下设计模式与Go语言强类型的特点,能提高用户体验:
比如说,工具库里要传一个时间类型的参数,可以将入参设计为
duration int
(参数类型只有数字),但更好的方式是duration time.Duration
(参数类型同时包含了数字+单位)。
所以,对不熟悉框架的同学,在早期可以投入一些时间写写Controller/Dao层的单测,了解相关工具库的实现;而随着经验的积累,Controller/Dao层会专注于做2件事:
- 选择合适的工具库进行调用
- 数据转化(从一个结构体转化到另一个结构体)
随着项目的迭代,Controller/Dao层会变得越来越“薄”,投入单测的意义就没那么大了。
2.4 评价指标
至此,我们明确了以 保障核心业务逻辑 为单元测试的目标,并以 业务领域层 作为核心的单元测试覆盖对象,项目单元测试覆盖率指标也相对明确了,如:
1 | 指定service路径下的所有文件,来计算单测覆盖率 |
之后,就是一个不断迭代的过程了。整体的业务项目与工具库呈现如下:
3. 单元测试的相关实践
3.1 Controller层不应向下传递协议类参数
我们先看两段代码:
标准HTTP的handler
1 | package controller |
1 | package service |
Gin框架的Handler
1 | package controller |
1 | package service |
这两种代码都将协议相关的数据结构http.ResponseWriter
、http.Request
、 gin.Context
传递到了业务领域层。从功能开发来说完全正确,但大幅提升了业务领域层Foo
函数单测的难度。
再看protobuf
方案,通过预定义的接口文档与代码生成技术,让controller层的定义变成了如下形式:
1 | func Foo(ctx context.Context, req *proto.FooRequest) (resp *proto.FooResponse, err error) { |
业务自定义的Controller层已经实现了与协议解耦,开发者无需关心协议是HTTP
还是gRPC
,数据格式是json
还是form
等。Controller层的这个优势,自然保证了Service层与协议无关。
所以,protobuf
为代表的的IDL方案,对业务领域层的单测更为友好。
3.2 Dao层的业务实现高频出错,怎么写单测?
在理想状态,Dao层出现问题的概率很小,但实际情况中有诸多限制:
- dao层包含很多业务逻辑
- 开发者使用工具库的经验少,CRUD常常犯错
- 历史项目,dao层很难调整,工具库也常常出错
当你评估Dao层的单测会给整个项目带来足够的收益时,自然可以添加Dao层的单测。这时,对于外部依赖的问题,有如下2种方式:
- 优先使用容器,可利用
testing.Main
的特性来创建和销毁(类似python中的setUp
和tearDown
) - 如果不得不依赖测试环境,尽可能地用
defer
的特性去清理单测产生的数据
长期维护这两个方案,都比较费时费力。
3.3 Go的单测有哪些好用的库或者工具?
- Mock类
- 接口相关
- wire 解决依赖注入的利器
- Goland的提取接口 从具体实现中,提取出接口定义,重构代码的利器
- 写单元测试
- testing.Main 统一进行单测依赖项的初始化与销毁的工作,减少重复性代码
- gotests 生成具体单元测试代码的框架,少写很多代码,已集成到
VSCode
/Goland
- testify 断言,可以减少单测的代码量,并增加可读性
- 其它 - 发掘自己写单测时的高度重复性的代码,利用
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