Junedayday Blog

六月天天的个人博客

0%

Go语言技巧 - 17.【Go工程化测试】业务项目中的Go单元测试心得

go-tip

导语

在网上搜索 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 如何屏蔽外部依赖

屏蔽外部依赖,业界主要有两种解法:

  1. 容器技术 - 将外部依赖转为内部项,跟随单元测试的生命周期
  2. 代码mock - 拦截对外部依赖的调用,获得可预期的返回结果

第一个解法比较取巧,本质上仍是依赖外部服务,只是由单元测试掌控它们的生命周期。这种方案对于验证中间件相关的功能确实非常方便,但长期维护的成本不低,慎用。(后文会再次提及)

第二个解法是单元测试最为推荐的方式,即常说的 mock/打桩。mock的具体方案依赖编程语言、框架以及对应的生态。例如在Spring里写单测很方便,包括:

  1. 底层JVM强大的运行时能力
  2. Spring的依赖注入
  3. 社区中成熟的各中间件Mock

而Go语言在这块并没有得天独厚的优势。下面,我分享一个社区中比较推荐的解法。

1.3 适配Go语言的单测方案

图中的重点内容如下:

  1. 三个虚箭头
    1. Service层的对象依赖Dao层的接口
    2. Dao层接口的业务实现,由开发者自行编写代码
    3. Dao层接口的mock实现,由 gomock 自动生成
  2. 依赖注入DI
    1. 业务对象在初始化时注入想要的实现,遵循IoC的设计原则
    2. 正常情况下,注入业务实现;单元测试时,注入mock实现
    3. 一般可利用google的wire工具来自动化地生成依赖注入的代码

Mock实现无需依赖外部,我们利用面向对象的特性轻松地解决了这个问题。在复杂的工程中,还应注意两点:

  1. DI应和业务的抽象结合起来,不要只当作单纯的一种解耦的工具。
  2. 业务领域层往往内部也会分为多层(参考DDD),优先梳理上下文关系,才能设计好DI的实现。

DI是一个非常重要的解耦手段,但Go语言的框架无法强限制,往往只能靠“制定规范”, 如 Kratos

1.4 一个DI示例

service层

1
2
3
4
5
6
7
8
9
10
11
package service

type Reader struct {
// 依赖dao层的接口
dao.DaoReader
}

// 依赖注入
func InitBook(reader dao.DaoReader) *Reader {
return &Reader{reader}
}

dao层

1
2
3
4
5
6
7
8
9
10
package dao
// 接口
type DaoReader interface {}

// 业务实现
type MyReader DaoReader

func NewMyReader() DaoReader {
return &MyReader{}
}

mock_dao层(建议另起一个目录)

1
2
3
# 生成mock的示例命令
# 从dao/reader.go中的interface生成
mockgen -source=dao/reader.go -destination=mock_dao/reader.go
1
2
3
4
5
6
7
8
// 以下代码为自动生成,并进行了简化
package mock_dao

type MockDaoReader struct {}

func NewMockDaoReader(ctrl *gomock.Controller) *MockDaoReader{
return &MockDaoReader{}
}

于是,就有了正常情况下与单元测试情况下的依赖注入:

1
2
3
4
5
6
7
package service
// 正常的注入
reader := InitBook(dao.NewMyReader())

// 单元测试的注入
mockReader := mock_dao.NewMockDaoReader(gomock.NewController(t))
reader := InitBook(mockReader)

2. 在有限的时间内,探索“最有价值”的单元测试

2.1 一个代码覆盖率的问题

在开发过程中,上层代码对下层的代码调用往往有具有限制,如限制了传参的类型、数量、范围。以下面的代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 上层
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%,每层的单测都得写,相信没几个公司经得起这样的投入。

时间有限,我们该如何寻找“最有价值”的单元测试呢?

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层的建设思路往往会分两步走:

  1. 沉淀并维护公共的工具库 ,并保证其单元测试覆盖率
    1. Controller层的RPC框架
    2. Dao层的MySQL ORM/服务SDK
  2. Controller/Dao层主要工作就是去调用工具库,并适配其接口

