手把手教你如何进行 Golang 单元测试(上)

发表于:2022-11-01 09:39

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:stevennzhou    来源:知乎

  引入
  随着工程化开发在司内大力的推广,单元测试越来越受到广大开发者的重视。在学习的过程中,发现网上针对 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)
   }
  }
  当使用桩序列时,要分析好单元测试用例和序列值的对应关系,保证最终被测试的代码块都能被完整覆盖。
  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号