|
Why单元测试让我们对重构与修改有信心新功能的增加,代码复杂性的提高,优化代码的需要,或新技术的出现都会导致重构代码的需求。在没有写单元测试的情况下,对代码进行大规模修改,是一件不敢想象的事情,因为写错的概率实在太大了。而如果原代码有单元测试,即使修改了代码单测依然通过,说明没有破坏程序正确性,一点都不慌!及早发现问题,降低定位问题的成本bug发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。作为编码人员,也是单元测试的主要执行者,能在单测阶段发现的问题,就不用等到联调测试再暴露出来,减少解决成本。代码设计的提升为了实现功能而编码的时候,大多时候我们考虑的是函数实现,一顿编写,写好了运行成功就万事大吉了。而写单元测试的时候,我们跳出了函数,从输入输出的角度去思考函数/结构体的功能。此时我们不由得,这个函数真的需要吗?这个函数的功能是不是可以简化/抽象/拆分一下?这个函数考虑的情况似乎不够全面吧?这里的使用外部依赖是否真的合适?这些思考,能推动我们更仔细思考代码的设计,加深对代码功能的理解,从而形成更合理的设计和结构。单元测试也是一种编写文档的行为单元测试是产品代码的第⼀个使⽤者,并伴随代码⽣命周期的始终。它⽐任何⽂字⽂档更直观、更准确、更有效,⽽且永不过时。当产品代码更新时单元测试就会同步更新(否则通不过测试);而⽂字⽂档则更新往往滞后,甚⾄不更新,从⽽对后来的开发者和维护者产⽣误导,正所谓:过时的⽂档⽐没有⽂档更有害。单元测试的时机编码前:TDDTest-DrivenDevelopment,测试驱动开发,是敏捷开发的⼀项核⼼实践和技术,也是⼀种设计⽅法论。TDD原理是开发功能代码之前,先编写测试⽤例代码,然后针对测试⽤例编写功能代码,使其能够通过。其好处在于通过测试的执⾏代码,肯定满⾜需求,⽽且有助于接⼝编程,降低代码耦合,也极⼤降低bug出现⼏率。然而TDD的坏处也显而易见:由于测试⽤例在未进⾏代码设计前写;很有可能限制开发者对代码整体设计,并且由于TDD对开发⼈员要求⾮常⾼,跟传统开发思维不⼀样,因此实施起来比较困难,在客观情况不满足的情况下,不应该盲目追求对业务代码使用TDD的开发模式。编码后:存量在完成业务需求后,可能由于上线时间较为紧、没有单测相关规划的历史缘故,当时只手动测试是否符合功能。而这部分存量代码出现较大的新需求或者维护已经成为问题,需要大规模重构时,是推动补全单测的好时机。因为为存量代码补充上单测一方面能够推进重构者进一步理解原先逻辑,另一方面能够增强重构后的信心,降低风险。但补充存量单测可能需要再次回忆理解需求和逻辑设计等细节,甚至写单测者并不是原编码设计者。与编码同步进行:增量及时为增量代码写上单测是一种良好的习惯。因为此时有对需求有一定的理解,能够更好地写出单元测试来验证正确性。并且能在单测阶段发现问题,修复的成本也是最小的,不必等到联调测试中发现。另一方面在写单测的过程中也能够反思业务代码的正确性、合理性,能推动我们在实现的过程中更好地反思代码的设计并及时调整。Golang单测框架选型&示例主要介绍golang原生testing框架、testify框架、goconvey框架,看一下哪种框架是结合业务体验更好的。golang原生testing框架特点文件形式:文件以_test.go结尾函数形式:funcTestXxx(*testing.T)断言:使用t.Errorf或相关方法来发出失败信号运行:使用gotest–v执行单元测试示例// 原函数 (in add.go)func Add(a,b int) int { return a + b}// 测试函数 (in add_test.go)func TestAdd(t *testing.T) { var ( a = 1 b = 1 expected = 2 ) var got = Add(a, b) if got != expected { t.Errorf("Add(%d, %d) = %d, expected %d", a, b, got, expected) }}扩展:Table-Driven的测试模式Table-Driven是很多Go语言开发者所推崇的测试代码编写方式,Go语言标准库的测试也是通过这个结构来撰写的。简单来说其实就是将多个测试用例封装到数组中,依次执行相同的测试逻辑。值得一提的是该设计思想并不是golang自带testing框架特有,即使是用其他测试框架,也可以应用此种写法。一般来说大概长这个样子:func TestAdd(t *testing.T) { var addTests = []struct { a int b int expected int // expected result }{ {1, -1, 0}, {3, 2, 5}, {7, 3, 10}, } for _, tt := range addTests { got := Add(tt.a, tt.b) if got != tt.expected { t.Errorf("Add(%d, %d) = %d, expected %d", tt.a, tt.b, got, tt.expected) } }}其中可见我们通过匿名结构体构建了每一个测试用例的结构,一个输入in和一个我们期望的输出out,然后在真实的测试函数中,通过range轮询每一个测试用例,并且调用测试函数,比较输出结果,如果输出结果不等于我们期望的结果,即报错。这种测试框架最好的一点在于,结构清晰,并且添加新的测试case会非常方便。而另一方面,缺点在于测试用例之间的层级关系不明显都是平铺关系,并且各个测试用例的断言方式相对单一,mock、stub的相对不灵活。Testify简介Testify基于gotesting编写,所以语法上、执行命令行与gotest完全兼容,只是其是比较清晰的断言定义。它提供assert和require两种用法,分别对应失败后的执行策略,前者失败后继续执行,后者失败后立刻停止。但是它们都是单次断言失败,当前testcase就失败。示例import ( "testing" "github.com/stretchr/testify/assert")...// 直观使用assert断言能力func TestFind(t *testing.T) { service := ... firstName, lastName := service.find(someParams) assert.Equal(t, "John", firstName) assert.Equal(t, "Dow", lastName)}// Table-Driven的的模式使用assertfunc TestCalculate(t *testing.T) { assert := assert.New(t) var tests = []struct { input int expected int }{ {2, 4}, {-1, 1}, {10, 2}, {-5, -3}, {99999, 100001}, } for _, test := range tests { assert.Equal(Calculate(test.input), test.expected) }}其他特性testify工具还提供了mock功能,不过在实际过程中,不太建议使用该功能,因为相较其他成熟mock框架testify的mock使用起来较为不便GoconveyGoConvey是一款针对Golang的测试框架,可以管理和运行测试用例,同时提供了丰富的断言函数,并支持很多Web界面特性。直观示范&解释// 被测原函数func StringSliceEqual(a, b []string) bool { if len(a) != len(b) { return false } if (a == nil) != (b == nil) { return false } for i, v := range a { if v != b[i] { return false } } return true}// 测试代码import ( "testing" . "github.com/smartystreets/goconvey/convey")func TestStringSliceEqual(t *testing.T) { Convey("TestStringSliceEqual should return true when a != nil & b != nil", t, func() { a := []string{"hello", "goconvey"} b := []string{"hello", "goconvey"} So(StringSliceEqual(a, b), ShouldBeTrue) })}例子对刚接触Convey的看来可能有点抽象,这里展开讲解一下:每个测试用例必须使用Convey函数包裹起来,可以理解为一个Convey就是一个测试用例(嵌套情况下则为一组)Convey的三个参数分别为:第一个参数为string类型的测试描述第二个参数为测试函数的入参(类型为*testing.T)第三个参数为不接收任何参数也不返回任何值的函数(习惯使用闭包),并且在第三个参数闭包的实现中通过So函数完成断言判断而对于断言So参数的理解,总共有三个参数:actual:输入assert:断言expected:期望值关于assert,Convey包已经帮我们定义了大部分的基础断言了:// source code: github.com\smartystreets\goconvey@v1.6.4\convey\assertions.govar ( ShouldEqual = assertions.ShouldEqual ShouldNotEqual = assertions.ShouldNotEqual ShouldAlmostEqual = assertions.ShouldAlmostEqual ShouldNotAlmostEqual = assertions.ShouldNotAlmostEqual ShouldResemble = assertions.ShouldResemble ShouldNotResemble = assertions.ShouldNotResemble .....如果上述不满足,我们也可以自定义。Convey的嵌套Convey语句可以无限嵌套,以体现测试用例之间的关系。需要注意的是,只有最外层的Convey需要传入*testing.T类型的变量t。import ( "testing" . "github.com/smartystreets/goconvey/convey")func TestStringSliceEqual(t *testing.T) { Convey("TestStringSliceEqual should return true when a != nil & b != nil", t, func() { a := []string{"hello", "goconvey"} b := []string{"hello", "goconvey"} So(StringSliceEqual(a, b), ShouldBeTrue) }) Convey("TestStringSliceEqual should return true when a == nil & b == nil", t, func() { So(StringSliceEqual(nil, nil), ShouldBeTrue) }) Convey("TestStringSliceEqual should return false when a == nil & b != nil", t, func() { a := []string(nil) b := []string{} So(StringSliceEqual(a, b), ShouldBeFalse) }) Convey("TestStringSliceEqual should return false when a != nil & b != nil", t, func() { a := []string{"hello", "world"} b := []string{"hello", "goconvey"} So(StringSliceEqual(a, b), ShouldBeFalse) })}注:子Convey的执行策略是并行的,因此前面的子Convey执行失败,不会影响后面的Convey执行。但是一个Convey下的子So,执行是串行的。测试框架总结&选型首先自带testing包没有断言功能,编写起来方便程度不足Testify拥有断言能力,一般采用Table-Driven方式编写测试用例,但这样用例之间的层级关系不够明显,并且和其他mock/stub框架结合使用的灵活程度GoConveyGoConvey能够方便清晰地体现和管理测试用例,断言能力丰富。而且层级嵌套用例的编写相较于Table-Driven的写法灵活轻量,并且和其他Stub/Mock框架的兼容性相比更好,不足之处在于理解起来可能需要一些学习成本。总的来说GoConvey值得推荐,下方实践的例子采用的是GoConvey。Mock&Stub基本概念一般来说,单元测试中是不允许有外部依赖的,那么也就是说这些外部依赖都需要被模拟。Mock(模拟)和Stub(桩)是在测试过程中,模拟外部依赖行为的两种常用的技术手段。通过Mock和Stub我们不仅可以让测试环境没有外部依赖而且还可以模拟一些异常行为,普遍来说,我们遇到最常见的依赖无非下面几种:网络依赖——函数执行依赖于网络请求,比如第三方http-api,rpc服务,消息队列等等数据库依赖I/O依赖当然,还有可能是依赖还未开发完成的功能模块。但是处理方法都是大同小异的——抽象成接口,通过mock和stub进行模拟测试。在Go语言中,可以这样描述Mock和Stub:Mock:在测试包中创建一个结构体,满足某个外部依赖的接口interface{}Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法下面简单的举例说明Mock直观实战案例原逻辑背景例如有一段逻辑,需要先通过某个storage系统的client读取一个远程系统上文件,然后经过一定处理后,再通过client删除远程系统上该文件func demoFunc(client storage.Client, name string) { // 其他前置逻辑 ... // 从远程存储系统获取该文件 rsp, err := client.Get(context.Background(), name) if err != nil{ log.Error("err:%v", err) } // 对读取文件rsp的处理逻辑 ... // 从远程存储系统删除该文件 _, err = client.Delete(context.Background(),name) if err != nil{ log.Error("err:%v", err) } // 其他逻辑 ...}显然,这里涉及到了外部依赖,我们可以mockclient的行为,去避免这个外部依赖,并关注于测试能够覆盖我们的代码逻辑。对client进行mock的过程首先看需要mock的clientinterfacetype Client interface { Get(ctx context.Context, name string) ([]byte, error) Delete(ctx context.Context, name string) (error) // other method..}然后,为了在不使用外部依赖的前提下测试到通过client分别读取、删除文件成功或者失败的逻辑,我们自己实现了一个模拟的fakeClient,它实现了Client这个interface,并定义了其方法如果name是特定值就会返回err或者特定内容,如下:type fakeClient struct {}func (c *fakeClient) Get(ctx context.Context, name string) ([]byte, error) { if name == "errName" { return nil, errors.New("getErr") } if name == "sucName" { return []byte("demo val"), nil } // other case .. return nil, errors.New("unknown name")}func (c *fakeClient) Delete(ctx context.Context, name string) (error) { if name == "errName" { return errors.New("getErr") } if name == "sucName" { return nil } // other case .. return errors.New("unknown name")}// other method to implement..在测试开始执行时,传入的client改为我们的fakeClient对象,而非真正连接外部依赖的client。并且由于我们可以控制fakeClient的方法,根据不同入参定制不同行为;另一方面在测试的时候传入对应入参,从而达到mock效果,走到我们想要走到的测试逻辑。func Test_demoFunc(t *testing.T) { c := fakeClient{} demoCosFunc(c, "sucName") // other test logic..}Mock框架——GoMock在上述案例中,我们为了模拟一些外部依赖或者错误情况时,手动实现了一个mock类,然后为该mock类注入我们希望要的逻辑,从而屏蔽依赖,达到模拟的效果。而在interface较为复杂的时候,我们可以借用一些Mock框架,例如GoMock。GoMock是由Golang官方开发维护的测试框架,实现了较为完整的基于interface的Mock功能。GoMock测试框架包含了GoMock包和mockgen工具两部分其中GoMock包完成对桩对象生命周期的管理mockgen工具用来生成interface对应的Mock类源文件。GoMock使用示例找到需要mock的interfacepackage tmptype Repository interface { Create(key string, value []byte) error Retrieve(key string) ([]byte, error) Update(key string, value []byte) error Delete(key string) error}使用mockgen工具生成mock类文件mockgen-source={file_name}.go>{mock_file_name}.go自动化生成Mock类的代码如下:// Code generated by MockGen. DO NOT EDIT.// Source: gomocktest.go// ackage mock is a generated GoMock package.package mock_repositoryimport ( gomock "github.com/golang/mock/gomock" reflect "reflect")// MockRepository is a mock of Repository interfacetype MockRepository struct { ctrl *gomock.Controller recorder *MockRepositoryMockRecorder}// MockRepositoryMockRecorder is the mock recorder for MockRepositorytype MockRepositoryMockRecorder struct { mock *MockRepository}// NewMockRepository creates a new mock instancefunc NewMockRepository(ctrl *gomock.Controller) *MockRepository { mock := &MockRepository{ctrl: ctrl} mock.recorder = &MockRepositoryMockRecorder{mock} return mock}// EXPECT returns an object that allows the caller to indicate expected usefunc (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { return m.recorder}........... //省略Importgomock&生成的mock的类import ( . "github.com/golang/mock/gomock" "test/mock_repository" "testing")初始化控制器&mock对象// 初始化控制器ctrl := NewController(t)defer ctrl.Finish()// 创建mock对象mockRepo := mock_repository.NewMockRepository(ctrl)mock对象的行为注入// mock对象的行为注入mockRepo.EXPECT().Retrieve("ray").Return(nil, errors.New("no such person"))mockRepo.EXPECT().Create(Any(), Any()).Return(nil)Stubstub(打桩)即在测试包中创建一个模拟方法,用于替换生成代码中的方法。打桩的库GoStub与Gomonkey均为主流的打桩库但GoStub存在如下几个问题:方法(成员函数)无法通过GoStub框架打桩GoStub如果对func打桩,还必须声明出variable才能进行stub,即使是interfacemethod也需要这么来定义,对代码有侵入性。另外,GoStub如果需要stub的方法,入参和返回的数量都是长度不固定的数组类型,就无法进行stub而反观Gomokey,能够实现GoStub的功能,还能避免其缺陷,故推荐使用。Gomonkey的基本场景为:基本场景:为一个函数打桩基本场景:为一个过程打桩基本场景:为一个方法打桩复合场景:由任意相同或不同的基本场景组合而成下面以cos云存储删除文件的逻辑为案例,演示下如何使用gomonkey为方法打桩import ( "context" "net/http" "net/url" "os" "github.com/tencentyun/cos-go-sdk-v5")func demo() { // 初始化cos client urlObj, _ := url.Parse("https://.cos..myqcloud.com") baseUrl := &cos.BaseURL{BucketURL: urlObj} c := cos.NewClient(baseUrl, &http.Client{ Transport: &cos.AuthorizationTransport{ SecretID: os.Getenv("COS_SECRETID"), SecretKey: os.Getenv("COS_SECRETKEY"), }, }) // *删除文件对象 (有外部依赖) name := "test/object" _,err := c.Object.Delete(context.Background(),name) if err != nil{ panic(err) }}该段代码逻辑中有向腾讯云cos删除文件,显然这里有外部网络调用的依赖,故需要进行打桩。下面是cos-go-sdk文件中的代码节选,可见我们需要为client对象的Delete方法打桩//DeleteObject请求可以将一个文件(Object)删除。//// https://www.qcloud.com/document/product/436/7743func (s *ObjectService) Delete(ctx context.Context, name string, opt ...*ObjectDeleteOptions) (*Response, error) 故demo函数的测试代码中需要有打桩代码如下:// 1. 生成需要打桩方法的对象,即clientc := cos.NewClient(&cos.BaseURL{}, &http.Client{})// 2. 定义好被打桩方法的返回值stubRet := []gomonkey.OutputCell{ {Values: gomonkey.Params{nil}}, // 模拟第一次调用Delete的时候,删除成功,返回nil}// 3. gomonkey 进行对该方法打桩,加上patchpatch := gomonkey.ApplyMethodSeq(reflect.TypeOf(c), "Delete", stubRet)// 4. 函数退出前及时reset patch,防止影响后续测试defer patch.Reset()生成需要打桩方法的对象定义好OutputCell,期望的返回值调用gomonkey.ApplyMethodSeq,第一个参数为对象的reflect.Type,第二个位method名,第三个位期望返回值该测试完成后删除补丁(patch.Reset)关于模拟的方式、补丁的生命周期一些思考对于不同的场景,我们mock或者stub时具体模拟方式、补丁的生命周期可能有所不通,具体如下两种每次仅模拟单次行为的结果,不需要考虑入参与顺序模拟所有行为,需要考虑入参场景被模拟的对象(方法、函数等)只需要在某个case内简单调用几次被模拟的对象(方法、函数等)在测试的时候对于不同的case会被调用许多次,而且期望对所有模拟的情况进行一个集中式的管理。具体行为那么在模拟的时候,可以只在该case需要用到该外部依赖的时进行模拟,且只模拟其返回值,不做别的逻辑,然后随后对模拟的补丁进行reset,以保证不会影响到其他case。那么这时候模拟的时候需要对入参进行switch-case,模拟各个case不同入的参情况将要对应的返回。这时候模拟的补丁不是在case内马上被reset,而是在整批case都进行完毕后才会reset。优点方便、简单、直接。更方便、集中地对被模拟对象的在有不同入参下的各种行为进行管理维护缺点临时性的模拟,无法根据入参或者顺序进行不同的效果。模拟较为臃肿,并且若有不慎可能case之间的模拟会相互影响例子仅调用一下外部系统,进行某种注册或者记录流水的行为,这时可以不论入参,简单模拟为其返回是否成功。上述的trpcselector,由于多个case需要测试select时不同的情况,需要模拟其select方法在不同target的情况下的返回值以及行为。Mock&Stub的辨析与总结对于控制被替代的方法来讲,mock如果想支持不同的输出,就需要提前实现不同的分支代码,甚至需要定义不同的mock结构体来实现,这样的mock代码会变成一个支持所有逻辑分支的一个最大集合,mock代码复杂性会变高;另一方面,stub却能很好的控制桩函数的不同分支,因为stub替换的是函数,那么只要需要再用到这种输出的时候,定义一个函数即可,而这个函数甚至都可以是匿名函数。引用deanyang大神对mock与stub的辨析理解:打桩和mock应该是最容易混淆的,而且习惯上我们统一用mock去形容模拟返回的能力,习惯成自然,也就把mock常挂在嘴边了。stub可以理解为mock的子集,mock更强大一些:mock可以验证实现过程,验证某个函数是否被执行,被执行几次mock可以依条件生效,比如传入特定参数,才会使mock效果生效mock可以指定返回结果当mock指定任何参数都返回固定的结果时,它等于stub实践流程&技巧:StepbyStep前提准备:IDE:GoLand测试框架:GoConveystub库:GoMonkey(满足不了需求时可采用其他)测试文件与函数的生成右键函数名,Generate->testforfunction发现即可自动生成同名测试文件_test.go,然后里面已经生成好测试函数GoConveyWebUI生成测试代码case基本脚手架运行GoconveyWebUI工具:$GOPATH/bin/goconvey打开用例脚手架编写工具http://127.0.0.1:8080/composer.html这里要考虑被测函数的各个分支逻辑,以便都能覆盖到各种情况根据所测函数逻辑,思考和编写所有要测case,然后用脚手架编写工具,有层次地写出各个要测的case名脚手架中填充具体测试逻辑将生成的Convey脚手架复制到Goland被测函数中,填写各个case的具体测试逻辑填充脚手架,编写每个case的具体测试逻辑:3A法则单元测试的代码结构⼀般一个三步经典结构:准备(arrange),调⽤(action),断⾔(assert)。Arrange:准备部分的⽬的是准备好调⽤所需要的外部环境,如数据,Stub,Mock,临时变量,调⽤请求,环境背景变量等等。Action:调⽤部分则是实际调⽤需要测试⽅法,函数或者流程。Assert:断⾔部分判断调⽤部分的返回结果是否符合预期。例子:
|
|