在这种模式下,Controller与Dao层发生的问题可以得到有效控制:

  1. 工具库本身 - 引用优秀的开源库或自建,保证测试完备,自身很少出错(有问题就统一升级)
  2. 工具库的调用 - 依赖库设计的调用方式与使用者的经验
  3. Controller/Dao层自身代码 - 只做简单的工具库调用与数据结构的转换

第2点中的工具库设计很重要,建议多考虑一下设计模式与Go语言强类型的特点,能提高用户体验:

比如说,工具库里要传一个时间类型的参数,可以将入参设计为 duration int (参数类型只有数字),但更好的方式是duration time.Duration(参数类型同时包含了数字+单位)。

所以,对不熟悉框架的同学,在早期可以投入一些时间写写Controller/Dao层的单测,了解相关工具库的实现;而随着经验的积累,Controller/Dao层会专注于做2件事:

  • 选择合适的工具库进行调用
  • 数据转化(从一个结构体转化到另一个结构体)

随着项目的迭代,Controller/Dao层会变得越来越“薄”,投入单测的意义就没那么大了。

2.4 评价指标

至此,我们明确了以 保障核心业务逻辑 为单元测试的目标,并以 业务领域层 作为核心的单元测试覆盖对象,项目单元测试覆盖率指标也相对明确了,如:

1
2
3
# 指定service路径下的所有文件,来计算单测覆盖率
go test ./service/... -coverprofile=profile.cov
go tool cover -html=profile.cov -o coverage.html

之后,就是一个不断迭代的过程了。整体的业务项目与工具库呈现如下:

3. 单元测试的相关实践

3.1 Controller层不应向下传递协议类参数

我们先看两段代码:

标准HTTP的handler

1
2
3
4
5
package controller

func FooHandler(w http.ResponseWriter, req *http.Request) {
service.Foo(w, req)
}
1
2
3
4
5
package service

func Foo(w http.ResponseWriter, req *http.Request) {
fmt.Fprint(w, "my response")
}

Gin框架的Handler

1
2
3
4
5
package controller

func FooHandler(c *gin.Context) {
service.Foo(c)
}
1
2
3
4
5
package service

func Foo(c *gin.Context) {
c.JSON(200, resp)
}

这两种代码都将协议相关的数据结构http.ResponseWriterhttp.Requestgin.Context 传递到了业务领域层。从功能开发来说完全正确,但大幅提升了业务领域层Foo函数单测的难度。

再看protobuf方案,通过预定义的接口文档与代码生成技术,让controller层的定义变成了如下形式:

1
2
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种方式:

  1. 优先使用容器,可利用testing.Main的特性来创建和销毁(类似python中的setUptearDown
  2. 如果不得不依赖测试环境,尽可能地用defer的特性去清理单测产生的数据

长期维护这两个方案,都比较费时费力。

3.3 Go的单测有哪些好用的库或者工具?

  1. Mock类
    1. gomock 官方推荐的工具,可以从接口生成mock代码
    2. Go Monkey 可以对特定函数进行打桩,一般用于特定错误的模拟
  2. 接口相关
    1. wire 解决依赖注入的利器
    2. Goland的提取接口 从具体实现中,提取出接口定义,重构代码的利器
  3. 写单元测试
    1. testing.Main 统一进行单测依赖项的初始化与销毁的工作,减少重复性代码
    2. gotests 生成具体单元测试代码的框架,少写很多代码,已集成到VSCode/Goland
    3. testify 断言,可以减少单测的代码量,并增加可读性
  4. 其它 - 发掘自己写单测时的高度重复性的代码,利用go genereate特性自动生成

小结

本文讨论的业务代码是以对象为最小维度的。如果对象内部涉及到goroutinechannel 等特性,就需要在该对象的单测设计时有更多的考量,但不会影响整体项目的框架。

无论是框架分层、代码抽象,还是工具库的建设,单元测试都是高度依赖Go项目框架与规范的。良好的代码测试覆盖率是必须要框架适配的,生搬硬套往往让自己写单测写得很疲惫,也会让单元测试慢慢失去价值。

在Go项目中,要保证核心代码的高测试覆盖率,难度往往比需求开发高 - 往往过程性思维的CRUD,就能满足完成需求,而优秀的单元测试则为了保证测试的完备性,需要相当的抽象能力,并且持续重构。

道阻且长,行则将至。

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码