Go的单元测试技巧

本文为社区粉丝原创投稿,再次感谢作者DrmagicE的分享,欢迎大家在评论区留言和作者讨论,同时也欢迎大家踊跃投稿,分享您的golang语言学习经验!投稿邮箱地址为tougao@golang.ltd

原创投稿:DrmagicE

单元测试(Unit Test)

Go语言原生支持测试工具go test,省去了各种各样测试框架的学习成本。说来也惭愧,写代码这么些年,也从来没有给自己的代码写过单元测试,代码质量的确堪忧。遂花时间学习整理了一下单元测试的基本方法,以及在Go中的实践技巧。

单元测试的难点

以下是我在尝试进行单元测试的过程中遇到的一些难点,在下文中会介绍相应的一些应对方案。

1.掌握单元测试粒度

单元测试粒度是让人十分头疼的问题,特别是对于初尝单元测试的程序员(比如我)。测试粒度做的太细,会耗费大量的开发以及维护时间,每改一个方法,都要改动其对应的测试方法。当发生代码重构的时候那简直就是噩梦(因为你所有的单元测试又都要写一遍了…)。 如单元测试粒度太粗,一个测试方法测试了n多方法,那么单元测试将显的非常臃肿,脱离了单元测试的本意,容易把单元测试写成__集成测试__。

2. 破除外部依赖(mock,stub 技术)

单元测试中是不允许有任何外部依赖的,也就是说这些外部依赖都需要被模拟(mock)。外部依赖越多,mock越复杂。如何用模拟的依赖来测试真实依赖的行为?mock写的太简单,达不到测试的目的。mock太复杂, 不仅成本增加,而且又如何确保mock的正确性呢?

有的时候模拟是有效的方便的。但是其他一些时候,过多的模拟对象,Stub对象,假对象,导致单元测试主要在测模拟对象而不是实际的系统。

Costs and Benefits

在受益于单元测试的好处的同时,也必然增加了代码量以及维护成本(单元测试代码也是要维护的)。下面这张成本/价值象限图很清晰的阐述了在不同性质的系统中单元测试__成本__和__价值__之间的关系。

1.依赖很少的简单的代码(左下)

对于外部依赖少,代码又简单的代码。自然其成本和价值都是比较低的。举Go官方库里errors包为例,整个包就两个方法New()Error(),没有任何外部依赖,代码也很简单,所以其单元测试起来也是相当方便。

2. 依赖较多但是很简单的代码(右下)

依赖一多,mock和stub就必然增多,单元测试的成本也就随之增加。但代码又如此简单(比如上述errors包的例子),这个时候写单元测试的成本已经大于其价值,还不如不写单元测试

3. 依赖很少的复杂代码 (左上)

像这一类代码,是最有价值写单元测试的。比如一些独立的复杂算法(银行利息计算,保险费率计算,TCP协议解析等),像这一类代码外部依赖很少,但却很容易出错,如果没有单元测试,几乎不能保证代码质量。

4.依赖很多又很复杂(右上)

这种代码显然是单元测试的噩梦。写单元测试吧,代价高昂;不写单元测试吧,风险太高。像这种代码我们尽量在设计上将其分为两部分:1.处理复杂的逻辑部分 2.处理依赖部分 然后1部分进行单元测试

原文参考:http://blog.stevensanderson.com/2009/11/04/selective-unit-testing-costs-and-benefits/

迈出单元测试第一步

1. 识别依赖,抽象成接口

识别系统中的外部依赖,普遍来说,我们遇到最常见的依赖无非下面几种:

  1. 网络依赖——函数执行依赖于网络请求,比如第三方http-api,rpc服务,消息队列等等
  2. 数据库依赖
  3. I/O依赖(文件)

当然,还有可能是依赖还未开发完成的功能模块。但是处理方法都是大同小异的——抽象成接口,通过mock和stub进行模拟测试。

2. 明确需要测什么

当我们开始敲产品代码的时候,我们必然已经过初步的设计,已经了解系统中的外部依赖以及业务复杂的部分,这些部分是要优先考虑写单元测试的。在写每一个方法/结构体的时候同时思考这个方法/结构体需不需要测试?如何测试?对于什么样的方法/结构体需要测试,什么样的可以不做,除了可以从上面的成本/价值象限图中获得答案外,还可以参考以下关于单元测试粒度要做多细问题的回答:

