Go单元测试培训


一、如何写好单元测试

单元测试(Unit Tests, UT) 是一个优秀项目不可或缺的一部分,特别是在一些频繁变动和多人合作开发的项目中尤为重要。或多或少都会有因为自己的提交,导致应用挂掉或服务宕机的经历。如果这个时候你的修改导致测试用例失败,你再重新审视自己的修改,发现之前的修改还有一些特殊场景没有包含,恭喜你减少了一次上库失误。也会有这样的情况,项目很大,启动环境很复杂,你优化了一个函数的性能,或是添加了某个新的特性,如果部署在正式环境上之后再进行测试,成本太高。对于这种场景,几个小小的测试用例或许就能够覆盖大部分的测试场景。而且在开发过程中,效率最高的莫过于所见即所得了,单元测试也能够帮助你做到这一点,试想一下,假如你一口气写完一千行代码,debug 的过程也不会轻松,如果在这个过程中,对于一些逻辑较为复杂的函数,同时添加一些测试用例,即时确保正确性,最后集成的时候,会是另外一番体验。

如何写好单元测试呢?

首先,学会写测试用例。比如如何测试单个函数/方法;比如如何做基准测试;比如如何写出简洁精炼的测试代码;再比如遇到数据库访问等的方法调用时,如何 mock

然后,写可测试的代码。高内聚,低耦合是软件工程的原则,同样,对测试而言,函数/方法写法不同,测试难度也是不一样的。职责单一,参数类型简单,与其他函数耦合度低的函数往往更容易测试。我们经常会说,“这种代码没法测试”,这种时候,就得思考函数的写法可不可以改得更好一些。为了代码可测试而重构是值得的。

接下来将介绍如何使用 Go 语言的标准库 testing 进行单元测试。


二、Go单元测试框架介绍

