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.ResponseWriter 、http.Request 、 gin.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中的setUp和tearDown)
  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特性自动生成

小结

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

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

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

道阻且长,行则将至。

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

Blog: http://junes.tech/

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

公众号: golangcoding

二维码