老板为我的代码付报酬,而不是测试,所以,我对此的价值观是——测试越少越好,少到你对你的代码质量达到了某种自信(我觉得这种的自信标准应该要高于业内的标准,当然,这种自信也可能是种自大)。如果我的编码生涯中不会犯这种典型的错误(如:在构造函数中设了个错误的值),那我就不会测试它。我倾向于去对那些有意义的错误做测试,所以,我对一些比较复杂的条件逻辑会异常地小心。当在一个团队中,我会非常小心的测试那些会让团队容易出错的代码。 https://coolshell.cn/articles/8209.html

Mock和Stub怎么做

Mock(模拟)和Stub(桩)是在测试过程中,模拟外部依赖行为的两种常用的技术手段。 通过Mock和Stub我们不仅可以让测试环境没有外部依赖,而且还可以模拟一些异常行为,如数据库服务不可用,没有文件的访问权限等等。

Mock和Stub的区别

在Go语言中,可以这样描述Mock和Stub:

  • Mock:在测试包中创建一个结构体,满足某个外部依赖的接口interface{}
  • Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法

还是有点抽象,下面举例说明。

Mock示例

Mock:在测试包中创建一个结构体,满足某个外部依赖的接口interface{}

生产代码:

1//auth.go
2//假设我们有一个依赖http请求的鉴权接口
3type AuthService interface{
4    Login(username string,password string) (token string,e error)
5    Logout(token string) error
6}

mock代码:

1//auth_test.go
2type authService struct {
3}
4func (auth *authService) Login (username string,password string) (string,error) {
5    return "token", nil
6}
7func (auth *authService) Logout(token string) error{
8    return nil
9}

在这里我们用authService实现了AuthService接口,这样测试Login,Logout就不再需需要依赖网络请求了。而且我们也可以模拟一些错误的情况进行测试:

 1//auth_test.go
 2//模拟登录失败
 3type authLoginErr struct {
 4    auth AuthService  //可以使用组合的特性,Logout方法我们不关心,只用“覆盖”Login方法即可
 5}
 6func (auth *authLoginErr) Login (username string,password string) (string,error) {
 7    return "", errors.New("用户名密码错误")
 8}
 9
10//模拟api服务器宕机
11type authUnavailableErr struct {
12}
13func (auth *authLoginErr) Login (username string,password string) (string,error) {
14    return "", errors.New("api服务不可用")
15}
16func (auth *authLoginErr) Logout(token string) error{
17    return errors.New("api服务不可用")
18}

Stub示例

Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法。 这是《Go语言圣经》(11.2.3)当中的一个例子: 生产代码:

 1//storage.go
 2//发送邮件
 3var notifyUser = func(username, msg string) { //<--将发送邮件的方法变成一个全局变量
 4    auth := smtp.PlainAuth("", sender, password, hostname)
 5    err := smtp.SendMail(hostname+":587", auth, sender,
 6        []string{username}, []byte(msg))
 7    if err != nil {
 8        log.Printf("smtp.SendEmail(%s) failed: %s", username, err)
 9    }
10}
11//检查quota,quota不足将发邮件
12func CheckQuota(username string) {
13    used := bytesInUse(username)
14    const quota = 1000000000 // 1GB
15    percent := 100 * used / quota
16    if percent < 90 {
17        return // OK
18    }
19    msg := fmt.Sprintf(template, used, percent)
20    notifyUser(username, msg) //<---发邮件
21}

显然,在跑单元测试的过程中,我们肯定不会真的给用户发邮件。在书中采用了stub的方式来进行测试:

 1//storage_test.go
 2func TestCheckQuotaNotifiesUser(t *testing.T) {
 3    var notifiedUser, notifiedMsg string
 4    notifyUser = func(user, msg string) {  //<-看这里就够了,在测试中,覆盖了发送邮件的全局变量
 5        notifiedUser, notifiedMsg = user, msg
 6    }
 7
 8    // ...simulate a 980MB-used condition...
 9
10    const user = "joe@example.org"
11    CheckQuota(user)
12    if notifiedUser == "" && notifiedMsg == "" {
13        t.Fatalf("notifyUser not called")
14    }
15    if notifiedUser != user {
16        t.Errorf("wrong user (%s) notified, want %s",
17            notifiedUser, user)
18    }
19    const wantSubstring = "98% of your quota"
20    if !strings.Contains(notifiedMsg, wantSubstring) {
21        t.Errorf("unexpected notification message <<%s>>, "+
22            "want substring %q", notifiedMsg, wantSubstring)
23    }
24}

可以看到,在Go中,如果要用stub,那将是侵入式的,必须将生产代码设计成可以用stub方法替换的形式。上述例子体现出来的结果就是:为了测试,专门用一个全局变量notifyUser来保存了具有外部依赖的方法。然而在不提倡使用全局变量的Go语言当中,这显然是不合适的。所以,并不提倡这种Stub方式。

