Golang 编写易于单元测试的代码

HackerPie
·

聊聊单测这个事

单元测试一直是大家老生长谈的话题之一,尽管各种测试方法论和测试工具集层层出不穷,但是实际上,在我所工作过的公司中,还没有见过能把单测坚持好的团队。单测的概念不复杂,单测的重要性大家也都是认同的,但是是什么造成单测没有执行下来呢?我觉得主要是两类原因吧:

  • 开发工期太赶:时间只够写功能性代码,测试代码只能舍弃,系统功能依赖不可重复的人力操作
  • 项目设计问题:项目代码结构设计不良,导致单测代码难以编写,或者运行需要过多复杂的依赖,加上项目已存在大量代码,不敢重构

第一个原因见仁见智,也不是我想聊的重点。我最近更多的实践和感悟是,如果一个项目从一开始就没有考虑好单测的需要,等到后期就几乎难以改造成易于单元测试执行的结构了。而另一方面,我也是最近才对单测这个事情有一种顿悟的感觉。所以,下面也是想通过一个小 demo 项目,来总结如何设计在 golang 里编写易于单测展开的代码。

项目设计问题导致的单测难以展开,一般都是因为代码组件之间形成了静态的依赖关系,比如对数据库的依赖,对外部服务的依赖,等等。这些依赖,可能是直接的,也可能是依赖的依赖,也就是间接的。而按照单测的定义,一个足够小的代码单元的测试,应该只关注这个单元的输入和输出即可,外加足以驱动单测执行的最小依赖集合,而不应该担心除此之外的其他一切东西。实际项目中,我们也会将代码进行分层设计,按照职责划分不同的代码模块,但是由于依赖管理的设计意识不足,常会发现模块之间形成了静态的依赖关系,导致编写单测时,不得不去关注各种间接的依赖,这就好比一个芯片在生产阶段就已经焊死在了主板之上,以至于如果我们需要对芯片的功能进行验证的话,就只能将整个主板制作完整之后,才能通过启动主板来检查芯片的功能,想想这有多离谱。

说明

出于演示目的,我编写了一个逻辑上不严谨的小示例项目,代码托管在 HackerPie/go-microblog。demo 实现了两个用于管理指定用户微博的 Restful API,按照后续讨论章节的内容,这份代码相应地通过多个 git tag 来识别对应的代码版本,分别为v1v2v3v4

概述

尽管只是一个小 demo,我还是希望提前说明下这个 demo 的分层设计。demo 核心逻辑存放在 internal 目录里,因为只是 demo,所以只划分了 servicerepo 以及 model 三层:

image.png

各层说明:

  • service: 该层代码负责请求的处理与响应,同时负责核心业务逻辑,一般真实项目里,我会进一步分开服务处理和核心业务逻辑层,但是作为示例项目,就简化了;
    • adapter: adapter 主要定义各类 dto 对象和数据库模型对象之间的转换适配,我认为这仍旧属于 service 层的逻辑,但是在实际代码中,我会独立一个目录来管理;
  • repo: 该层代码负责单一数据模型的持久化操作,即数据的 CURD;
  • model: 该层定义各类数据结构,按照使用场景不同,进一步划分 dtodb
    • dto: 数据传输对象,用于定义一些需要返回给客户端或者从客户端请求反序列化的数据结构;
    • db: 数据库模型定义,用于描述数据库表的结构,此层不负责任何数据读写操作。

各层代码在项目代码结构中的管理如图:

v1: 依赖具体实现的版本

v1 版本 代码中,是一个经典的代码分层之间直接依赖具体实现的例子:

// cmd/api_server.go
r := gin.Default()
r.GET("/users/:user_id/blogs", service.ListUserMBlogs)
r.POST("/users/:user_id/blogs", service.PublishNewBlog)

r.Run(":8000")

// internal/service/micro_blogs_service.go
func ListUserMBlogs(c *gin.Context) {
	// ...
	mblogs, err := repo.ListUserMBlogs(userID)
	// ...
}

