如何写出优雅的Golang代码(五)

 go教练   2019-07-30 16:55   176 人阅读  0 条评论

一个代码质量和工程质量有保证的项目一定有比较合理的单元测试覆盖率,没有单元测试的项目一定是不合格的或者不重要的,单元测试应该是所有项目都必须有的代码,每一个单元测试都表示一个可能发生的情况,单元测试就是业务逻辑下面我们一起来看看如何写出优雅的Golang代码的第五部分

 微信截图_20190529100227.png

作为软件工程师,重构现有的项目对于我们来说应该是一件比较正常的事情,如果项目中没有单元测试,我们很难在不改变已有业务逻辑的情况对项目进行重构,一些业务的边界情况很可能会在重构的过程中丢失,当时参与相应case开发的工程师可能已经不在团队中,而项目相关的文档可能也消失在了归档的wiki中(更多的项目可能完全没有文档),我们能够在重构中相信的东西其实只有当前的代码逻辑(很可能是错误的)以及单元测试(很可能是没有的)。

简单总结一下,单元测试的缺失不仅会意味着较低的工程质量,而且意味着重构的难以进行,一个有单元测试的项目尚且不能够保证重构前后的逻辑完全相同,一个没有单元测试的项目很可能本身的项目质量就堪忧,更不用说如何在不丢失业务逻辑的情况下进行重构了。

写代码并不是一件多困难的事情,不过想要在项目中写出可以测试的代码并不容易,而优雅的代码一定是可以测试的,我们在这一节中需要讨论的就是什么样的代码是可以测试的。

如果想要想清楚什么样的才是可测试的,我们首先要知道测试是什么?作者对于测试的理解就是控制变量,在我们隔离了待测试方法中一些依赖之后,当函数的入参确定时,就应该得到期望的返回值。

 微信截图_20190729100029.png

如何控制待测试方法中依赖的模块是写单元测试时至关重要的,控制依赖也就是对目标函数的依赖进行Mock消灭不确定性,为了减少每一个单元测试的复杂度,我们需要:

Mock

单元测试的执行不应该依赖于任何的外部模块,无论是调用外部的 HTTP 请求还是数据库中的数据,我们都应该想尽办法模拟可能出现的情况,因为单元测试不是集成测试的,它的运行不应该依赖除项目代码外的其他任何系统。

Go 语言中如果我们完全不使用接口,是写不出易于测试的代码的,作为静态语言的 Golang,只有我们使用接口才能脱离依赖具体实现的窘境,接口的使用能够为我们带来更清晰的抽象,帮助我们思考如何对代码进行设计,也能让我们更方便地对依赖进行Mock。

我们再来回顾一下上一节对接口进行介绍时展示的常见模式:

type Service interface { ... } type service struct { ... } func NewService(...) (Service, error) { return &service{...}, nil }

上述代码在 Go 语言中是非常常见的,如果你不知道应不应该使用接口对外提供服务,这时就应该无脑地使用上述模式对外暴露方法了,这种模式可以在绝大多数的场景下工作,至少作者到目前还没有见到过不适用的。

函数简单

另一个建议就是保证每一个函数尽可能简单,这里的简单不止是指功能上的简单、单一,还意味着函数容易理解并且命名能够自解释。

一些语言的lint工具其实会对函数的理解复杂度(PerceivedComplexity)进行检查,也就是检查函数中出现的if/else、switch/case分支以及方法的调用的数量,一旦超过约定的阈值就会报错,Ruby 社区中的 Rubocop 和上面提到的 golangci-lint 都有这个功能。

Ruby 社区中的 Rubocop 对于函数的长度和理解复杂度都有着非常严格的限制,在默认情况下函数的行数不能超过10行,理解复杂度也不能超过7,除此之外,Rubocop 其实还有其他的复杂度限制,例如循环复杂度(CyclomaticComplexity),这些复杂度的限制都是为了保证函数的简单和容易理解。

组织方式

如何对测试进行组织也是一个值得讨论的话题,Golang 中的单元测试文件和代码都是与源代码放在同一个目录下按照package进行组织的,server.go文件对应的测试代码应该放在同一目录下的server_test.go文件中。

如果文件不是以_test.go结尾,当我们运行go test ./pkg时就不会找到该文件中的测试用例,其中的代码也就不会被执行,这也是 Go 语言对于测试组织方法的一个约定。

单元测试的最常见以及默认组织方式就是写在以_test.go结尾的文件中,所有的测试方法也都是以Test开头并且只接受一个testing.T类型的参数:

func TestAuthor(t *testing.T) { author := blog.Author() assert.Equal(t, "draveness", author) }

如果我们要给函数名为Add的方法写单元测试,那么对应的测试方法一般会被写成TestAdd,为了同时测试多个分支的内容,我们可以通过以下的方式组织Add函数相关的测试:

func TestAdd(t *testing.T) { assert.Equal(t, 5, Add(2, 3)) } func TestAddWithNegativeNumber(t *testing.T) { assert.Equal(t, -2, Add(-1, -1)) }