Mock与Stub相结合

既然不提倡Stub方式,那是不是在Go测试当中就可以抛弃Stub了呢?原本我是这么认为的,但直到我读了这篇译文Golang 标准包布局[译],虽然这篇译文讲的是包的布局,但里面的测试示例很值得学习。

 1//生产代码 myapp.go
 2package myapp
 3
 4type User struct {
 5    ID      int
 6    Name    string
 7    Address Address
 8}
 9//User的一些增删改查
10type UserService interface {
11    User(id int) (*User, error)
12    Users() ([]*User, error)
13    CreateUser(u *User) error
14    DeleteUser(id int) error
15}

常规Mock方式:

 1//测试代码 myapp_test.go
 2type userService struct{
 3}
 4func (u* userService) User(id int) (*User,error) {
 5    return &User{Id:1,Name:"name",Address:"address"},nil
 6}
 7//..省略其他实现方法
 8
 9//模拟user不存在
10type userNotFound struct {
11    u UserService
12}
13func (u* userNotFound) User(id int) (*User,error) {
14    return nil,errors.New("not found")
15}
16
17//其他...

一般来说,mock结构体内部很少会放变量,针对每一个要模拟的场景(比如上面的user不存在),最政治正确的方法应该是新建一个mock结构体。这样有两个好处:

  1. mock出来的结构体十分简单,不需要进行额外的设置,不容易出错。
  2. mock出来的结构体职责单一,测试代码自说明能力更强,可读性更高。

但在刚才提到的文章中,他是这么做的:

 1//测试代码
 2// UserService 代表一个myapp.UserService.的 mock实现 
 3type UserService struct {
 4    UserFn      func(id int) (*myapp.User, error)
 5    UserInvoked bool
 6
 7    UsersFn     func() ([]*myapp.User, error)
 8    UsersInvoked bool
 9
10    // 其他接口方法补全..
11}
12
13// User调用mock实现, 并标记这个方法为已调用
14func (s *UserService) User(id int) (*myapp.User, error) {
15    s.UserInvoked = true
16    return s.UserFn(id)
17}

这里不仅实现了接口,还通过在结构体内放置与接口方法函数签名一致的方法(UserFn UsersFn ...),以及XxxInvoked是否调用标识符来追踪方法的调用情况。这种做法其实将mock与stub相结合了起来:在mock对象的内部放置了可以被测试函数替换的函数变量UserFn UsersFn…)。我们可以在我们的测试函数中,根据测试的需要,手动更换函数实现:

 1//mock与stub结合的方式
 2func TestUserNotFound(t *testing.T) {
 3    userNotFound := &UserService{}
 4    userNotFound.UserFn = func(id int) (*myapp.User, error) { //<---自己实现UserFn的实现
 5        return nil,errors.New("not found")
 6    }
 7    //后续业务测试代码...
 8
 9    if !userNotFound.UserInvoked {
10        t.Fatal("没有调用User()方法")
11    }
12}
1//传统的mock方式
2func TestUserNotFound(t *testing.T) {
3    userNotFound := &userNotFound{} //<---结构体方法已经决定了返回值
4    //后续业务测试代码
5}

通过将mock与stub结合,不仅能在测试方法中动态的更改实现,还追踪方法的调用情况,上述例子中只是追踪了方法是否被调用,实际中,如果有需要,我们也可以追踪方法的调用次数,甚至是方法的调用顺序:

 1type UserService struct {
 2    UserFn      func(id int) (*myapp.User, error)
 3    UserInvoked bool
 4    UserInvokedTime int //<--追踪调用次数
 5
 6
 7    UsersFn     func() ([]*myapp.User, error)
 8    UsersInvoked bool
 9
10    // 其他接口方法补全..
11
12    FnCallStack []string //<---函数名slice,追踪调用顺序
13}
14
15// User调用mock实现, 并标记这个方法为已调用
16func (s *UserService) User(id int) (*myapp.User, error) {
17    s.UserInvoked = true
18    s.UserInvokedTime++ //<--调用发次数
19    s.FnCallStack = append(s.FnCallStack,"User") //调用顺序
20    return s.UserFn(id)
21}

