51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

手机号码,快捷登录

查看: 980|回复: 0
打印 上一主题 下一主题

[原创] 带你走进Golang 单元测试(上)

[复制链接]

该用户从未签到

跳转到指定楼层
1#
发表于 2022-11-2 15:38:06 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
引入



  随着工程化开发在司内大力的推广,单元测试越来越受到广大开发者的重视。在学习的过程中,发现网上针对 Golang 单元测试大多从理论角度出发介绍,缺乏完整的实例说明,晦涩难懂的 API 让初学接

触者难以下手。

  本篇不准备大而全的谈论单元测试、笼统的介绍 Golang 的单测工具,而将从 Golang 单测的使用场景出发,以最简单且实际的例子讲解如何进行单测,最终由浅入深探讨 go 单元测试的两个比较细节的


问题。

  在阅读本文时,请务必对 Golang 的单元测试有最基本的了解。


  一段需要单测的 Golang 代码


  package unit

  import (


   "encoding/json"


   "errors"


   "github.com/gomodule/redigo/redis"


   "regexp"


  )


  type PersonDetail struct {


   Username string `json:"username"`


   Email    string `json:"email"`


  }


  // 检查用户名是否非法


  func checkUsername(username string) bool {


   const pattern = `^[a-z0-9_-]{3,16}$`


   reg := regexp.MustCompile(pattern)


   return reg.MatchString(username)


  }


  // 检查用户邮箱是否非法


  func checkEmail(email string) bool {


   const pattern = `^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$`


   reg := regexp.MustCompile(pattern)


   return reg.MatchString(email)


  }


  // 通过 redis 拉取对应用户的资料信息


  func getPersonDetailRedis(username string) (*PersonDetail, error) {


   result := &PersonDetail{}


   client, err := redis.Dial("tcp", ":6379")


   defer client.Close()


   data, err := redis.Bytes(client.Do("GET", username))


   if err != nil {


    return nil, err


   }


   err = json.Unmarshal(data, result)


   if err != nil {


    return nil, err


   }



   return result, nil

  }


  // 拉取用户资料信息并校验


  func GetPersonDetail(username string) (*PersonDetail, error) {


   // 检查用户名是否有效


   if ok := checkUsername(username); !ok {


    return nil, errors.New("invalid username")


   }


   // 从 redis 接口获取信息


   detail, err := getPersonDetailRedis(username)


   if err != nil {


    return nil, err


   }


   // 校验


   if ok := checkEmail(detail.Email); !ok {


    return nil, errors.New("invalid email")


   }


   return detail, nil


  }


  这是一段典型的有 I/O 的功能代码,主体功能是传入用户名,校验合法性之后通过 redis 获取信息,之后校验获取值内容的合法性后并返回。


  后台服务单测场景


  对于一个传统的后端服务,它主要有以下几点的职责和功能:

  ·接收外部请求,controller 层分发请求、校验请求参数


  · 请求有效分发后,在 service 层与 dao 层进行交互后做逻辑处理


  · dao 层负责数据操作,主要是数据库或持久化存储相关的操作


  因此,从职责出发来看,在做后台单测中,核心主要是验证 service 层和 dao 层的相关逻辑,此外 controller 层的参数校验也在单测之中。


  细分来看,对于相关逻辑的单元测试,笔者倾向于把单测分为两种:


  · 无第三方依赖,纯逻辑代码


  · 有第三方依赖,如文件、网络 I/O、第三方依赖库、数据库操作相关的代码


  注:单元测试中只是针对单个函数的测试,关注其内部的逻辑,对于网络/数据库访问等,需要通过相应的手段进行 mock。


  Golang 单测工具选型


  由于我们把单测简单的分为了两种:

  对于无第三方依赖的纯逻辑代码,我们只需要验证相关逻辑即可,这里只需要使用 assert(断言),通过控制输入输出比对结果即可。


  对于有第三方依赖的代码,在验证相关代码逻辑之前,我们需要将相关的依赖 mock(模拟),之后才能通过断言验证逻辑。这里需要借助第三方工具库来处理。


  因此,对于 assert **(断言)**工具,可以选择 testify 或 convery,笔者这里选择了 testify。对于 mock **(模拟)**工具,笔者这里选择了 gomock 和 gomonkey。关于 mock 工具同时使用



