golang单元测试及mock总结

发表于:2024-2-01 09:25

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

 作者:铁柱同学    来源:稀土掘金

  一、前言
  1、单测的定位
  单测在软件工程中的地位毋庸置疑,它要求工程师必须去主动思考代码的边界,异常处理等等。另一方面,它又是代码最好的说明书,你的函数具体做了什么,输入和输出一目了然。
  计算机科学家Edsger Dijkstra曾说过:“测试能证明缺陷存在,而无法证明没有缺陷。”再多的测试也不能证明一个程序没有BUG。在最好的情况下,测试可以增强我们的信心:代码在很多重要场景下是可以正常工作的。
  2、vscode中生成单测
  vscode生成单元测试如下,我们需要编写测试用例数组,明确指出来want结果以及wantErr,通过遍历的方式去执行测试用例数组。
  func TestGenerateStsTokenService(t *testing.T) {
  type args struct {
  ctx             context.Context
  generateStsData *dto.GenerateStsReqParams
  }
  tests := []struct {
  name     string
  args     args
  wantResp *common.RESTResp
  wantErr  bool
  }{
  {
  name: "测试正常生成sts",
  args: args{
  ctx: context.TODO(),
  generateStsData: &dto.GenerateStsReqParams{
  SessionName: "webApp",
  AuthParams:  &dto.AuthParamsData{},
  },
  },
  wantResp: &common.RESTResp{
  Code: 0,
  Data: &dto.OssStsRespData{
  },
  },
  wantErr: false,
  },
  {
  name: "测试异常生成sts",
  args: args{
  ctx: context.TODO(),
  generateStsData: &dto.GenerateStsReqParams{
  SessionName: "liteApp",
  AuthParams:  &dto.AuthParamsData{},
  },
  },
  wantResp: &common.RESTResp{
  Code: 20003,
  Data: interface{}(nil),
  },
  wantErr: true,
  },
  }
  for _, tt := range tests {
  t.Run(tt.name, func(t *testing.T) {
   
  gotResp, err := GenerateStsTokenService(tt.args.ctx, tt.args.generateStsData)
  if (err != nil) != tt.wantErr {
  t.Errorf("GenerateStsTokenService() error = %v, wantErr %v", err, tt.wantErr)
  return
  }
  if !reflect.DeepEqual(gotResp, tt.wantResp) {
  t.Errorf("GenerateStsTokenService() = %v, want %v", gotResp, tt.wantResp)
  }
  })
  }
  }
  二、构造测试case的注意事项
  1、项目初始化
  // TestMain会在执行其他测试用例的时候,自动执行
  func TestMain(m *testing.M) {
      setup()  //初始化函数
      retCode := m.Run() // 运行单元测试
      teardown() //后置校验,钩子函数,可不实现
      os.Exit(retCode) //清理结果
  }
  2、构造空interface{}
  // 直接给Data赋值为nil的话,验证会失败,
  // 单纯的nil和(*infra.QueryOneMappingCode)(nil)是不一样的
  wantResp: &common.RESTResp{
  Code:    0,
  Message: "",
  Data:    (*infra.QueryOneMappingCode)(nil),
  },
  // 数组类型的空
  // []dto.OneMappingCode{}也会验证失败
  wantRes: []dto.OneMappingCode(nil),
  3、构造结构体的time.Time类型
  Data: &infra.xxx{
  ID:          54,
  Code:        "338798",
  TakerUid:    "",
  State:       1,
  Type:        1,
  CreatedAt: time.Date(2023, time.June, 9, 16, 32, 59, 0, time.Local),
  },
  也可以直接打印接口的返回,看看CreatedAt返回的是什么,然后构造一下就可以。
  t.Logf("gotResp:(%#v)", gotResp.Data)
  4、构造json格式的test case
  wantResp: &common.RESTResp{
  Code:    0,
  Message: "success",
  Data: `{
  "id": 54,
  "code": "338798",
  "creator_uid": "12345",
  "client_appId": "1234",
  "taker_uid": "",
  "state": 1,
  "type": 1,
  "created_at": "2023-06-09T16:32:59+08:00"
     }`,
  },
  三、运行单测文件
  1、整体运行单测文件
    cd /xxx 单测目录
    go test
    成功输出:
    PASS
    ok
  2、运行单个单测文件报错
  错误提示如下:
  # command-line-arguments [command-line-arguments.test]
  ./base_test.go:26:18: undefined: Ping
  明明Ping函数和单测文件都在同一个包下面,为什么会出现undefined呢?command-line-arguments是什么?
  答:
  (1)command-line-arguments是什么
  go复制代码go test [flags] [packages] [build flags] [packages]
  命令行参数中指定的每个包或文件都将被视为一个要进行测试的包。而 "command-line-arguments" 
  这个标识符就是用来表示上述情况中命令行参数中指定的文件。
  这样可以使 go test 命令将指定的文件作为单独的包进行处理,并执行其中的测试函数。
  (2)undefined发生原因
  错误提示build失败,也就是说我们需要把单测文件依赖的文件也传入进去。比如我这里单测base_test.go文件,则需要把base.go也写到命令行参数中。
  go test ./base.go ./base_test.go
  (3)缺少初始化导致的发生panic
  一般来说我们在一个package下,定义一个TestMain()函数就可以了,进行代码的初始化。但是当我们需要运行单个测试文件的时候,有可能这个测试文件里面恰好没有TestMain()了咋整。
  api_test.go
  TestMain()
  base_test.go // 没有TestMain()函数
  // 解决方案
  1、初始化代码放到setup()函数中
  2、go命令行
  go test ./base.go ./base_test.go ./api_test.go ./api.go
  3、只想运行base_test.go怎么办
  base_test.go中加上自己的setuoBase()
  3、查看单测覆盖率
  go test -cover
  coverage: 80.4% of statements
  4、单测覆盖文件解读
  go test -coverprofile=coverage.out
  // 打开单测覆盖率文件
  mode: set
  base.go:10.118,14.23 3 1
  base.go:14.23,17.3 2 1
  解释如下:
  10.118,14.23 3 1 表示第 10 行到第 14 行代码被测试覆盖到了,且覆盖
  率为 3/1 (即 300%)。这是因为第 10 行至少执行了一次,如果执行了三次,则覆盖率为 300%。
  14.23,17.3 2 1 表示第 14 行到第 17 行代码被测试覆盖到了,且覆盖率为 2/1 (即 200%)。
  5、生成可被浏览器打开的单测文件
  go test -coverprofile=coverage.out
  go tool cover -html=coverage.out -o coverage.html
  绿色代表被覆盖到的代码,红色代表没有被覆盖到的代码。
  左上角是运行单测命令目录下,所有go文件的覆盖率。
  可以考虑新增单测case来覆盖到这部分红色。
  6、单测覆盖率的问题
  覆盖率为 100% 表示测试用例覆盖了所有的可能执行路径,即程序的所有功能都被覆盖到了。而覆盖率高于 100% 则表示相同的代码路径被多次测试或某些代码行在被测试期间被执行了多次。
  但是单测100%并不能保证没有bug,只能保证写出来的代码没问题,但逻辑或者业务上的漏洞是检测不到的。
  博主在滴滴的组是建议单测覆盖率50%以上,其他朋友的公司要求核心接口必须有单测,整体单测覆盖率30%以上。有需要的可以参考下。
  四、关于单测粒度的问题
  写单测的时候,总会疑问到底要写的多细呢?特别是原来项目没有单测的时候,补单测的代码比业务逻辑代码还多。。。
  本例中,目录结构如下:
  domain:
  base.go
  code.go
  code_test.go
  util.go
  code.go会调用base.go和util.go的函数,运行code_test.go发现单测覆盖率
  已经80%了,是不是意味着只需要写个code_test.go就可以了呢?
  1、chatgpt的回答
  实际上不是的,base.go和util.go后续还可能被其他的文件使用,我们写单测的时候,应该尽量覆盖所有的异常情况,也就是程序的边界问题。因此base.go和util.go也需要做对应的单测,这样才能得到高质量的代码。
  2、个人理解
  单个code_test.go文件导致的问题是下层函数不mock,可能会影响到实际的数据,导致单测只能运行一次,而不能一直PASS。其次是代码流程变长导致单测case越写越多,接近集成测试了,这不是我们单测的目标。
  把code_test.go中关于base.go和util.go的函数都给mock掉,发现单测覆盖率只有37%,且测试路径比较短。还需要分别写base_test.go和util_test.go,写完util_test.go单测覆盖率立马82%。
  拆分的粒度变细,更加关注每个函数的输入和输出。特别是当修改某个函数的时候,只需要使用对应的单测来进行验证,而不需要从入口处进行测试。毕竟单元测试不是集成测试。
  五、mock数据
  在写单测的时候,程序难免会出现各种跨文件的函数调用,以及操作第三方中间件或者上下游交互的情况,这个时候mock就显得尤为重要。
  想象下,没有mock的时候,我们运行单测可能就会写入一次数据库?或者对下游发起一次请求?这样的单测,怕是只能运行一次哟。mock的出现让我们关注代码的实现细节,不会担心会造成数据污染或者单测只能运行一遍就GG的情况。
  1、mock组件选择
  博主这里更喜欢无侵入的mock,直接一把梭。可惜monkey已经不更新了,现在都是用gomonkey,国人大佬开发的。
  2、mock实操
  (1)mock函数调用
  函数中存在大量的封装调用,比如A->B,A->C这种,因此自由mock B和C函数对我们的单元测试来说还是很重要的。
  patches := gomonkey.ApplyFunc(queryOneMappCode, func(ctx context.Context, code string) (*infra.QueryOneMappingCode, error) {
  // 参数大于6则返回空
  if len(code) > 6 {
  return nil, nil
  }
  return &infra.QueryOneMappingCode{
  ID:          54,
  Code:        "338798",
  CreatedAt:   time.Date(2023, time.June, 9, 16, 32, 59, 0, time.Local),
  }, nil
  })
  defer patches.Reset()
  (2)mock方法调用
  1、实例化接口
  var mockProvider = provider.Test
  // 接口如下
  type TestDbProvider interface {
  SetDb(db *sqlx.DB)
  GetOne(dest interface{}, sql string, args interface{}) (resp *infra.QueryOneMappingCode, err error)
  }
  2、mock对应的查询方法
  // 注意,第一个参数不能是指针,不然mock会失效
  // 例如 var oss_bucket_obj *oss.Bucket ,传入target为: *oss_bucket_obj
  // 传地址会报错
  patches := gomonkey.ApplyMethodFunc(mockProvider, "GetOne", func(dest interface{}, sql string, args interface{}) (resp *infra.QueryOneMappingCode, err error) {
  code := args.(string)
  if code == "123456" {
  return &infra.QueryOneMappingCode{
  ID:          1,
  Code:        "123456",
  CreatedAt:   time.Date(2023, time.June, 9, 16, 32, 59, 0, time.Local),
  }, nil
  } else if code == "456789" {
  return &infra.QueryOneMappingCode{
  ID:          1,
  Code:        "456789",
  CreatedAt:   time.Date(2023, time.June, 9, 16, 32, 59, 0, time.Local),
  }, nil
  } else {
  return nil, nil
  }
  })
  defer patches.Reset()
  (3)mock其他包的函数
  在xx_test文件中直接引用其他包即可。一般xx_test.go和xx.go在同一个包下,所以也不用担心出现循环引用的问题。
  patches := gomonkey.ApplyFunc(util.GenerateRandomCode, func(numDigits int) string {
  return "123456"
  })
  defer patches.Reset()
  (4)mock循环中的函数
  比如在A函数中,循环3次调用了B函数,那么mock如下:
  createA := &infra.CreateMappingCode{Code: "933903"}
  createB := &infra.CreateMappingCode{Code: "601690"}
  createC := &infra.CreateMappingCode{Code: "798493"}
  p := gomonkey.ApplyFuncSeq(structureMappingCodeRecord, []gomonkey.OutputCell{
  {Values: gomonkey.Params{createA}},
  {Values: gomonkey.Params{createB}},
  {Values: gomonkey.Params{createC}},
  })
  defer p.Reset() // 恢复原始函数
  (5)mock http调用
  // vscode自动生成的test代码
  for _, tt := range tests {
  t.Run(tt.name, func(t *testing.T) {
  // mock httptest
  ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  if r.Method != http.MethodGet {
  w.WriteHeader(http.StatusNotFound)
  }
  // 构造返回参数
  w.WriteHeader(http.StatusOK)
  // 获取POST请求的参数,根据参数返回不同的响应
  bodyBytes, err := io.ReadAll(r.Body)
  if err != nil {
  // 处理错误
  w.WriteHeader(http.StatusBadRequest)
  }
  // 获取post参数
  params := new(dto.GenerateStsReqParams)
  json.Unmarshal(bodyBytes, params)
  // 根据传递的参数返回不同的响应
  res := new(common.RESTResp)
  if params.SessionName == "webApp" {
  res = &common.RESTResp{
  Code:    0,
  Message: "success",
  Data: &dto.OssStsRespData{
  Region:          "hangzhou",
  Bucket:          "test",
  },
  }
  } else {
  res = &common.RESTResp{
  Code:    1,
  Message: "failed",
  Data:    &dto.OssStsRespData{},
  }
  }
  // 模拟接口的返回,http接口返回是字节数据,因此需要json.Marshal
  jsonStr, _ := json.Marshal(res)
  w.Write(jsonStr)
  }))
  defer ts.Close()
  // 替换原来的url为mock的url
  GenerateOssStsUrl = ts.URL
      // 发起请求,请求中的http会被mock掉
  gotResp, err := GenerateStsTokenService(tt.args.ctx, tt.args.generateStsData)
  if (err != nil) != tt.wantErr {
  t.Errorf("GenerateStsTokenService() error = %v, wantErr %v", err, tt.wantErr)
  return
  }
  t.Logf("gotResp:(%#v) ,wantResp:(%#v)", gotResp, tt.wantResp)
  if !reflect.DeepEqual(gotResp, tt.wantResp) {
  t.Errorf("GenerateStsTokenService() = %v, want %v", gotResp, tt.wantResp)
  }
  })
  }
  3、对于mock的看法
  对于mock,有以下两种态度:
  go复制代码一方的人主张不要滥用mock,能不mock就不mock。被测单元也不一定是具体的一个函数,可能是多个函数本来就应该串起来,必要的时候再mock。
  一方则主张将被测函数所有调用的外面函数全部mock掉,只关注被测函数自己的一行行代码,只要调用其他函数,全都mock掉,用假数据来测试。
  本来处于懒惰和少写单测的角度,我是支持第一种方式的。
  例如:
  单测函数:A函数
  内部逻辑:
  A->B : B函数全是业务逻辑
  A->C : C函数包括mysql或者redis操作
  A->D->E: D函数纯业务逻辑,构造请求参数。E函数对外发起http请求
  第一种方式是只mock C和E函数,测试A函数的时候,会把B和D也测试到。主打一个省事快捷。
  直到我遇到了更复杂的场景,B里面还有B1和B2函数,D里面有D1和D2函数,逻辑非常复杂的情况下,第一种方式就变成了集成测试。单测用例慢慢变成了测试用例。 比如只修改D2函数的情况下,要修改和通过单测A进行测试。
  第二种方式,就是在每一层都mock掉外部调用。单测A就只关注A的逻辑,mock掉B,C,D,E,只关注B,C,D,E输出是正确或者错误的情况。
  针对B,C,D,E函数又有自己的单测函数,充分覆盖掉。这样当修改D2函数的时候,只需要修改和通过D2的单测即可。
  对于外部依赖,比如第三方库mysql,redis,mq这种统一进行mock。 对于内部的函数调用,建议是粒度细一些,A_test.go就只对A.go里面的逻辑负责。至于调用B.go的部分,就交给B_test.go吧。
  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

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

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

沪ICP备05003035号

沪公网安备 31010102002173号