面向接口编程是一个老生常谈的话题,接口 的做用其实就是为不一样层级的模块提供了一个定义好的中间层,上游再也不须要依赖下游的具体实现,充分地对上下游进行了解耦。git
golang-interfacegithub
这种编程方式不只是在 Go 语言中是被推荐的,在几乎全部的编程语言中,咱们都会推荐这种编程的方式,它为咱们的程序提供了很是强的灵活性,想要构建一个稳定、健壮的 Go 语言项目,不使用接口是彻底没法作到的。golang
若是一个略有规模的项目中没有出现任何 type ... interface 的定义,那么做者能够推测出这在很大的几率上是一个工程质量堪忧而且没有多少单元测试覆盖的项目,咱们确实须要认真考虑一下如何使用接口对项目进行重构。数据库
单元测试是一个项目保证工程质量最有效而且投资回报率最高的方法之一,做为静态语言的 Go,想要写出覆盖率足够(最少覆盖核心逻辑)的单元测试自己就比较困难,由于咱们不能像动态语言同样随意修改函数和方法的行为,而接口就成了咱们的救命稻草,写出抽象良好的接口并经过接口隔离依赖可以帮助咱们有效地提高项目的质量和可测试性,咱们会在下一节中详细介绍如何写单元测试。编程
package post var client *grpc.ClientConn func init() { var err error client, err = grpc.Dial(...) if err != nil { panic(err) } } func ListPosts() ([]*Post, error) { posts, err := client.ListPosts(...) if err != nil { return []*Post{}, err } return posts, nil }
上述代码其实就不是一个设计良好的代码,它不只在 init 函数中隐式地初始化了 grpc 链接这种全局变量,并且没有将 ListPosts 经过接口的方式暴露出去,这会让依赖 ListPosts 的上层模块难以测试。数组
咱们可使用下面的代码改写原有的逻辑,使得一样地逻辑变得更容易测试和维护:缓存
package post type Service interface { ListPosts() ([]*Post, error) } type service struct { conn *grpc.ClientConn } func NewService(conn *grpc.ClientConn) Service { return &service{ conn: conn, } } func (s *service) ListPosts() ([]*Post, error) { posts, err := s.conn.ListPosts(...) if err != nil { return []*Post{}, err } return posts, nil }
经过接口 Service 暴露对外的 ListPosts 方法;
使用 NewService 函数初始化 Service 接口的实现并经过私有的结构体 service 持有 grpc 链接;
ListPosts 再也不依赖全局变量,而是依赖接口体 service 持有的链接;
当咱们使用这种方式重构代码以后,就能够在 main 函数中显式的初始化 grpc 链接、建立 Service 接口的实现并调用 ListPosts 方法:bash
package main import ... func main() { conn, err = grpc.Dial(...) if err != nil { panic(err) } svc := post.NewService(conn) posts, err := svc.ListPosts() if err != nil { panic(err) } fmt.Println(posts) }
这种使用接口组织代码的方式在 Go 语言中很是常见,咱们应该在代码中尽量地使用这种思想和模式对外提供功能:框架
使用大写的 Service 对外暴露方法;
使用小写的 service 实现接口中定义的方法;
经过 NewService 函数初始化 Service 接口;
当咱们使用上述方法组织代码以后,其实就对不一样模块的依赖进行了解耦,也正遵循了软件设计中常常被提到的一句话 — 『依赖接口,不要依赖实现』,也就是面向接口编程。编程语言
项目中的单元测试应该是稳定的而且不依赖任何的外部项目,它只是对项目中函数和方法的测试,因此咱们须要在单元测试中对全部的第三方的不稳定依赖进行 Mock,也就是模拟这些第三方服务的接口;除此以外,为了简化一次单元测试的上下文,在同一个项目中咱们也会对其余模块进行 Mock,模拟这些依赖模块的返回值。
单元测试的核心就是隔离依赖并验证输入和输出的正确性,Go 语言做为一个静态语言提供了比较少的运行时特性,这也让咱们在 Go 语言中 Mock 依赖变得很是困难。
Mock 的主要做用就是保证待测试方法依赖的上下文固定,在这时不管咱们对当前方法运行多少次单元测试,若是业务逻辑不改变,它都应该返回彻底相同的结果,在具体介绍 Mock 的不一样方法以前,咱们首先要清楚一些常见的依赖,一个函数或者方法的常见依赖能够有如下几种:
Go 语言中最多见也是最通用的 Mock 方法,也就是可以对接口进行 Mock 的golang/mock框架,它可以根据接口生成 Mock 实现,假设咱们有如下代码:
package blog type Post struct {} type Blog interface { ListPosts() []Post } type jekyll struct {} func (b *jekyll) ListPosts() []Post { return []Post{} } type wordpress struct{} func (b *wordpress) ListPosts() []Post { return []Post{} }
咱们的博客可能使用jekyll
或者wordpress
做为引擎,可是它们都会提供ListsPosts
方法用于返回所有的文章列表,在这时咱们就须要定义一个Post
接口,接口要求遵循Blog
的结构体必须实现ListPosts
方法。
当咱们定义好了Blog
接口以后,上层Service
就再也不须要依赖某个具体的博客引擎实现了,只须要依赖Blog
接口就能够完成对文章的批量获取功能:
package service type Service interface { ListPosts() ([]Post, error) } type service struct { blog blog.Blog } func NewService(b blog.Blog) *Service { return &service{ blog: b, } } func (s *service) ListPosts() ([]Post, error) { return s.blog.ListPosts(), nil }
若是咱们想要对Service
进行测试,咱们就可使用 gomock 提供的mockgen
工具命令生成MockBlog
结构体,使用以下所示的命令:
$ mockgen -package=mblog -source=pkg/blog/blog.go > test/mocks/blog/blog.go $ cat test/mocks/blog/blog.go // Code generated by MockGen. DO NOT EDIT. // Source: blog.go // Package mblog is a generated GoMock package. ... // NewMockBlog creates a new mock instance func NewMockBlog(ctrl *gomock.Controller) *MockBlog { mock := &MockBlog{ctrl: ctrl} mock.recorder = &MockBlogMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use func (m *MockBlog) EXPECT() *MockBlogMockRecorder { return m.recorder } // ListPosts mocks base method func (m *MockBlog) ListPosts() []Post { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListPosts") ret0, _ := ret[0].([]Post) return ret0 } // ListPosts indicates an expected call of ListPosts func (mr *MockBlogMockRecorder) ListPosts() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPosts", reflect.TypeOf((*MockBlog)(nil).ListPosts)) }
这段mockgen
生成的代码很是长的,因此咱们只展现了其中的一部分,它的功能就是帮助咱们验证任意接口的输入参数而且模拟接口的返回值;而在生成 Mock 实现的过程当中,做者总结了一些能够分享的经验:
test/mocks
目录中放置全部的 Mock 实现,子目录与接口所在文件的二级目录相同,在这里源文件的位置在pkg/blog/blog.go
,它的二级目录就是blog/
,因此对应的 Mock 实现会被生成到test/mocks/blog/
目录中;package
为mxxx
,默认的mock_xxx
看起来很是冗余,上述blog
包对应的 Mock 包也就是mblog
;mockgen
命令放置到Makefile
中的mock
下统一管理,减小祖传命令的出现;
mock: rm -rf test/mocks mkdir -p test/mocks/blog mockgen -package=mblog -source=pkg/blog/blog.go > test/mocks/blog/blog.go
当咱们生成了上述的 Mock 实现代码以后,就可使用以下的方式为Service
写单元测试了,这段代码经过NewMockBlog
生成一个Blog
接口的 Mock 实现,而后经过EXPECT
方法控制该实现会在调用ListPosts
时返回空的Post
数组:
func TestListPosts(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockBlog := mblog.NewMockBlog(ctrl) mockBlog.EXPECT().ListPosts().Return([]Post{}) service := NewService(mockBlog) assert.Equal(t, []Post{}, service.ListPosts()) }
因为当前Service
只依赖于Blog
的实现,因此在这时咱们就可以断言当前方法必定会返回[]Post{}
,这时咱们的方法的返回值就只与传入的参数有关(虽然ListPosts
方法没有入参),咱们可以减小一次关注的上下文并保证测试的稳定和可信。
这是 Go 语言中最标准的单元测试写法,全部依赖的package
不管是项目内外都应该使用这种方式处理(在有接口的状况下),若是没有接口 Go 语言的单元测试就会很是难写,这也是为何从项目中是否有接口就能判断工程质量的缘由了。
最后要介绍的猴子补丁其实就是一个大杀器了,bouk/monkey可以经过替换函数指针的方式修改任意函数的实现,因此若是上述的几种方法都不能知足咱们的需求,咱们就只可以经过猴子补丁这种比较 hack 的方法 Mock 依赖了:
func main() { monkey.Patch(fmt.Println, func(a ...interface{}) (n int, err error) { s := make([]interface{}, len(a)) for i, v := range a { s[i] = strings.Replace(fmt.Sprint(v), "hell", "*bleep*", -1) } return fmt.Fprintln(os.Stdout, s...) }) fmt.Println("what the hell?") // what the *bleep*? }
然而这种方法的使用其实有一些限制,因为它是在运行时替换了函数的指针,因此若是遇到一些简单的函数,例如rand.Int63n
和time.Now
,编译器可能会直接将这种函数内联到调用实际发生的代码处并不会调用原有的方法,因此使用这种方式每每须要咱们在测试时额外指定-gcflags=-l
禁止编译器的内联优化。
$ go test -gcflags=-l ./...
bouk/monkey的 README 对于它的使用给出了一些注意事项,除了内联编译以外,咱们须要注意的是不要在单元测试以外的地方使用猴子补丁,咱们应该只在必要的时候使用这种方法,例如依赖的第三方库没有提供interface
或者修改time.Now
以及rand.Int63n
等内置函数的返回值用于测试时。
从理论上来讲,经过猴子补丁这种方式咱们可以在运行时 Mock Go 语言中的一切函数,这也为咱们提供了单元测试 Mock 依赖的最终解决方案。
参考文章:
https://draveness.me/golang-101