gomock 和 gomonkey,这里跟 Golang 的语言特性有关,下面会详细的说明。

  完善测试用例


  这里我们开始对示例代码中的函数做单元测试。

  生成单测模板代码


  首先在 Goland 中打开项目,加载对应文件后右键找到 Generate 项,点击后选择 Tests for package,之后生成以 _test.go 结尾的单测文件。(如果想针对某一特定函数做单测,请选择对应的函数后右


键选定 Generate 项执行 Tests for selection。)

  这里展示通过 IDE 生成的 TestGetPersonDetail 测试函数:


  package unit


  import (


    "reflect"


    "testing"


  )


  func TestGetPersonDetail(t *testing.T) {


   type args struct {


    username string


   }


   tests := []struct {


    name    string


    args    args


    want    *PersonDetail


    wantErr bool


   }{


    // TODO: Add test cases.


   }


   for _, tt := range tests {


    t.Run(tt.name, func(t *testing.T) {


     got, err := GetPersonDetail(tt.args.username)


     if (err != nil) != tt.wantErr {


      t.Errorf("GetPersonDetail() error = %v, wantErr %v", err, tt.wantErr)


      return


     }


     if !reflect.DeepEqual(got, tt.want) {


      t.Errorf("GetPersonDetail() got = %v, want %v", got, tt.want)


     }


    })


   }


  }


  由 Goland 生成的单测模板代码使用的是官方的 testing 框架,为了更方便的断言,我们把 testing 改造成 testify 的断言方式。


  这里其实只需要引入 testify 后修改 test 函数最后的断言代码即可,这里我们以 TestGetPersonDetail 为例子,其他函数不赘述。


  package unit


  import (


    "github.com/stretchr/testify/assert" // 这里引入了 testify


    "reflect"


    "testing"


  )


  func TestGetPersonDetail(t *testing.T) {


   type args struct {


    username string


   }


   tests := []struct {


    name    string


    args    args


    want    *PersonDetail


    wantErr bool


   }{


    // TODO: Add test cases.


   }


   for _, tt := range tests {


    got, err := GetPersonDetail(tt.args.username)


    // 改写这里断言的方式即可


    assert.Equal(t, tt.want, got)


    assert.Equal(t, tt.wantErr, err != nil)


   }


  }




  分析代码生成测试用例


  对 checkUsername 、 checkEmail 纯逻辑函数编写测试用例,这里以 checkEmail 为例。

  func Test_checkEmail(t *testing.T) {


   type args struct {


    email string


   }


   tests := []struct {


    name string


    args args


    want bool


   }{


    {


     name: "email valid",


     args: args{


      email: "1234567@qq.com",


     },


     want: true,


    },


    {


     name: "email invalid",


     args: args{


      email: "test.com",


     },


     want: false,


    },


   }


   for _, tt := range tests {


    got := checkEmail(tt.args.email)


    assert.Equal(t, tt.want, got)


   }


  }




  使用 gomonkey 打桩


  对于 GetPersonDetail 函数而言,该函数调用了 getPersonDetailRedis 函数获取具体的 PersonDetail 信息。为此,我们需要为它打一个“桩”。

  所谓的“桩”,也叫做“桩代码”,是指用来代替关联代码或者未实现代码的代码。


  // 拉取用户资料信息并校验


  func GetPersonDetail(username string) (*PersonDetail, error) {


   // 检查用户名是否有效


   if ok := checkUsername(username); !ok {



    return nil, errors.New("invalid username")

   }


   // 从 redis 接口获取信息


   detail, err := getPersonDetailRedis(username)


   if err != nil {


    return nil, err


   }


   // 校验


   if ok := checkEmail(detail.Email); !ok {


    return nil, errors.New("invalid email")


   }


   return detail, nil


  }


  从 GetPersonDetail 函数可见,为了能够完全覆盖该函数,我们需要控制 getPersonDetailRedis 函数不同的输出来保证后续代码都能够被覆盖运行到。因此,这里需要使用 gomonkey 来给


getPersonDetailRedis 函数打一个“桩序列”。

  所谓的函数“桩序列”指的是提前指定好调用函数的返回值序列,当该函数多次调用时候,能够按照原先指定的返回值序列依次返回。


  func TestGetPersonDetail(t *testing.T) {


   type args struct {


    username string


   }


   tests := []struct {


    name    string


    args    args


    want    *PersonDetail


    wantErr bool


   }{


    {name: "invalid username", args: args{username: "steven xxx"}, want: nil, wantErr: true},


    {name: "invalid email", args: args{username: "invalid_email"}, want: nil, wantErr: true},


    {name: "throw err", args: args{username: "throw_err"}, want: nil, wantErr: true},


    {name: "valid return", args: args{username: "steven"}, want: &PersonDetail{Username: "steven", Email: "12345678@qq.com"}, wantErr: false},


   }


   // 为函数打桩序列


   // 使用 gomonkey 打函数桩序列


   // 第一个用例不会调用 getPersonDetailRedis,所以只需要 3 个值


   outputs := []gomonkey.OutputCell{


    {


     Values: gomonkey.Params{&PersonDetail{Username: "invalid_email", Email: "test.com"}, nil},


    },


    {


     Values: gomonkey.Params{nil, errors.New("request err")},


    },


    {


     Values: gomonkey.Params{&PersonDetail{Username: "steven", Email: "12345678@qq.com"}, nil},


    },


   }


   patches := gomonkey.ApplyFuncSeq(getPersonDetailRedis, outputs)


   // 执行完毕后释放桩序列


   defer patches.Reset()


   for _, tt := range tests {


    got, err := GetPersonDetail(tt.args.username)


    assert.Equal(t, tt.want, got)


    assert.Equal(t, tt.wantErr, err != nil)


   }


  }


  当使用桩序列时,要分析好单元测试用例和序列值的对应关系,保证最终被测试的代码块都能被完整覆盖。




分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
收藏收藏
回复

使用道具 举报

本版积分规则

关闭

站长推荐上一条 /1 下一条

小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

GMT+8, 2024-11-26 05:09 , Processed in 0.065211 second(s), 23 queries .

Powered by Discuz! X3.2

© 2001-2024 Comsenz Inc.

快速回复 返回顶部 返回列表