关于golang:如何有效地测试Go代码

单元测试

如果把开发程序比作盖房子,那么咱们必须确保所有的用料都是合格的,否则盖起来的房子就会存在问题。对于程序而言,咱们能够将盖房子的砖头、钢筋、水泥等当做一个个性能单元,如果每个单元是合格的,咱们将有信念认为程序是强壮的。单元测试(Unit Test,UT)就是测验性能单元是否合格的工具。

一个没有UT的我的项目,它的代码品质与工程保障是堪忧的。但在理论开发工作中,很多程序员往往并不写测试代码,他们的开发周期可能如下图所示。

而做了充沛UT的程序员,他们的我的项目开发周期更大概率如下。

我的项目开发中,不写UT兴许能使代码交付更快,然而咱们无奈保障写进去的代码真的可能正确地执行。写UT能够缩小前期解决bug的工夫,也能让咱们释怀地应用本人写进去的代码。从久远来看,后者更能无效地节俭开发工夫。

既然UT这么重要,是什么起因在阻止开发人员写UT呢?这是因为除了开发人员的惰性习惯之外,编写UT代码同样存在难点。

  1. 代码耦合度高,短少必要的形象与拆分,以至于不晓得如何写UT。
  2. 存在第三方依赖,例如依赖数据库连贯、HTTP申请、数据缓存等。

可见,编写可测试代码的难点就在于解耦依赖

接口与Mock

对于难点1,咱们须要面向接口编程。在《接口Interface——塑造强壮与可扩大的Go应用程序》一文中,咱们探讨了应用接口给代码带来的灵便解耦与高扩大个性。接口是对一类对象的抽象性形容,表明该类对象能提供什么样的服务,它最次要的作用就是解耦调用者和实现者,这成为了可测试代码的要害。

对于难点2,咱们能够通过Mock测试来解决。Mock测试就是在测试过程中,对于某些不容易结构或者不容易获取的对象,用一个虚构的对象来创立以便测试的测试方法。

如果咱们的代码都是面向接口编程,调用方与服务方将是松耦合的依赖关系。在测试代码中,咱们就能够Mock 出另一种接口的实现,从而很容易地替换掉第三方的依赖。

测试工具

1. 自带测试库:testing

在介绍Mock测试之前,先看一下Go中最简略的测试单元应该如何写。假如咱们在math.go文件下有以下两个函数,当初咱们须要对它们写测试案例。

// math.go
package math

func Add(x, y int) int {
    return x + y
}

func Multi(x, y int) int {
    return x * y
}

如果咱们的IDE是Goland,它有一个十分好用的一键测试代码生成性能。

如上图所示,光标置于函数名之上,右键抉择 Generate,咱们能够抉择生成整个package、以后file或者以后选中函数的测试代码。以 Tests for selection 为例,Goland 会主动在以后 math.go 同级目录新建测试文件math_test.go,内容如下。

// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    type args struct {
        x int
        y int
    }
    tests := []struct {
        name string
        args args
        want int
    }{
        // TODO: Add test cases.
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Add(tt.args.x, tt.args.y); got != tt.want {
                t.Errorf("Add() = %v, want %v", got, tt.want)
            }
        })
    }
}

能够看到,在Go测试常规中,单元测试的默认组织形式就是写在以 _test.go 结尾的文件中,所有的测试方法也都是以 Test 结尾并且只承受一个 testing.T 类型的参数。同时,如果咱们要给函数名为 Add 的办法写单元测试,那么对应的测试方法个别会被写成 TestAdd

当测试模板生成之后,咱们只需将测试案例增加至 TODO 即可。

        {
            "negative + negative",
            args{-1, -1},
            -2,
        },
        {
            "negative + positive",
            args{-1, 1},
            0,
        },
        {
            "positive + positive",
            args{1, 1},
            2,
        },

此时,运行测试文件,能够发现所有测试案例,均胜利通过。

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestAdd/negative_+_negative
    --- PASS: TestAdd/negative_+_negative (0.00s)
=== RUN   TestAdd/negative_+_positive
    --- PASS: TestAdd/negative_+_positive (0.00s)
=== RUN   TestAdd/positive_+_positive
    --- PASS: TestAdd/positive_+_positive (0.00s)
PASS
2. 断言库:testify

简略理解了Go内置 testing 库的测试写法后,举荐一个好用的断言测试库:testify。testify具备常见断言和mock的工具链,最重要的是,它可能与内置库 testing 很好地配合应用,其我的项目地址位于https://github.com/stretchr/t…。

如果采纳testify库,须要引入"github.com/stretchr/testify/assert"。之外,上述测试代码中以下局部

            if got := Add(tt.args.x, tt.args.y); got != tt.want {
                t.Errorf("Add() = %v, want %v", got, tt.want)
            }

更改为如下断言模式

     assert.Equal(t, Add(tt.args.x, tt.args.y), tt.want, tt.name)