func PublishNewBlog(c *gin.Context) {
	// ...
	if err = repo.NewUserMBlog(userID, req.Content); err != nil {
    // ...
}

// intrnal/repo/micro_blogs_repo.go
func ListUserMBlogs(userID int) ([]*dbModel.MicroBlog, error) {
    // ...
	err := db.Model(dbModel.MicroBlog{}).Where("user_id = ?", userID).Scan(&mblogs).Error
}

func NewUserMBlog(userID int, content string) error {
	// ...
	return db.Create(&mblog).Error
}

在这个版本的实现中,Web 接口 /users/:user_id/blogs 依赖了 service.ListUserMBlogs 的实现,而其又直接依赖了 repo.ListUserMBlogs 函数,而后者又依赖了 db,也就是 gorm.DB 对象指针,亦即数据库连接。假如我们需要为 service.ListUserMBlogs 编写单元测试,用于验证几类显而易见的测试场景:

  • 数据查询失败
  • 数据查询成功,但是没有匹配的数据集
  • 数据查询成功,并且有匹配的数据集

那么,基于这套设计和测试需求,我们需要实现:

  • 在测试环境初始化中建立好数据库连接
  • 模拟数据库连接失败等可能导致数据查询失败的场景,这会跟上面的数据库连接管理造成矛盾
  • 载入指定测试数据集,以满足不同的匹配结果的场景
  • 最后需要清理数据库数据,以防止干扰其他单元测试用例的结果

假如我们还希望这些单测用例可以执行于 CI 流程或者每日自动回归中,又会有新的问题:

  • CI 环境需要提供可用的 MySQL 数据库等;
  • CI 环境需要初始化过程中额外完成 DDL 操作,以准备好单测依赖的库表结构;

除了这些一下子想到的问题,还会有协作层面的问题:

  • 测试环境难以保持一致:如果使用本地数据库,则大家各自的数据库管理的不同会导致每个人在执行同一套测试用例时,需要针对性定制自己的环境信息等;如果使用共享的远程数据库,则容易因为并行的开发和测试导致相互干扰。

一趟捋下来,仅仅是一个简单函数的单元测试,在本来就已经很有限的场景下,就已经牵扯出这么多令人生畏的问题,我想,开发没有动力写单测,也是自然的事情了。

很自然的,针对这种设计风格的代码,我们急需一个解决方案,方便我们在单测中实现依赖的解耦!这就是依赖倒置原则的用武之地!

v2: 依赖倒置:依赖接口

在我另一篇博文《依赖倒置原则》中,我们知道依赖倒置可以帮助避免耦合依赖双方实现的代码结构问题。而按照依赖倒置原则,我们需要将依赖实现的代码,改为依赖接口定义的代码,具体到 golang 中,就是 interface,于是,应用了依赖倒置原则的新版本代码应运而生:

// cmd/api_server/main.go
func buildService() *service.MicroBlogsService {
	db := repo.NewDB()
	repoImpl := repo.NewMicroBlogRepoImpl(db)
	srv := service.NewMicroBlogsService(repoImpl)
	return srv
}

func main() {
	srv := buildService()

	r := gin.Default()
	r.GET("/users/:user_id/blogs", srv.ListUserMBlogs)
	r.POST("/users/:user_id/blogs", srv.PublishNewBlog)

	r.Run(":8000")
}

// internal/service/micro_blogs_service.go
type MicroBlogsService struct {
	repo repo.MicroBlogRepoIface  // 依赖了 repo.MicroBlogRepoIface 接口
}

func NewMicroBlogsService(repo repo.MicroBlogRepoIface) *MicroBlogsService {
	return &MicroBlogsService{repo: repo}
}

func (srv *MicroBlogsService) ListUserMBlogs(c *gin.Context) {
    // ....
	mblogs, err := srv.repo.ListUserMBlogs(userID)
	// ...
}

func (srv *MicroBlogsService) PublishNewBlog(c *gin.Context) {
	// ...
	if err = srv.repo.NewUserMBlog(userID, req.Content); err != nil {
    // ...
}

// internal/repo/interfaces.go
type MicroBlogRepoIface interface {  // <----- MicroBlogRepoIface 接口定义
	ListUserMBlogs(userID int) ([]*dbModel.MicroBlog, error)
	NewUserMBlog(userID int, content string) error
}

// internal/repo/micro_blogs_repo.go
type MicroBlogRepoImpl struct {
	db *gorm.DB
}

func NewMicroBlogRepoImpl(db *gorm.DB) *MicroBlogRepoImpl {
	return &MicroBlogRepoImpl{db: db}
}

func (impl *MicroBlogRepoImpl) ListUserMBlogs(userID int) ([]*dbModel.MicroBlog, error) {
    // ...
	err := impl.db.Model(dbModel.MicroBlog{}).Where("user_id = ?", userID).Scan(&mblogs).Error
    // ...
}

func (impl *MicroBlogRepoImpl) NewUserMBlog(userID int, content string) error {
    // ...
	return impl.db.Create(&mblog).Error
}

v2 版本 代码中,最主要的重构是提取了 repo.MicroBlogRepoIface 接口的定义,而 service 层逻辑不再直接依赖 repo 层的具体函数,而是依赖此接口。而为了整个程序能够正常初始化,则需要手工完成依赖的注入,具体体现在 cmd/api_server/main.gobuildService 函数中:

db := repo.NewDB()
repoImpl := repo.NewMicroBlogRepoImpl(db)
srv := service.NewMicroBlogsService(repoImpl)

buildService 函数首先通过工厂函数获取了 *gorm.DB 对象,将其注入 repo.NewMicroBlogRepoImpl 工厂函数,进而生产得到 repo.MicroBlogRepoImpl 对象,其实现了 repo.MicroBlogRepoIface 接口,因此可以作为 MicroBlogsService 的依赖,因此通过 service.NewMicroBlogsService 完成依赖注入,最终得到我们需要的 service 对象。

这种通过运行时完成依赖注入的方式,为单测提供了一个很关键的扩展入口:我们可以在单测初始化时为 service 注入 repo.MicroBlogRepoIface 接口的其他实现,这样就可以达到隔离真实数据库依赖的目的!

v3: 基于接口 mock 添加单测

通过 v2 版本的重构,项目代码已经为单测代码编写打下了很好的基础。显然,如果需要在不同测试用例下需要 repo.MicroBlogRepoIface 的实现能够不同行为或者返回值,我们最简单的方式就是可以在每个测试用例里手写一个新的类型,并且让其实现 repo.MicroBlogRepoIface 的每一个方法即可。但是这种方式比较低效,而且会带来维护的问题:一旦这个接口的定义变了,将会要求我们将单测代码中的每个实现都相应进行修改!有没有一种方式,可以实现接口的 mock 代码的自动生成呢?有的,gomock

gomock 是 golang 官方维护的用于为接口自动生成 mock 实现的工具,方便单测中复用 mock 代码完成调用断言、返回值定制等。

v3 版本代码中,我们借助 gomock 实现了 mock 代码的生成,并且应用到了单测代码中:

// internal/repo/interfaces.go

//go:generate mockgen -destination=./mocks/mock_repo.go -package=repomocks -source=interfaces.go

type MicroBlogRepoIface interface {
	ListUserMBlogs(userID int) ([]*dbModel.MicroBlog, error)
	NewUserMBlog(userID int, content string) error
}

// internal/repo/mocks/mock_repo.go
type MockMicroBlogRepoIface struct {
	ctrl     *gomock.Controller
	recorder *MockMicroBlogRepoIfaceMockRecorder
}

type MockMicroBlogRepoIfaceMockRecorder struct {
	mock *MockMicroBlogRepoIface
}

func NewMockMicroBlogRepoIface(ctrl *gomock.Controller) *MockMicroBlogRepoIface {
	// ...
}

func (m *MockMicroBlogRepoIface) EXPECT() *MockMicroBlogRepoIfaceMockRecorder {
	// ...
}

// ListUserMBlogs indicates an expected call of ListUserMBlogs.
func (mr *MockMicroBlogRepoIfaceMockRecorder) ListUserMBlogs(userID interface{}) *gomock.Call {
	// ...
}

func (mr *MockMicroBlogRepoIfaceMockRecorder) NewUserMBlog(userID, content interface{}) *gomock.Call {
	// ...
}

// internal/service/micro_blogs_service_test.go
func TestMicroBlogsService_ListUserMBlogs(t *testing.T) {
	type mockRepoReturn struct {
		list []*dbModel.MicroBlog
		err  error
	}

	tests := []struct {
		name             string
		expectMsg        string
		expectDataLength int
		mock             mockRepoReturn
	}{
        // ...
		{
			name: "list is empty",
			mock: mockRepoReturn{
				list: []*dbModel.MicroBlog{},
			},
			expectMsg: "success",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// ...

			ctrl := gomock.NewController(t)
			defer ctrl.Finish()

			mockRepo := repomocks.NewMockMicroBlogRepoIface(ctrl)
			mockRepo.EXPECT().
				ListUserMBlogs(gomock.Eq(1)).
				Return(tt.mock.list, tt.mock.err)

			srv := &MicroBlogsService{
				repo: mockRepo, // 这里将 mock 的实现注入了 MicroBlogsService 实例
			}
			srv.ListUserMBlogs(c)

            // ...
		})
	}
}

在这个版本里,首先在 internal/repo/interfaces.go 中新增了 go generate 指令:

//go:generate mockgen -destination=./mocks/mock_repo.go -package=repomocks -source=interfaces.go

该指令将会指引后续的 go generate 命令,将当前文件里的 interface 的 mock 实现保存到相对于当前文件的 mocks/mock_repo.go 文件中,使用的 go 包名为 repomocks

接着在命令行里执行 go generate ./... 后,便符合期待地自动生成了 internal/repo/mocks/mock_repo.go 文件,可以看到里面的类型 MockMicroBlogRepoIface 实现了 repo.MicroBlogRepoIface 接口。

而在最后的单测代码中,我们通过表格驱动测试的风格,定制了对应每一个测试用例下的 mock 实现的返回值:

mockRepo := repomocks.NewMockMicroBlogRepoIface(ctrl)
mockRepo.EXPECT().
    ListUserMBlogs(gomock.Eq(1)).
    Return(tt.mock.list, tt.mock.err)   // <------- 定制返回值

看到没有?这次我们的单测逻辑里,是不用在意数据库相关的东西的,对于 service 层的单测代码来说,它只需要关注它依赖的 repo.MicroBlogRepoIface 接口的直接行为即可,至于背后的实际实现,则是无需关心的内容了。因为隔离了对环境的间接依赖,我们有信心可以将这样的单测代码丢到各种执行环境中去运行,而无需担心环境改变导致单测可能执行失败的繁琐问题。

v4: 使用 google/wire 实现依赖注入

在 v2 版本代码中,我们的 buildService 函数用于实现依赖注入,但是在实际的项目中,我们的依赖会复杂得多,如果依靠人工编写这种依赖注入代码,会非常繁琐枯燥,而 google/wire 则是可以用来帮我们提升幸福感的工具。

wire 是一个 google 公司开发维护的用于实现编译时依赖注入的工具,其工作的方式也是代码的自动生成。wire 有两个核心概念:injector 和 provider,provider 可以理解各种可以生成依赖组件实例的工厂函数,而 injector 则是用于定义最终依赖产物的函数,通过 injector 的返回值定义以及项目中提供的一系列 provider,wire 能够自动识别出应用组件之间的依赖关系,并且自动生成依赖注入的完整代码。下面看 v4 版本的相关代码:

// cmd/api_server/main.go
func main() {
	srv := buildService()

	r := gin.Default()
	r.GET("/users/:user_id/blogs", srv.ListUserMBlogs)
	r.POST("/users/:user_id/blogs", srv.PublishNewBlog)

	r.Run(":8000")
}

// cmd/api_server/wire.go
func buildService() *service.MicroBlogsService {
	wire.Build(service.NewMicroBlogsService,
		repo.WireSet,
		repo.NewDB)

	return &service.MicroBlogsService{}
}

// cmd/api_server/wire_gen.go
func buildService() *service.MicroBlogsService {
	db := repo.NewDB()
	microBlogRepoImpl := repo.NewMicroBlogRepoImpl(db)
	microBlogsService := service.NewMicroBlogsService(microBlogRepoImpl)
	return microBlogsService
}

// internal/repo/wire_set.go
var WireSet = wire.NewSet(
	NewMicroBlogRepoImpl,
	wire.Bind(new(MicroBlogRepoIface), new(*MicroBlogRepoImpl)),
)

// internal/repo/conn.go
func NewDB() *gorm.DB {
	db, err := gorm.Open(mysql.Open("[email protected](127.0.0.1:3306)/micro_blog?charset=utf8mb4&parseTime=True&loc=Local"))
	if err != nil {
		panic(err)
	}
	return db
}

在这个版本中,我们将原来手写的 buildService 函数从 main.go 文件中清除了,取而代之的,在新的 wire.go 文件中,我们定义了一个 wire injector buildService

func buildService() *service.MicroBlogsService {
	wire.Build(service.NewMicroBlogsService,
		repo.WireSet,
		repo.NewDB)

	return &service.MicroBlogsService{}
}

与之前手写代码不同的是,这里通过 wire.Build 指明了用于实现完整依赖注入所需的所有 provider,所以这里的 service.NewMicroBlogsServicerepo.NewDB 都是 provider,而 repo.WireSet 则是一个 provider set:

var WireSet = wire.NewSet(
	NewMicroBlogRepoImpl,
	wire.Bind(new(MicroBlogRepoIface), new(*MicroBlogRepoImpl)),
)

wire.NewSet 用于定义一组 provider 的集合,好处是方便打包使用,这样就不用在 injector 中重复罗列这些 provider。而 wire.Bind 则是用于提示 wire:MicroBlogRepoImpl 类型实现了 MicroBlogRepoIface 接口,应该在依赖注入过程中将 MicroBlogRepoImpl 注入给所有依赖 MicroBlogRepoIface 接口的组件。

通过 wire,减轻了我们的依赖注入的负担,让这种应用架构变得更称手。

其他组件 mock 的思路

由于是示例项目,上面的内容最核心的内容,还是在于通过依赖倒置的原则,将应用内分层之间的耦合分离,让单元测试有施展的空间。但是,实际项目由于复杂度等,除了分层接口的 mock,还可能会遇到的情况是对相同组件内部的其他方法或者函数的依赖,这种情况下,基于接口的依赖倒置没有发挥的空间。作为解决方案,我会慎重引入 bouk/monkey 以猴子补丁的形式在单测中临时替换被依赖函数或者方法的实现。

数据库依赖的问题

前面在介绍重构和单测的过程中,其实没有讲到 repo 层自身的单测的问题。而如果考虑 repo 层的单测的话,就需要解决对 *gorm.DB 的依赖的问题,因为 gorm.DB 不是一个接口定义,所以不能通过 mock 代码生成的方式来解决。要解决这个问题,有两种思路:

  • 使用 sqlite 这种本地文件型数据库
  • 使用 go-sqlmock 这类用于 mock 数据连接层的工具库

由于 sqlite 本质上还是物理数据库,而且有造数据和清理数据的负担,我不大会作为首选的工具。而如果使用 sqlmock,则可以很轻松地将 gorm 依赖的数据库连接进行替换,进而实现 mock 数据库层的目的:

db, mock, err := sqlmock.New()
// ...
gormDB := gorm.Open(db) // <---- 注入 sqlmock,作为 gorm.DB 的依赖
repo := NewMicroBlogRepoImpl(gormDB)

远程调用依赖的问题

工程实践中,另一类常见的依赖,就是远程调用的依赖,既包含 HTTP 协议服务的依赖,也可能是其他 rpc 服务的依赖。如果是 HTTP 类服务的依赖,可以借助 httpmock 实现 mock。而对于 rpc 类服务,则最好期待相关 rpc 框架在生成协议桩代码的时候,能够顺便提供相关的接口定义,还是同样的原则:依赖接口,不依赖具体实现!

其他可能影响代码可测试性的因素

上面的思考,更多的是思考如何实现单测最小化依赖的问题,避免依赖问题成为单测执行的阻碍以及不稳定因素。而如果放开点思考,还有一些其他因素同样会降低代码的可测试性:

  • 全局变量:全局变量破坏了单测用例的相互独立性;
  • 函数或方法的复杂度:因为单测的对象是一个足够小的逻辑单元,如果一个函数或者方法包含了太多的逻辑,也会同时很大程度加大单测的复杂度,如果涉及到多个接口的 mock,还需要考虑多种 mock 组合的设计,我们尽可能简化单个函数或者方法的逻辑,让乘法(mock组合)变成简单的加法。

其他思考

值得记住的是,单测并不是银弹,哪怕单测测试覆盖率已经达到 100%,也不能仅凭单测结果证明系统是完全符合预期的。因为单测中对环境的隔离,以及单测未能覆盖组件之间组装起来之后运行的场景,这些问题都只能交给集成测试环节来保障。但是话说回来,在很多人都不写或者写不好单测的情况下,能够坚持写好单测的话,就已经可以跑赢很多人了。

与 mock 的方式相对的,有些场景下,我们仍然希望基于真实的数据库环境运行自动化测试,但是为了测试用例可以重复执行而保持稳定的结果,需要考虑如何实现测试数据的装载和清理问题。参照 Rails 中的 test fixtures,我也尝试过编写了 golang 版本的 gofixtures,其原理是实现 sql.Driver 接口,并且在测试用例启动时开启全局事务,在完成测试用例执行之后,再回滚这个全局事务,而达到数据回滚的目的。

最后一点思考是,如果想要写好单测,就应该跟对待其他功能性代码一样看待单测,将单测的支持一并考虑到项目代码的设计中去,也就是写代码除了追求常见的易读性、可维护性、可扩展性,还得追求测试友好性。

参考资料

评论
社区准则 博客 联系 反馈 状态
主题