但同时,我们也会发现我们的mock结构体更复杂了,维护成本也随之增加了。两种mock风格各有各的好处,反正要记得软件工程没有银弹,合适的场景选用合适的方法就行了。 但总体而言,mock与stub相结合的这种方式的确是一种不错的测试思路,尤其是当我们需要追踪函数是否调用,调用次数,调用顺序等信息时,mock+stub将是我们的不二选择。举个例子:

 1//缓存依赖
 2type Cache interface{
 3    Get(id int) interface{} //获取某id的缓存 
 4    Put(id int,obj interface{}) //放入缓存
 5}
 6
 7//数据库依赖
 8type UserRepository interface{
 9    //....
10}
11//User结构体
12type User struct {
13    //...
14}
15//userservice
16type UserService interface{
17    cache Cache 
18    repository UserRepository
19}
20
21func (u *UserService) Get(id int) *User {
22    //先从缓存找,缓存找不到在去repository里面找
23}
24
25func main() {   
26    userService := NewUserService(xxx) //注入一些外部依赖
27    user := userService.Get(2) //获取id = 2的user
28}

现在要测试userService.Get(id)方法的行为:

  1. Cache命中之后是否还查数据库?(不应该再查了)
  2. Cache未命中的情况下是否会查库?

这种测试通过mock+stub结合做起来将会非常方便,作为小练习,可以尝试自己实现一下。

我自己的实践

为了实践单元测试,我将之前写的一个MQTT服务器重构了一遍。在重构的过程中,发现该项目代码的特点介于 **3.依赖很少的复杂代码 ** 和 4.依赖很多又很复杂 之间。一个TCP服务肯定对net.Listenernet.Conn有很强的依赖,所以测试的重点就是如何破除这两个依赖,将其模拟出来。 由于之前的MQTT服务已经在正式服务器跑了比较长的一段时间,一直都以为没什么大问题了,但在做了单元测试才发现原来还有那么多的潜在bug没有发现,也是为自己捏了一把汗,这也再度说明了单元测试的重要性。第一次做单元测试,编写边摸索,将近用了写业务代码3倍的时间才完成,但效果还是很显著的。相信随着熟练度增加,后续编写测试代码的时间也会慢慢降下来。 最近将重构后的MQTT服务器放上了Github:https://github.com/DrmagicE/gmqtt 其中对net.Listenernet.Conn的Mock主要集中在client_test.go文件中,其中内容也是参考了官方库的 net/http/server_test.go文件中的Mock方法。还请各位大神多多指教,欢迎拍砖。


版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。

原文发布于微信公众号 - Golang语言社区(Golangweb)

原文发表时间:2018-10-10

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏VMCloud

【解析向】腾讯云的Windows Server日志配置收集工具是个什么鬼?(5)

且听笔者一句劝,无论是Windows还是Linux抑或是Unix都是大部分理论是相同的,与其与人争吵对比这几个平台之类的差异,不如好好研究下底层的各个子系统...

2527
来自专栏从零开始学 Web 前端

代码阅读——十个C开源项目

Webbench是一个在linux下使用的非常简单的网站压测工具。它使用fork()模拟多个客户端同时访问我们设定的URL,测试网站在压力下工作的性能,最多可以...

4323
来自专栏王清培的专栏

记5.28大促压测的性能优化—线程池相关问题

目录: 1.环境介绍 2.症状 3.诊断 4.结论 5.解决 6.对比java实现 废话就不多说了,本文分享下博主在5.28大促压测期间解决的一个性能问题,觉得...

2377
来自专栏hadoop学习笔记

菜鸟如何使用hanlp做分词的过程记录

最近在学习hanlp的内容,准备在节后看看有没有时间整理一波hanlp分享下,应该还是会像之前分享DKHadoop一样的方式吧。把整个学习的过程中截图在配文字的...

1284
来自专栏程序员的知识天地

这一堆初中生写的类库、框架,让一群中年程序员坐不住了!

前不久在V2EX上看到一个帖子,主题是「一堆初中生写的类库、框架」,原本猿妹以为只是个标题党不以为意。点进去该博主的博客主页发现,博主确实是一名初中生,而且他的...

1323
来自专栏即时通讯技术

微信自用高性能通用key-value组件MMKV已开源!

腾讯微信团队于2018年9月底宣布开源 MMKV ,这是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,...

1422
来自专栏IT大咖说

漫谈Android组件化及Web化

内容来源:2018 年 04 月 14 日,高级Android工程师陈家伟在“2018互联网开发者大会”进行《漫谈Android组件化及Web化》演讲分享。IT...

1445
来自专栏工科狗和生物喵

计算机操作系统概念初解

一、存储系统 在计算机系统中存储层次可分为,处理器上的寄存器、高速缓冲存储器、主存储器(内存)、辅助存储器(外存)四级。高速缓冲存储器用来改善主存储器与中央处理...

3548
来自专栏七夜安全博客

(原创)python爬取慕课网视频

2604
来自专栏移动端周边技术扩展

移动端常用数据库

2734

扫码关注云+社区

领取腾讯云代金券