除了这种将一个函数相关的测试分散到多个Test方法之外,我们可以使用for循环来减少重复的测试代码,这在逻辑比较复杂的测试中会非常好用,能够减少大量的重复代码,不过也需要我们小心地进行设计:

func TestAdd(t *testing.T) { tests := []struct{ name string first int64 second int64 expected int64 } { { name: "HappyPath": first: 2, second: 3, expected: 5, }, { name: "NegativeNumber": first: -1, second: -1, expected: -2, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { assert.Equal(t, test.expected, Add(test.first, test.second)) }) } }

这种方式其实也能生成树形的测试结果,将Add相关的测试分成一组方便我们进行观察和理解,不过这种测试组织方法需要我们保证测试代码的通用性,当函数依赖的上下文较多时往往需要我们写很多的if/else条件判断语句影响我们对测试的快速理解。

作者通常会在测试代码比较简单时使用第一种组织方式,而在依赖较多、函数功能较为复杂时使用第二种方式,不过这也不是定论,我们需要根据实际情况决定如何对测试进行设计。

第二种比较常见的方式是按照簇进行组织,其实就是对 Go 语言默认的测试方式进行简单的封装,我们可以使用stretchr/testify中的suite包对测试进行组织:

import ( "testing" "github.com/stretchr/testify/suite" ) type ExampleTestSuite struct { suite.Suite VariableThatShouldStartAtFive int } func (suite *ExampleTestSuite) SetupTest() { suite.VariableThatShouldStartAtFive = 5 } func (suite *ExampleTestSuite) TestExample() { suite.Equal(suite.VariableThatShouldStartAtFive, 5) } func TestExampleTestSuite(t *testing.T) { suite.Run(t, new(ExampleTestSuite)) }

我们可以使用suite包,以结构体的方式对测试簇进行组织,suite提供的SetupTest/SetupSuite和TearDownTest/TearDownSuite是执行测试前后以及执行测试簇前后的钩子方法,我们能在其中完成一些共享资源的初始化,减少测试中的初始化代码。

最后一种组织代码的方式就是使用 BDD 的风格对单元测试进行组织,ginkgo就是 Golang 社区最常见的 BDD 框架了,这里提到的行为驱动开发(BDD)和测试驱动开发(TDD)都是一种保证工程质量的方法论。想要在项目中实践这种思想还是需要一些思维上的转变和适应,也就是先通过写单元测试或者行为测试约定方法的 Spec,再实现方法让我们的测试通过,这是一种比较科学的方法,它能为我们带来比较强的信心。

我们虽然不一定要使用 BDD/TDD 的思想对项目进行开发,但是却可以使用 BDD 的风格方式组织非常易读的测试代码:

var _ = Describe("Book", func() { var ( book Book err error ) BeforeEach(func() { book, err = NewBookFromJSON(`{ "title":"Les Miserables", "author":"Victor Hugo", "pages":1488 }`) }) Describe("loading from JSON", func() { Context("when the JSON fails to parse", func() { BeforeEach(func() { book, err = NewBookFromJSON(`{ "title":"Les Miserables", "author":"Victor Hugo", "pages":1488oops }`) }) It("should return the zero-value for the book", func() { Expect(book).To(BeZero()) }) It("should error", func() { Expect(err).To(HaveOccurred()) }) }) }) })

BDD 框架中一般都包含Describe、Context以及It等代码块,其中Describe的作用是描述代码的独立行为、Context是在一个独立行为中的多个不同上下文,最后的It用于描述期望的行为,这些代码块最终都构成了类似『描述……,当……时,它应该……』的句式帮助我们快速地理解测试代码。

微信截图_20190529100205.png

Mock方法

项目中的单元测试应该是稳定的并且不依赖任何的外部项目,它只是对项目中函数和方法的测试,所以我们需要在单元测试中对所有的第三方的不稳定依赖进行 Mock,也就是模拟这些第三方服务的接口;除此之外,为了简化一次单元测试的上下文,在同一个项目中我们也会对其他模块进行 Mock,模拟这些依赖模块的返回值。

单元测试的核心就是隔离依赖并验证输入和输出的正确性,Go 语言作为一个静态语言提供了比较少的运行时特性,这也让我们在Go语言中Mock依赖变得非常困难。

Mock的主要作用就是保证待测试方法依赖的上下文固定,在这时无论我们对当前方法运行多少次单元测试,如果业务逻辑不改变,它都应该返回完全相同的结果,在具体介绍 Mock 的不同方法之前,我们首先要清楚一些常见的依赖,一个函数或者方法的常见依赖可以有以下几种:

接口 数据库HTTP 请求Redis、缓存以及其他依赖

这些不同的场景基本涵盖了写单元测试时会遇到的情况,我们会在接下来的内容中分别介绍如何处理以上几种不同的依赖。

首先要介绍的其实就是 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{} }

以上就是今天给大家介绍的如何写出优雅的Golang代码如果你还想了解更多关于golang的知识技巧,可以持续关注我们http://www.fastgolang.com

本文地址:http://fastgolang.com/117.html
版权声明:本文为原创文章,版权归 go教练 所有,欢迎分享本文,转载请保留出处!

 发表评论


表情

还没有留言,还不快点抢沙发?