Why单元测试
Test-Driven Development, 测试驱动开发,是敏捷开发的⼀项核⼼实践和技术,也是⼀种设计⽅法论。TDD原理是开发功能代码之前,先编写测试⽤例代码,然后针对测试⽤例编写功能代码,使其能够通过。其好处在于通过测试的执⾏代码,肯定满⾜需求,⽽且有助于接⼝编程,降低代码耦合,也极⼤降低bug出现⼏率。
然而TDD的坏处也显而易见:由于测试⽤例在未进⾏代码设计前写;很有可能限制开发者对代码整体设计,并且由于TDD对开发⼈员要求⾮常⾼,跟传统开发思维不⼀样,因此实施起来比较困难,在客观情况不满足的情况下,不应该盲目追求对业务代码使用TDD的开发模式。
在完成业务需求后,可能由于上线时间较为紧、没有单测相关规划的历史缘故,当时只手动测试是否符合功能。
而这部分存量代码出现较大的新需求或者维护已经成为问题,需要大规模重构时,是推动补全单测的好时机。因为为存量代码补充上单测一方面能够推进重构者进一步理解原先逻辑,另一方面能够增强重构后的信心,降低风险。
但补充存量单测可能需要再次回忆理解需求和逻辑设计等细节,甚至写单测者并不是原编码设计者。
及时为增量代码写上单测是一种良好的习惯。因为此时有对需求有一定的理解,能够更好地写出单元测试来验证正确性。并且能在单测阶段发现问题,修复的成本也是最小的,不必等到联调测试中发现。
另一方面在写单测的过程中也能够反思业务代码的正确性、合理性,能推动我们在实现的过程中更好地反思代码的设计并及时调整。
主要介绍golang原生testing框架、testify框架、goconvey框架,看一下哪种框架是结合业务体验更好的。
文件形式:文件以_test.go 结尾 函数形式:func TestXxx(*testing.T) 断言:使用 t.Errorf 或相关方法来发出失败信号 运行:使用go test –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 是很多 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基于gotesting编写,所以语法上、执行命令行与go test完全兼容,只是其是比较清晰的断言定义。它提供 assert 和 require 两种用法,分别对应失败后的执行策略,前者失败后继续执行,后者失败后立刻停止。但是它们都是单次断言失败,当前test case就失败。
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的的模式使用assert
func 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使用起来较为不便
GoConvey是一款针对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的三个参数 分别为:
而对于断言So 参数的理解,总共有三个参数:
关于assert,Convey 包已经帮我们定义了大部分的基础断言了:
// source code: github.com\smartystreets\goconvey@v1.6.4\convey\assertions.go
var (
ShouldEqual = assertions.ShouldEqual
ShouldNotEqual = assertions.ShouldNotEqual
ShouldAlmostEqual = assertions.ShouldAlmostEqual
ShouldNotAlmostEqual = assertions.ShouldNotAlmostEqual
ShouldResemble = assertions.ShouldResemble
ShouldNotResemble = assertions.ShouldNotResemble
.....
如果上述不满足,我们也可以自定义。
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框架结合使用的灵活程度GoConvey
GoConvey能够方便清晰地体现和管理测试用例,断言能力丰富。而且层级嵌套用例的编写相较于Table-Driven的写法灵活轻量,并且和其他Stub/Mock框架的兼容性相比更好,不足之处在于理解起来可能需要一些学习成本。
总的来说GoConvey值得推荐,下方实践的例子采用的是GoConvey。
一般来说,单元测试中是不允许有外部依赖的,那么也就是说这些外部依赖都需要被模拟。Mock(模拟)和Stub(桩)是在测试过程中,模拟外部依赖行为的两种常用的技术手段。通过Mock和Stub我们不仅可以让测试环境没有外部依赖而且还可以模拟一些异常行为,普遍来说,我们遇到最常见的依赖无非下面几种:
在Go语言中,可以这样描述Mock和Stub:
例如有一段逻辑,需要先通过某个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)
}
// 其他逻辑 ...
}
显然,这里涉及到了外部依赖,我们可以mock client的行为,去避免这个外部依赖,并关注于测试能够覆盖我们的代码逻辑。
首先看需要mock的client interface
type 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类,然后为该mock类注入我们希望要的逻辑,从而屏蔽依赖,达到模拟的效果。而在interface较为复杂的时候,我们可以借用一些Mock框架,例如GoMock。
GoMock是由Golang官方开发维护的测试框架,实现了较为完整的基于interface的Mock功能。GoMock测试框架包含了GoMock包和mockgen工具两部分
package tmp
type Repository interface {
Create(key string, value []byte) error
Retrieve(key string) ([]byte, error)
Update(key string, value []byte) error
Delete(key string) error
}
mockgen -source={file_name}.go > {mock_file_name}.go
自动化生成Mock类的代码如下:// Code generated by MockGen. DO NOT EDIT.
// Source: gomocktest.go
// Package mock is a generated GoMock package.
package mock_repository
import (
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockRepository is a mock of Repository interface
type MockRepository struct {
ctrl *gomock.Controller
recorder *MockRepositoryMockRecorder
}
// MockRepositoryMockRecorder is the mock recorder for MockRepository
type MockRepositoryMockRecorder struct {
mock *MockRepository
}
// NewMockRepository creates a new mock instance
func 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 use
func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder {
return m.recorder
}
........... //省略
import (
. "github.com/golang/mock/gomock"
"test/mock_repository"
"testing"
)
// 初始化控制器
ctrl := NewController(t)
defer ctrl.Finish()
// 创建mock对象
mockRepo := mock_repository.NewMockRepository(ctrl)
// mock对象的行为注入
mockRepo.EXPECT().Retrieve("ray").Return(nil, errors.New("no such person"))
mockRepo.EXPECT().Create(Any(), Any()).Return(nil)
stub(打桩)即在测试包中创建一个模拟方法,用于替换生成代码中的方法。
GoStub 与 Gomonkey 均为主流的打桩库 但GoStub存在如下几个问题:
而反观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://<bucket>.cos.<region>.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方法打桩
// Delete Object请求可以将一个文件(Object)删除。
//
// https://www.qcloud.com/document/product/436/7743
func (s *ObjectService) Delete(ctx context.Context, name string, opt ...*ObjectDeleteOptions) (*Response, error)
故demo函数的测试代码中需要有打桩代码如下:
// 1. 生成需要打桩方法的对象,即client
c := cos.NewClient(&cos.BaseURL{}, &http.Client{})
// 2. 定义好被打桩方法的返回值
stubRet := []gomonkey.OutputCell{
{Values: gomonkey.Params{nil}}, // 模拟第一次调用Delete的时候,删除成功,返回nil
}
// 3. gomonkey 进行对该方法打桩,加上patch
patch := gomonkey.ApplyMethodSeq(reflect.TypeOf(c), "Delete", stubRet)
// 4. 函数退出前及时reset patch,防止影响后续测试
defer patch.Reset()
对于不同的场景,我们mock或者stub时具体模拟方式、补丁的生命周期可能有所不通,具体如下两种
每次仅模拟单次行为的结果,不需要考虑入参与顺序 | 模拟所有行为,需要考虑入参 | |
---|---|---|
场景 | 被模拟的对象(方法、函数等)只需要在某个case内简单调用几次 | 被模拟的对象(方法、函数等)在测试的时候对于不同的case会被调用许多次,而且期望对所有模拟的情况进行一个集中式的管理。 |
具体行为 | 那么在模拟的时候,可以只在该case需要用到该外部依赖的时进行模拟,且只模拟其返回值,不做别的逻辑,然后随后对模拟的补丁进行reset,以保证不会影响到其他case。 | 那么这时候模拟的时候需要对入参进行switch-case,模拟各个case不同入的参情况将要对应的返回。这时候模拟的补丁不是在case内马上被reset,而是在整批case都进行完毕后才会reset。 |
优点 | 方便、简单、直接。 | 更方便、集中地对被模拟对象的在有不同入参下的各种行为进行管理维护 |
缺点 | 临时性的模拟,无法根据入参或者顺序进行不同的效果。 | 模拟较为臃肿,并且若有不慎可能case之间的模拟会相互影响 |
例子 | 仅调用一下外部系统,进行某种注册 或者 记录流水的行为,这时可以不论入参,简单模拟为其返回是否成功。 | 上述的trpc selector,由于多个case需要测试select时不同的情况,需要模拟其select方法在不同target的情况下的返回值以及行为。 |
对于控制被替代的方法来讲,mock如果想支持不同的输出,就需要提前实现不同的分支代码,甚至需要定义不同的mock结构体来实现,这样的mock代码会变成一个支持所有逻辑分支的一个最大集合,mock代码复杂性会变高;
另一方面,stub却能很好的控制桩函数的不同分支,因为stub替换的是函数,那么只要需要再用到这种输出的时候,定义一个函数即可,而这个函数甚至都可以是匿名函数。
引用deanyang大神对mock与stub的辨析理解:
打桩和mock应该是最容易混淆的,而且习惯上我们统一用mock去形容模拟返回的能力,习惯成自然,也就把mock常挂在嘴边了。stub可以理解为mock的子集,mock更强大一些:
前提准备:IDE:GoLand 测试框架:GoConvey stub库:GoMonkey (满足不了需求时可采用其他)
单元测试的代码结构⼀般一个三步经典结构:准备(arrange),调⽤(action),断⾔(assert)。
例子:
注:-gcflags=all=-l参数是因为对内联函数的Stub一定要加上这个参数才可生效。所以,我们的命令行默认加上-gcflags=all=-l就行了
有时写单测看着空荡荡的测试文件,一时间总觉得不知道无从下手,亦或者会产生抗拒心理。而上述的流程就是希望能够将单测尽量能够流程化,减少开始写单测时的心里抗拒 & 提高效率
合理地对外部依赖进行封装例如可以将一组相关的外部依赖操作(例如所有访问某个特定外部系统的函数集)通过一个interface集中封装 而不是散落的许多个函数,这样便于实现mock类然后方便地替代掉其相关的所有外部依赖操作。同时多个连续相关的外部依赖调用也可以进行进一步的封装抽象出一个函数,从而减少mock、stub的数量。
降低单元模块的圈复杂度当业务代码单元的圈复杂度过高,对其的单元测试往往是比较困难的,因为有太多的分支与路径需要去覆盖,而且后期的维护也比较苦难。降低圈复杂度,无疑能够提高代码的可维护性,并且构建单元测试也会更加便捷。下面总结列出一点关于降低全复杂度的实践方法: