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

06/Mar/2022 · 5 minute read

聊聊单测这个事

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

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

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

说明

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

概述

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

demo 应用分层

各层说明:

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

internal 代码结构组织

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 流程或者每日自动回归中,又会有新的问题:

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

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

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

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("root@tcp(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 本质上还是物理数据库,而且有造数据和清理数据的负担,我不大会作为首选的工具。而如果使用 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 框架在生成协议桩代码的时候,能够顺便提供相关的接口定义,还是同样的原则:依赖接口,不依赖具体实现!

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

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

其他思考

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

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

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

参考资料