草帽路飞UU 发表于 2022-11-2 15:38:06

带你走进Golang 单元测试(上)

引入



  随着工程化开发在司内大力的推广,单元测试越来越受到广大开发者的重视。在学习的过程中,发现网上针对 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 = `^{3,16}$`

   reg := regexp.MustCompile(pattern)

   return reg.MatchString(username)

  }

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

  func checkEmail(email string) bool {

   const pattern = `^+@+(\.+)+$`

   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)

   }

  }

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




页: [1]
查看完整版本: 带你走进Golang 单元测试(上)