testify 提供的断言办法帮忙咱们疾速地对函数的返回值进行测试,从而缩小测试代码工作量。它可断言的类型十分丰盛,例如断言Equal、断言NIl、断言Type、断言两个指针是否指向同一对象、断言蕴含、断言子集等。

不要小瞧这一行代码,如果咱们在测试案例中,将”positive + positive”的期望值改为3,那么测试后果中会主动提供报错信息。

...
=== RUN   TestAdd/positive_+_positive
    math_test.go:36: 
            Error Trace:    math_test.go:36
            Error:          Not equal: 
                            expected: 2
                            actual  : 3
            Test:           TestAdd/positive_+_positive
            Messages:       positive + positive
    --- FAIL: TestAdd/positive_+_positive (0.00s)


Expected :2
Actual   :3
...
3. 接口mock框架:gomock

介绍完根本的测试方法的写法后,咱们须要探讨基于接口的 Mock 办法。在Go语言中,最通用的 Mock 伎俩是通过Go官网的 gomock 框架来主动生成其 Mock 办法。该我的项目地址位于https://github.com/golang/mock。

为了不便读者了解,本文举一个小明玩手机的例子。小明喜爱玩手机,他每天都须要通过手机聊微信、玩王者、逛知乎,如果某天没有干这些事件,小明就没方法睡觉。在该情景中,咱们能够将手机形象成接口如下。

// mockDemo/equipment/phone.go
type Phone interface {
    WeiXin() bool
    WangZhe() bool
    ZhiHu() bool
}

小明手上有一部十分老的IPhone6s,咱们为该手机对象实现Phone接口。

// mockDemo/equipment/phone6s.go
type Iphone6s struct {
}

func NewIphone6s() *Iphone6s {
    return &Iphone6s{}
}

func (p *Iphone6s) WeiXin() bool {
    fmt.Println("Iphone6s chat wei xin!")
    return true
}

func (p *Iphone6s) WangZhe() bool {
    fmt.Println("Iphone6s play wang zhe!")
    return true
}

func (p *Iphone6s) ZhiHu() bool {
    fmt.Println("Iphone6s read zhi hu!")
    return true
}

接着,咱们定义Person对象用来示意小明,并定义Person对象的生存函数dayLife和入睡函数goSleep

// mockDemo/person.go
type Person struct {
    name  string
    phone equipment.Phone
}

func NewPerson(name string, phone equipment.Phone) *Person {
    return &Person{
        name:  name,
        phone: phone,
    }
}

func (x *Person) goSleep() {
    fmt.Printf("%s go to sleep!", x.name)
}

func (x *Person) dayLife() bool {
    fmt.Printf("%s's daily life:\n", x.name)
    if x.phone.WeiXin() && x.phone.WangZhe() && x.phone.ZhiHu() {
        x.goSleep()
        return true
    }
    return false
}

最初,咱们把小明和iphone6s对象实例化进去,并开启他一天的生存。

//mockDemo/main.go
func main() {
    phone := equipment.NewIphone6s()
    xiaoMing := NewPerson("xiaoMing", phone)
    xiaoMing.dayLife()
}

// output
xiaoMing's daily life:
Iphone6s chat wei xin!
Iphone6s play wang zhe!
Iphone6s read zhi hu!
xiaoMing go to sleep!

因为小明每天必须刷完手机能力睡觉,即Person.goSleep,那么小明是否睡觉依赖于手机。

依照以后代码,如果小明的手机坏了,或者小明换了一个手机,那他就没方法睡觉了,这必定是万万不行的。因而咱们须要把小明对某特定手机的依赖Mock掉,这个时候 gomock 框架排上了用场。

如果没有下载gomock库,则执行以下命令获取

GO111MODULE=on go get github.com/golang/mock/mockgen

通过执行以下命令对phone.go中的Phone接口Mock

mockgen -destination equipment/mock_iphone.go -package equipment -source equipment/phone.go

在执行该命令前,以后我的项目的组织构造如下

.
├── equipment
│   ├── iphone6s.go
│   └── phone.go
├── go.mod
├── go.sum
├── main.go
└── person.go

执行mockgen命令之后,在equipment/phone.go的同级目录,新生成了测试文件 mock_iphone.go(它的代码主动生成性能,是通过Go自带generate工具实现的,感兴趣的读者能够浏览《Go工具之generate》一文),其局部内容如下

...
// MockPhone is a mock of Phone interface
type MockPhone struct {
    ctrl     *gomock.Controller
    recorder *MockPhoneMockRecorder
}

// MockPhoneMockRecorder is the mock recorder for MockPhone
type MockPhoneMockRecorder struct {
    mock *MockPhone
}

// NewMockPhone creates a new mock instance
func NewMockPhone(ctrl *gomock.Controller) *MockPhone {
    mock := &MockPhone{ctrl: ctrl}
    mock.recorder = &MockPhoneMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockPhone) EXPECT() *MockPhoneMockRecorder {
    return m.recorder
}