gotest 详情参考地址(http://c.biancheng.net/view/124.html)

goconvey 详情参考地址(https://github.com/smartystreets/goconvey)

gomock 详情参考地址(https://github.com/golang/mock)


三、Go工作目录介绍

Go单元测试培训 - 图1

Go单元测试培训 - 图2

Go单元测试培训 - 图3


四、Gotesting介绍

  1. Go 语言推荐测试文件和源代码文件放在一块,测试文件以 *_test.go 结尾。比如,当前 package 有 response.go 一个文件,我们想测试 response.go 中的 response 函数,那么应该新建 response_test.go作为测试文件。
  1. 测试目录及文件
  1. utils
  2. ├── response
  3. ├── response.go
  4. └── response_test.go
  1. 相应的response.go文件代码如下:
// Title  response.go
// Description  统一返回值包
// Author  jiangxincan@hatech.com.cn  2021/1/22 11:36
// update  jiangxincan@hatech.com.cn  2021/1/22 11:36
package response

import (
    json "encoding/json"
    "errors"
    log "github.com/sirupsen/logrus"
)

const (
    requestSuccess = 200 // 成功返回状态码
    requestError   = 500 // 失败返回状态码

    operateSuccessMessage = "操作成功" // 成功返回信息
    operateErrorMessage   = "操作失败" // 失败返回信息
)

// Title            统一返回结果结构体
// Description       统一返回结果结构体
// Auth              jiangxincan@hatech.com.cn         时间(2021/1/22 11:36)
type Result struct {
    Code    int         `json:"code" example:"200"`
    Message string      `json:"message" example:"操作成功"`
    Count   int         `json:"count" example:"1"`
    Data    interface{} `json:"data" example:"nil"`
}

// Title            编码解析函数
// Description       application/json编码解析函数
// Auth              jiangxincan@hatech.com.cn             时间(2021/1/22 11:36)
// Param            err                     error        "异常对象"
// Return            message                    string         "返回消息说明"
func message(err error) string {
    if err != nil {
        return operateErrorMessage + ": " + err.Error()
    }
    return operateSuccessMessage
}

// Title            统一返回成功信息日志记录函数(主要是分页)
// Description       统一返回成功信息日志记录函数(主要是分页)
// Auth              jiangxincan@hatech.com.cn                 时间(2021/1/22 11:36)
// Param            count                     int                "异常对象"
// Param            data                    interface{}     "结果对象"
func ResultSuccessManyAndCount(count int, data interface{}) *Result {
    return &Result{requestSuccess, message(nil), count, data}
}
  1. 那么response_test.go测试文件代码如下:
// @Title  请填写文件名称(需要改)
// @Description  请填写文件描述(需要改)
// @Author  xincan  2021/2/7 16:10
// @Update  xincan  2021/2/7 16:10
package goconvey

import (
   . "github.com/smartystreets/goconvey/convey"
   "hatech.com.cn/istorm-cnbr-operator/utils"
   "hatech.com.cn/istorm-cnbr-operator/utils/response"
   "hatech.com.cn/istorm-cnbr-operator/vo"
   "testing"
)

var ktv = &vo.KubernetesVo {
   Result: 2,
}

type UrlInfo struct {
   Res  http.ResponseWriter
   Req  *http.Request
   Vars map[string]string
}

// go test 测试
func TestUtilsResponse(t *testing.T) {
       res := response.ResultSuccessManyAndCount(1, ktv)
    data := (res.Data).(*vo.KubernetesVo)
    if data.Result == ktv.Result {
        log.Printf(" 测试成功:统一返回成功信息日志记录函数(主要是分页)。data.Result != ktv.Result != %v", data.Result)
    }
}
  1. 测试结果
=== RUN   TestMockResponseFromKubernetesService
time="2021-02-23T15:27:33+08:00" level=info msg=" 测试成功:统一返回成功信息日志记录函数(主要是分页)。data.Result == ktv.Result == 2"

0 total assertions

--- PASS: TestMockResponseFromKubernetesService (0.01s)
PASS

Process finished with exit code 0

  1. gotest几种用法,run方法后面是正则表达式(不区分大小写) ```css 第一种:测试该目录下所有包含*_test.go文件 go test

第一种:指定要测试的文件 go test -v -run=kubernetes-response_test.go

第三种:指定文件中的某一个方法,可以模糊匹配,可以多个方法同时测试 go test -v -run=方法1 go test -v -run=方法1|方法2

第三种:如果文件夹下包含其他文件夹,可以添加“./…”做递归测试 go test -v ./…

第四种:测试代码覆盖率 go test -v -cover

第五种:测试覆盖率(测试文件要和业务文件放到一起,通常情况下不会用到) go test -v -coverprofile cover.out user_test.go user.go go tool cover -html=cover.out -o cover.html



7. 
以上是gotest的基础用法,还有高级用法,如BenchMark测试,篇幅和时间原因先介绍这里


<a name="f7fa2605"></a>
#### **五、Goconvey安装介绍**

1. 安装命令

```css
go get github.com/smartystreets/goconvey
  1. 基本使用方法
// @Title  单元测试
// @Description  针对XXXXXX进行单元测试
// @Author  xincan  2021/2/23 16:10
// @Update  xincan  2021/2/23 16:10
package goconvey

import (
   . "github.com/smartystreets/goconvey/convey"
)

func TestMockResponseFromKubernetesService(t *testing.T) {

   Convey("测试内容一说明", t, func() {

       Convey("测试内容二说明", func() {
           So(Object.Id, ShouldEual, "1")
           So(Object.Name, ShouldEual, "张三")
       })

       Convey("测试内容三说明", func() {
            ......
       })

       Convey("测试内容四说明", func() {
             Convey("测试内容四,第一分支说明", func() {
                ......
              })
             Convey("测试内容四,第二分支说明", func() {
                ......
             })
      })
   })
}
  1. Goconvey So 部分判断条件说明

Go单元测试培训 - 图4


六、GoMock安装介绍

  1. 在Golang的官方Repo(https://github.com/golang/)中有一个单独的工程叫”mock”(https://github.com/golang/mock),虽然star不是特别多,但它却是Golang官方放出来的mock工具。gomock主要是针对我们go代码中的接口进行mock的。
go get github.com/golang/mock/gomock
go get github.com/golang/mock/mockgen
  1. 如何你设置过$GOPATH/bin到你的$PATH变量中,那么这里就可以直接运行mockgen命令了,否则需要使用绝对路径或者相当于$GOPATH的目录。

  2. 准备好代码,在工程下创建自定义测试文件夹goconvey,通过mockgen命令生成*_mock.go文件执行命令为:

mockgen -source=kubernetes/service/kubernetesService.go -destination=goconvey/kubernetes_mock.go -package=goconvey
  1. 创建*_test.go文件(kubernetes-service_test.go)

  2. 最终测试文件目录如下:

goconvey
├── kubernetes_mock.go
└── kubernetes-service_test.go
kubernetes
├── service
    └──  kubernetesService.go
  1. kubernetesService.go业务代码如下:
// Title  kubernetesService.go
// Description  kubernetesService服务层
// Author  jiangxincan@hatech.com.cn  2021/1/22 11:36
// update  jiangxincan@hatech.com.cn  2021/1/22 11:36
package service

import (
   "errors"
   "github.com/sirupsen/logrus"
   "hatech.com.cn/istorm-cnbr-operator/vo"
   "log"
)

// Title          kubernetesService服务接口
// Description     application/json编码解析函数
// Auth           jiangxincan@hatech.com.cn         时间(2021/1/22 11:36)
type IKubernetesService interface {
   // 加法运算
   Add(num1 int, num2 int) (*vo.KubernetesVo, error)
   // 减法运算
   Sub(num1 int, num2 int) (*vo.KubernetesVo, error)
   // 乘法运算
   Mul(num1 int, num2 int) (*vo.KubernetesVo, error)
   // 除法运算
   Div(num1 int, num2 int) (*vo.KubernetesVo, error)
   // 平均值运算
   Ave(num1 int, num2 int) (*vo.KubernetesVo, error)
}

// Title          kubernetesService服务实现结构体
// Description     kubernetesService服务实现结构体
// Auth           jiangxincan@hatech.com.cn         时间(2021/1/22 11:36)
type kubernetesService struct {
}

func KubernetesServiceImpl() *kubernetesService {
   return &kubernetesService{}
}

// Title             加法运算方法
// Description        加法运算
// Auth              jiangxincan@hatech.com.cn                时间(2021/1/22 11:36)
// Return            kubernetesVo           *vo.KubernetesVo     "返回kubernetesVo结构体"
func (service *kubernetesService) Add(num1 int, num2 int) (*vo.KubernetesVo, error) {
   log.Printf("service: num1: %v, num2: %v", num1, num2)
   return &vo.KubernetesVo{Result: num1 + num2}, nil
}

// Title             减法运算方法
// Description        减法运算
// Auth              jiangxincan@hatech.com.cn                时间(2021/1/22 11:36)
// Return            kubernetesVo           *vo.KubernetesVo     "返回kubernetesVo结构体"
func (service *kubernetesService) Sub(num1 int, num2 int) (*vo.KubernetesVo, error) {
   return &vo.KubernetesVo{Result: num1 - num2}, nil
}

// Title             乘法运算方法
// Description        乘法运算
// Auth              jiangxincan@hatech.com.cn                时间(2021/1/22 11:36)
// Return            kubernetesVo           *vo.KubernetesVo     "返回kubernetesVo结构体"
func (service *kubernetesService) Mul(num1 int, num2 int) (*vo.KubernetesVo, error) {
   return &vo.KubernetesVo{Result: num1 * num2}, nil
}

// Title             除法法运算方法
// Description        除法运算
// Auth              jiangxincan@hatech.com.cn                时间(2021/1/22 11:36)
// Return            kubernetesVo           *vo.KubernetesVo     "返回kubernetesVo结构体"
func (service *kubernetesService) Div(num1 int, num2 int) (v *vo.KubernetesVo, err error) {
   logrus.WithFields(logrus.Fields{"num1": num1, "num2": num2}).Info("求两个数的商", ": ", "除数不能为0")
   if num2 == 0 {
      return nil, errors.New("除数不能为0")
   }
   return &vo.KubernetesVo{Result: num1 / num2}, nil
}

// Title             平均值运算方法
// Description        平均值运算
// Auth              jiangxincan@hatech.com.cn                时间(2021/1/22 11:36)
// Return            kubernetesVo           *vo.KubernetesVo     "返回kubernetesVo结构体"
func (service *kubernetesService) Ave(num1 int, num2 int) (*vo.KubernetesVo, error) {
   return &vo.KubernetesVo{Result: (num1 + num2) / 2}, nil
}
  1. kubernetes-service_test.go测试文件代码如下:
// @Title  请填写文件名称(需要改)
// @Description  请填写文件描述(需要改)
// @Author  xincan  2021/2/7 16:10
// @Update  xincan  2021/2/7 16:10
package goconvey

import (
   "errors"
   _ "github.com/gavv/httpexpect/v2"
   "github.com/golang/mock/gomock"
   log "github.com/sirupsen/logrus"
   . "github.com/smartystreets/goconvey/convey"
   "hatech.com.cn/istorm-cnbr-operator/dto"
   "hatech.com.cn/istorm-cnbr-operator/vo"
   "testing"
)

var param = dto.KubernetesDto {
   Num1: 36,
   Num2: 4,
}

var rest = struct {
   addRes     int
   subRes     int
   mulRes     int
   divHaveRes int
   divEqRes   int
   aveRes     int
}{
   addRes:     40,
   subRes:     32,
   mulRes:     144,
   divHaveRes: 9,
   divEqRes:   0,
   aveRes:     20,
}

// mock service 测试
func TestMockServiceKubernetesService(t *testing.T) {

   ctrl := gomock.NewController(t)
   mock := NewMockIKubernetesService(ctrl)

   Convey("两个数处理,单元测试--service", t, func() {

      Convey("将两数相加", func() {
         mock.EXPECT().Add(param.Num1, param.Num2).Return(&vo.KubernetesVo{Result: rest.addRes}, nil)
         data, _ := mock.Add(param.Num1, param.Num2)
         if data.Result == rest.addRes {
            log.Printf("测试成功:两个数相加。结果为:%v + %v = %v", param.Num1, param.Num2, data.Result)
         }
      })

      Convey("将两数相减", func() {
         mock.EXPECT().Sub(param.Num1, param.Num2).Return(&vo.KubernetesVo{Result: rest.subRes}, nil)
         data, _ := mock.Sub(param.Num1, param.Num2)
         if data.Result == rest.subRes {
            log.Printf("测试成功:两个数相减。结果为:%v - %v = %v", param.Num1, param.Num2, data.Result)
         }
      })

      Convey("将两数相乘", func() {
         mock.EXPECT().Mul(param.Num1, param.Num2).Return(&vo.KubernetesVo{Result: rest.mulRes}, nil)
         data, _ := mock.Mul(param.Num1, param.Num2)
         if data.Result == rest.mulRes {
            log.Printf("测试成功:两个数相乘。结果为:%v * %v = %v", param.Num1, param.Num2, data.Result)
         }
      })

      Convey("将两数相除", func() {

         Convey("将两数相除,除数不为:0", func() {
            mock.EXPECT().Div(param.Num1, param.Num2).Return(&vo.KubernetesVo{Result: rest.divHaveRes}, nil)
            data, _ := mock.Div(param.Num1, param.Num2)
            if data.Result == rest.divHaveRes {
               log.Printf("测试成功:两个数相除,除数不为:0。结果为:%v ➗ %v = %v", param.Num1, param.Num2, data.Result)
            }
         })

         Convey("将两数相除,除数是为:0", func() {
            mock.EXPECT().Div(param.Num1, 0).Return(&vo.KubernetesVo{Result: 0}, errors.New("除数不能为0"))
            _, err := mock.Div(param.Num1, 0)
            if err.Error() == "除数不能为0" {
               log.Print("测试成功:两个数相除,除数为:0。目前除数为:", 0)
            }
         })

      })

      Convey("求两个数的平均值", func() {
         mock.EXPECT().Ave(param.Num1, param.Num2).Return(&vo.KubernetesVo{Result: rest.aveRes}, nil)
         data, _ := mock.Ave(param.Num1, param.Num2)
         if data.Result == rest.aveRes {
            log.Printf("测试成功:求两个数的平均值。结果为:(%v + %v) / 2 = %v", param.Num1, param.Num2, data.Result)
         }
      })

   })

}
  1. idea测试工具测试结果如下:
=== RUN   TestMockServiceKubernetesService
time="2021-02-23T15:46:36+08:00" level=info msg="测试成功:两个数相加。结果为:36 + 4 = 40"
time="2021-02-23T15:46:36+08:00" level=info msg="测试成功:两个数相减。结果为:36 - 4 = 32"
time="2021-02-23T15:46:36+08:00" level=info msg="测试成功:两个数相乘。结果为:36 * 4 = 144"
time="2021-02-23T15:46:36+08:00" level=info msg="测试成功:两个数相除,除数不为:0。结果为:36 ➗ 4 = 9"
time="2021-02-23T15:46:36+08:00" level=info msg="测试成功:两个数相除,除数为:0。目前除数为:0"
time="2021-02-23T15:46:36+08:00" level=info msg="测试成功:求两个数的平均值。结果为:(36 + 4) / 2 = 20"

0 total assertions

--- PASS: TestMockServiceKubernetesService (0.02s)
PASS

Process finished with exit code 0
  1. goconvey测试结果如下:
2021/02/23 15:47:59 goconvey.go:61: Initial configuration: [host: 127.0.0.1] [port: 8080] [poll: 250ms] [cover: true]
2021/02/23 15:47:59 tester.go:19: Now configured to test 10 packages concurrently.
2021/02/23 15:47:59 goconvey.go:178: Serving HTTP at: http://127.0.0.1:8080
2021/02/23 15:47:59 goconvey.go:105: Launching browser on 127.0.0.1:8080
2021/02/23 15:47:59 integration.go:122: File system state modified, publishing current folders... 0 9684099642
2021/02/23 15:47:59 goconvey.go:118: Received request from watcher to execute tests...
2021/02/23 15:47:59 goconvey.go:111: exec: "start": executable file not found in %PATH%
2021/02/23 15:47:59 goconvey.go:113:
2021/02/23 15:48:00 executor.go:69: Executor status: 'executing'
2021/02/23 15:48:00 coordinator.go:46: Executing concurrent tests: hatech.com.cn/istorm-cnbr-operator/goconvey
2021/02/23 15:48:01 parser.go:24: [passed]: hatech.com.cn/istorm-cnbr-operator/goconvey
2021/02/23 15:48:01 executor.go:69: Executor status: 'idle'

Go单元测试培训 - 图5


七、总结规范

1. 测试文件必须是业务`文件名称_test.go`结尾
2. 必要时,将测试文件放到业务文件同级文件夹下
3. 测试文件中的方法或函数必须是`Test*.go`开头
4. 测试代码块必须以Convey()函数进行包裹,内部多个分支用Convey()进行隔开
5. 测试文件中的函数可以是多个,也可以是单个,视情况而定

八、Idea测试展示

Go单元测试培训 - 图6

如图:针对kubernetes-endpoint_test.go进行测试,覆盖率是100%

Go单元测试培训 - 图7

如图:针对覆盖率,在每个被测试的文件后面都有测试比率,利用idea提供的遍历,可以进行文件测试和查看分析

Go单元测试培训 - 图8

如图:最终除了dto,vo之外的业务代码全部覆盖