如何有效地测试Go代码

发表于 3年以前  | 总阅读数:323 次

单元测试

如果把开发程序比作盖房子,那么我们必须确保所有的用料都是合格的,否则盖起来的房子就会存在问题。对于程序而言,我们可以将盖房子的砖头、钢筋、水泥等当做一个个功能单元,如果每个单元是合格的,我们将有信心认为程序是健壮的。单元测试(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文件下有以下两个函数,现在我们需要对它们写测试案例。

1package math
2
3func Add(x, y int) int {
4    return x + y
5}
6
7func Multi(x, y int) int {
8    return x * y
9}

如果我们的IDE是Goland,它有一个非常好用的一键测试代码生成功能。

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

 1package math
 2
 3import "testing"
 4
 5func TestAdd(t *testing.T) {
 6    type args struct {
 7        x int
 8        y int
 9    }
10    tests := []struct {
11        name string
12        args args
13        want int
14    }{
15        // TODO: Add test cases.
16    }
17    for _, tt := range tests {
18        t.Run(tt.name, func(t *testing.T) {
19            if got := Add(tt.args.x, tt.args.y); got != tt.want {
20                t.Errorf("Add() = %v, want %v", got, tt.want)
21            }
22        })
23    }
24}

可以看到,在Go测试惯例中,单元测试的默认组织方式就是写在以 _test.go 结尾的文件中,所有的测试方法也都是以 Test 开头并且只接受一个 testing.T 类型的参数。同时,如果我们要给函数名为 Add 的方法写单元测试,那么对应的测试方法一般会被写成 TestAdd

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

1        {
 2            "negative + negative",
 3            args{-1, -1},
 4            -2,
 5        },
 6        {
 7            "negative + positive",
 8            args{-1, 1},
 9            0,
10        },
11        {
12            "positive + positive",
13            args{1, 1},
14            2,
15        },

此时,运行测试文件,可以发现所有测试案例,均成功通过。

1=== RUN   TestAdd
2--- PASS: TestAdd (0.00s)
3=== RUN   TestAdd/negative_+_negative
4    --- PASS: TestAdd/negative_+_negative (0.00s)
5=== RUN   TestAdd/negative_+_positive
6    --- PASS: TestAdd/negative_+_positive (0.00s)
7=== RUN   TestAdd/positive_+_positive
8    --- PASS: TestAdd/positive_+_positive (0.00s)
9PASS
2. 断言库:testify

简单了解了Go内置 testing 库的测试写法后,推荐一个好用的断言测试库:testify。testify具有常见断言和mock的工具链,最重要的是,它能够与内置库 testing 很好地配合使用,其项目地址位于https://github.com/stretchr/testify。

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

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

更改为如下断言形式

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

testify 提供的断言方法帮助我们快速地对函数的返回值进行测试,从而减少测试代码工作量。它可断言的类型非常丰富,例如断言Equal、断言NIl、断言Type、断言两个指针是否指向同一对象、断言包含、断言子集等。

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

 1...
 2=== RUN   TestAdd/positive_+_positive
 3    math_test.go:36: 
 4            Error Trace:    math_test.go:36
 5            Error:          Not equal: 
 6                            expected: 2
 7                            actual  : 3
 8            Test:           TestAdd/positive_+_positive
 9            Messages:       positive + positive
10    --- FAIL: TestAdd/positive_+_positive (0.00s)
11
12
13Expected :2
14Actual   :3
15...
3. 接口mock框架:gomock

介绍完基本的测试方法的写法后,我们需要讨论基于接口的 Mock 方法。在Go语言中,最通用的 Mock 手段是通过Go官方的 gomock 框架来自动生成其 Mock 方法。该项目地址位于https://github.com/golang/mock。

为了方便读者理解,本文举一个小明玩手机的例子。小明喜欢玩手机,他每天都需要通过手机聊微信、玩王者、逛知乎,如果某天没有干这些事情,小明就没办法睡觉。在该情景中,我们可以将手机抽象成接口如下。

1// mockDemo/equipment/phone.go
2type Phone interface {
3    WeiXin() bool
4    WangZhe() bool
5    ZhiHu() bool
6}

小明手上有一部非常老的IPhone6s,我们为该手机对象实现Phone接口。

 1// mockDemo/equipment/phone6s.go
 2type Iphone6s struct {
 3}
 4
 5func NewIphone6s() *Iphone6s {
 6    return &Iphone6s{}
 7}
 8
 9func (p *Iphone6s) WeiXin() bool {
10    fmt.Println("Iphone6s chat wei xin!")
11    return true
12}
13
14func (p *Iphone6s) WangZhe() bool {
15    fmt.Println("Iphone6s play wang zhe!")
16    return true
17}
18
19func (p *Iphone6s) ZhiHu() bool {
20    fmt.Println("Iphone6s read zhi hu!")
21    return true
22}

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

1// mockDemo/person.go
 2type Person struct {
 3    name  string
 4    phone equipment.Phone
 5}
 6
 7func NewPerson(name string, phone equipment.Phone) *Person {
 8    return &Person{
 9        name:  name,
10        phone: phone,
11    }
12}
13
14func (x *Person) goSleep() {
15    fmt.Printf("%s go to sleep!", x.name)
16}
17
18func (x *Person) dayLife() bool {
19    fmt.Printf("%s's daily life:\n", x.name)
20    if x.phone.WeiXin() && x.phone.WangZhe() && x.phone.ZhiHu() {
21        x.goSleep()
22        return true
23    }
24    return false
25}

最后,我们把小明和iphone6s对象实例化出来,并开启他一天的生活。

 1//mockDemo/main.go
 2func main() {
 3    phone := equipment.NewIphone6s()
 4    xiaoMing := NewPerson("xiaoMing", phone)
 5    xiaoMing.dayLife()
 6}
 7
 8// output
 9xiaoMing's daily life:
10Iphone6s chat wei xin!
11Iphone6s play wang zhe!
12Iphone6s read zhi hu!
13xiaoMing go to sleep!
14

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

按照当前代码,如果小明的手机坏了,或者小明换了一个手机,那他就没办法睡觉了,这肯定是万万不行的。因此我们需要把小明对某特定手机的依赖Mock掉,这个时候 gomock 框架排上了用场。

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

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

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

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

在执行该命令前,当前项目的组织结构如下

1.
2├── equipment
3│   ├── iphone6s.go
4│   └── phone.go
5├── go.mod
6├── go.sum
7├── main.go
8└── person.go

执行mockgen命令之后,在equipment/phone.go的同级目录,新生成了测试文件 mock_iphone.go(它的代码自动生成功能,是通过Go自带generate工具完成的,感兴趣的读者可以阅读《[Go工具之generate] 》一文),其部分内容如下

 1...
 2// MockPhone is a mock of Phone interface
 3type MockPhone struct {
 4    ctrl     *gomock.Controller
 5    recorder *MockPhoneMockRecorder
 6}
 7
 8// MockPhoneMockRecorder is the mock recorder for MockPhone
 9type MockPhoneMockRecorder struct {
10    mock *MockPhone
11}
12
13// NewMockPhone creates a new mock instance
14func NewMockPhone(ctrl *gomock.Controller) *MockPhone {
15    mock := &MockPhone{ctrl: ctrl}
16    mock.recorder = &MockPhoneMockRecorder{mock}
17    return mock
18}
19
20// EXPECT returns an object that allows the caller to indicate expected use
21func (m *MockPhone) EXPECT() *MockPhoneMockRecorder {
22    return m.recorder
23}
24
25// WeiXin mocks base method
26func (m *MockPhone) WeiXin() bool {
27    m.ctrl.T.Helper()
28    ret := m.ctrl.Call(m, "WeiXin")
29    ret0, _ := ret[0].(bool)
30    return ret0
31}
32
33// WeiXin indicates an expected call of WeiXin
34func (mr *MockPhoneMockRecorder) WeiXin() *gomock.Call {
35    mr.mock.ctrl.T.Helper()
36    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WeiXin", reflect.TypeOf((*MockPhone)(nil).WeiXin))
37}
38...

此时,我们的person.go中的 Person.dayLife 方法就可以测试了。

1func TestPerson_dayLife(t *testing.T) {
 2    type fields struct {
 3        name  string
 4        phone equipment.Phone
 5    }
 6
 7  // 生成mockPhone对象
 8    mockCtl := gomock.NewController(t)
 9    mockPhone := equipment.NewMockPhone(mockCtl)
10  // 设置mockPhone对象的接口方法返回值
11    mockPhone.EXPECT().ZhiHu().Return(true)
12    mockPhone.EXPECT().WeiXin().Return(true)
13    mockPhone.EXPECT().WangZhe().Return(true)
14
15    tests := []struct {
16        name   string
17        fields fields
18        want   bool
19    }{
20        {"case1", fields{"iphone6s", equipment.NewIphone6s()}, true},
21        {"case2", fields{"mocked phone", mockPhone}, true},
22    }
23    for _, tt := range tests {
24        t.Run(tt.name, func(t *testing.T) {
25            x := &Person{
26                name:  tt.fields.name,
27                phone: tt.fields.phone,
28            }
29            assert.Equal(t, tt.want, x.dayLife())
30        })
31    }
32}

对接口进行Mock,可以让我们在未实现具体对象的接口功能前,或者该接口调用代价非常高时,也能对业务代码进行测试。而且在开发过程中,我们同样可以利用Mock对象,不用因为等待接口实现方实现相关功能,从而停滞后续的开发。

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

4. 常见三方mock依赖库

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

  • go-sqlmock

这是Go语言中用以测试数据库交互的SQL模拟驱动库,其项目地址为 https://github.com/DATA-DOG/go-sqlmock。它而无需真正地数据库连接,就能够在测试中模拟sql驱动程序行为,非常有助于维护测试驱动开发(TDD)的工作流程。

  • httpmock

用于模拟外部资源的http响应,它使用模式匹配的方式匹配 HTTP 请求的 URL,在匹配到特定的请求时就会返回预先设置好的响应。其项目地址为 https://github.com/jarcoal/httpmock 。

  • gripmock

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

  • redismock

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

5. 猴子补丁:monkey patch

如果上述的方案都不能很好的写出测试代码,这时可以考虑使用猴子补丁。猴子补丁简单而言就是属性在运行时的动态替换,它在理论上可以替换运行时中的一切函数。这种测试方式在动态语言例如Python中比较合适。在Go中,monkey库通过在运行时重写正在运行的可执行文件并插入跳转到您要调用的函数来实现Monkey patching。项目作者写道:这个操作很不安全,不建议任何人在测试环境之外进行使用。其项目地址为https://github.com/bouk/monkey。

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

1package main
 2
 3import (
 4    "fmt"
 5    "os"
 6    "strings"
 7
 8    "bou.ke/monkey"
 9)
10
11func main() {
12    monkey.Patch(fmt.Println, func(a ...interface{}) (n int, err error) {
13        s := make([]interface{}, len(a))
14        for i, v := range a {
15            s[i] = strings.Replace(fmt.Sprint(v), "hell", "*bleep*", -1)
16        }
17        return fmt.Fprintln(os.Stdout, s...)
18    })
19    fmt.Println("what the hell?") // what the *bleep*?
20}

需要注意的是,如果启用了内联,则monkey有时无法进行patching,因此,我们需要尝试在禁用内联的情况下运行测试。例如以上例子,我们需要通过以下命令执行。

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

总结

在项目开发中,单元测试是重要且必须的。对于单元测试的两大难点:解耦依赖,我们的代码可以采用 面向接口+mock依赖 的方式进行组织,将依赖都做成可插拔的,那在单元测试里面隔离依赖就是一件水到渠成的事情。

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

最后,在这些测试工具的使用上,本文的内容也只是一些浅尝辄止的介绍,希望读者能够在实际项目中多写写单元测试,深入体会TDD的开发思想。

本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/DXCC0i1WXE6vpIwvFEzjpw

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237293次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8132次阅读
 目录