// WeiXin mocks base method
func (m *MockPhone) WeiXin() bool {
    m.ctrl.T.Helper()
    ret := m.ctrl.Call(m, "WeiXin")
    ret0, _ := ret[0].(bool)
    return ret0
}

// WeiXin indicates an expected call of WeiXin
func (mr *MockPhoneMockRecorder) WeiXin() *gomock.Call {
    mr.mock.ctrl.T.Helper()
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WeiXin", reflect.TypeOf((*MockPhone)(nil).WeiXin))
}
...

此时,咱们的person.go中的 Person.dayLife 办法就能够测试了。

func TestPerson_dayLife(t *testing.T) {
    type fields struct {
        name  string
        phone equipment.Phone
    }

  // 生成mockPhone对象
    mockCtl := gomock.NewController(t)
    mockPhone := equipment.NewMockPhone(mockCtl)
  // 设置mockPhone对象的接口办法返回值
    mockPhone.EXPECT().ZhiHu().Return(true)
    mockPhone.EXPECT().WeiXin().Return(true)
    mockPhone.EXPECT().WangZhe().Return(true)

    tests := []struct {
        name   string
        fields fields
        want   bool
    }{
        {"case1", fields{"iphone6s", equipment.NewIphone6s()}, true},
        {"case2", fields{"mocked phone", mockPhone}, true},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            x := &Person{
                name:  tt.fields.name,
                phone: tt.fields.phone,
            }
            assert.Equal(t, tt.want, x.dayLife())
        })
    }
}

对接口进行Mock,能够让咱们在未实现具体对象的接口性能前,或者该接口的调用代价十分高时,也能对业务代码进行测试。而且在开发过程中,咱们同样能够利用Mock对象,不必因为期待接口实现方实现相干性能,从而停滞后续的开发。

在这里咱们可能领会到在Go程序中接口对于测试的重要性。没有接口的Go代码,单元测试会十分难写。所以,如果一个稍大型的我的项目中,没有任何接口,那么该项目标品质肯定是堪忧的。

4. 常见三方mock依赖库

在上文中提到,因为存在某些存在第三方依赖,会让咱们的代码难以测试。但其实曾经有一些比拟成熟的mock依赖库可供咱们应用。因为篇幅起因,以下列出的一些mock库将不再贴出示例代码,详细信息可通过对应的我的项目地址进行理解。

  • go-sqlmock

这是Go语言中用以测试数据库交互的SQL模仿驱动库,其我的项目地址为 https://github.com/DATA-DOG/g…。它而无需真正地数据库连贯,就可能在测试中模仿sql驱动程序行为,十分有助于保护测试驱动开发(TDD)的工作流程。

  • httpmock

用于模仿内部资源的http响应,它应用模式匹配的形式匹配 HTTP 申请的 URL,在匹配到特定的申请时就会返回事后设置好的响应。其我的项目地址为 https://github.com/jarcoal/ht… 。

  • gripmock

它用于模仿gRPC服务的服务器,通过应用.proto文件生成对gRPC服务的实现,其我的项目地址为 https://github.com/tokopedia/…。

  • redismock

用于测试与Redis服务器的交互,其我的项目地址位于 https://github.com/elliotchan…。

5. 猴子补丁:monkey patch

如果上述的计划都不能很好的写出测试代码,这时能够思考应用猴子补丁。猴子补丁简略而言就是属性在运行时的动静替换,它在实践上能够替换运行时中的所有函数。这种测试形式在动静语言例如Python中比拟适合。在Go中,monkey库通过在运行时重写正在运行的可执行文件并插入跳转到您要调用的函数来实现Monkey patching。我的项目作者写道:这个操作很不平安,不倡议任何人在测试环境之外进行应用。其我的项目地址为https://github.com/bouk/monkey。

monkey库的API比较简单,例如能够通过调用 monkey.Patch(<target function>, <replacement function>)来实现对函数的替换,以下是操作示例。

package main

import (
    "fmt"
    "os"
    "strings"

    "bou.ke/monkey"
)

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*?
}

须要留神的是,如果启用了内联,则monkey有时无奈进行patching,因而,咱们须要尝试在禁用内联的状况下运行测试。例如以上例子,咱们须要通过以下命令执行。

$ go build -o main -gcflags=-l main.go;./main
what the *bleep*?

总结

在我的项目开发中,单元测试是重要且必须的。对于单元测试的两大难点:解耦依赖,咱们的代码能够采纳 面向接口+mock依赖 的形式进行组织,将依赖都做成可插拔的,那在单元测试外面隔离依赖就是一件瓜熟蒂落的事件。

另外,本文探讨了一些实用的测试工具,包含自带测试库testing的疾速生成测试代码,断言库testify的断言应用,接口mock框架gomock如何mock接口办法和一些常见的三方依赖mock库举荐,最初再介绍了测试大杀器猴子补丁,当然,不到万不得已,不要应用猴子补丁。

最初,在这些测试工具的应用上,本文的内容也只是一些浅尝辄止的介绍,心愿读者可能在理论我的项目中多写写单元测试,深刻领会TDD的开发思维。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理