likes
comments
collection
share

单元测试实践

作者站长头像
站长
· 阅读数 47

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

本文作者:飞书商业应用研发部 陆长青

欢迎大家关注飞书技术,每周定期更新飞书技术团队技术干货内容,想看什么内容,欢迎大家评论区留言~

单测投入成本以及收益

  1. 单测成本一开始投入极大, 但随着时间的推移、经验的积累单测成本/研发投入成本,在逐渐降低
  2. 单测一定程度上降低了提测bug数,提升对代码质量的信心

如何写单测

单元测试实践

SmartUtil

优点

  1. 快速生成模版代码
  2. 针对路径中需要mock 方法(第一层函数调用、数据库、redis) 会自动识别出来进行生成
  3. Goland/Vscode 本地集成 非常便捷

针对函数生成Mock语句(强烈推荐)(Goland 版本1.7.6及以上支持)

在单测初期或使用新mock框架后,我们不知道如何写对下游依赖函数进行Mock,需要大量时间来学习手册,本功能针对被Mock的函数支持一键生成mock语句;

一个自动生成的简单例子

// we create the test template for the runnable function
// please fill the testcase and mock function
func Test_doTempSaveFormInstance_JDHFSU(t *testing.T) {
   type Args struct {
      Ctx              context.Context
      RenderEntity     *render_entity.RenderEntity
      FormInstance     *model.FormInstance
      FieldValueTOList []*form.FieldValueTO
      TempVersionID    int64
   }
   type test struct {
      Name    string
      Args    Args
      Want    *int64
      WantErr bool
   }
   tests := []test{
      // TODO: add the testcase
   }
   for _, tt := range tests {
      mockey.PatchConvey(tt.Name, t, func() {
         // TODO: add the return of mock functions
         mockey.Mock(render_entity.FilterFieldValueTO).Return().Build()
         mockey.Mock(value_check.CheckFieldValueTOList).Return().Build()
         mockey.Mock(getTempSaveFormExpressionValueList).Return().Build()
         mockey.Mock(feature_gating.EnableMultiEdit).Return().Build()
         mockey.Mock(id_generator.GetGenerateID).Return().Build()
         mockey.Mock(log.CtxErrorf).Return().Build()
         mockey.Mock(getOrCreateFormInstanceEncryptedKey).Return().Build()
         mockey.Mock(mysql.GetDB).Return().Build()
         mockey.Mock((*gorm.DB).Transaction).Return().Build()
         got, err := doTempSaveFormInstance(tt.Args.Ctx, tt.Args.RenderEntity, tt.Args.FormInstance, tt.Args.FieldValueTOList, tt.Args.TempVersionID)
         if (err != nil) != tt.WantErr {
            t.Errorf("%q. doTempSaveFormInstance() error = %v, wantErr %v", tt.Name, err, tt.WantErr)
         }
         if got != tt.Want {
            t.Errorf("%q. doTempSaveFormInstance() = %v, want %v", tt.Name, got, tt.Want)
         }
      })
   }
}

单测工具

Go Test

说明
TestXxx(t *testing.T)单元测试

断言(verify)

工具描述
goconvey是一款针对Golang的测试框架,可以管理和运行测试用例,同时提供了丰富的断言函数,语法简单优雅,具体使用可以参考:www.jianshu.com/p/e3b2b1194…

基本语法

断言 go convey

常用的方式

convey.So(var1, ShouldEqual, var2)  // 断言 var1 == var2
convey.So(err, ShouldBeNil)         // 断言 err == nil
convey.So(err, ShouldBeErr, expectErr) // 断言 err == expectErr
convey.So(slice1, ShouldResemble, slice2) // 断言两个切片相等
convey.So(var1, ShouldBeZeroValue)   // 断言 var 是该类型的零值
convey.So(num1, ShouldBeLessThan, 2) // 断言 num1 < 2
convey.So(got, convey.ShouldBeTrue) // 断言是否为true

完整断言使用手册参见:GoConvey Doc Assertions

自定义断言方式

比如: 判断一个Map的Key是否与另一个Map的Value相同

func MyMapCompare(actual interface{}, expected ...interface{}) string {
    map1 := actual(map[string]string)
    map2 := expected[0](map[string]string)
    if len(map1) != len(map2) {
        return "length of map is not equal to another"
    }
    for _, val := range map2 {
        if _, ok := map1[val]; !ok {
            return "the val in map2 is not the key in map1";
        }
    return ""
}
// 使用自定义断言函数
So(map1, MyMapCompare, map2)

断言写法

// 这样执行的时候会把case 的名字打印出来
convey.Convey(tt.Name, func() { convey.So(got, convey.ShouldResemble, testArgs.Want) })
//或者
convey.SoMsg(tt.Name, got, convey.ShouldResemble, testArgs.Want)

执行

go test -gcflags="all=-l -N"  -v

Mock 函数

mock := func(ctx context.Context, nationalIDType *form.ValueTO, nationalIDValueTO *form.ValueTO) (bool, error) {
   reg, err := regexp.Compile(checkRule)
   if err != nil {
      return false, nil
   }

   nationalID, err := utils.StringFormValue(nationalIDValueTO)
   if err != nil {
      return false, nil
   }
   return reg.Match([]byte(nationalID)), nil
}
mockey.Mock(details_widget_service.ValidateNationalID).To(mock).Build()

Mock struct函数

type Class struct{}

func (c *Class) FunB (s string) string {
    ...
    return s
}

Mock((*Class).FunA).To(mock).Build()

工具函数

  • 获取私有函数
target := mockito.GetPrivateMethod(rpcClient.FlashServiceClientInst, "GetResourceItems")
mockey.Mock(target).Return(nil, mockErr).Build()

Mock 变量

a := 20
MockValue(&a).To(20)

Mock 结果统计

  1. 被mock函数调用次数

API Times() int

  1. hook函数调用次数

API:MockTimes() int

异步方法测试

提供了 IncludeCurrentGoRoutine、ExcludeCurrentGoRoutine 、FilterGoRoutine 关于 Goroutine 限制功能,可以控制Mock的生效 Goroutine 范围

TestMain

在写测试时,有时需要在测试之前或之后进行额外的设置(setup)或拆卸(teardown)。 为了支持这些需求,testing 提供了 TestMain 函数 :

func TestMain(m * testing . M) {
    log . Println("Do stuff BEFORE the tests!")
    exitVal := m . Run()
    log . Println("Do stuff AFTER the tests!")

    os . Exit(exitVal)
}

如果测试文件中包含该函数,那么生成的测试将调用 TestMain(m),而不是直接运行测试。TestMain 运行在主 goroutine 中 , 可以在调用 m.Run 前后做任何设置和拆卸。

Mock Interface

Mockey 是不支持mock interface的

目前提供一种方法 struct实现interface ,然后将变量指定到实现struct的实例,mock stuct方法

单元测试实践

mockDataEngineClient := &data_engine_rpc_mock.DataEngineService{}
data_engine_rpc.DataEngineClient = mockDataEngineClient
mockey.Mock((*data_engine_rpc_mock.DataEngineService).QueryDataInstance).Return(tt.DataInstanceID, nil, nil).Build()

针对interface 生成mock 代码

通过mockgen 工具生成该 Mock 代码, 详细了解请看这里:mockgen使用说明

mockgen -source=<Interface所在的文件名> -destination=<生成的文件名> -package=<包名,建议与Interface的报名一致>

CI集成

增量覆盖率生成

  1. 新增配置文件 .codebase/pipelines/ci.yaml
commands:
  - go test -gcflags="all=-l -N" -race -v -p 1 -coverprofile=coverage.out ./...
  - go tool cover -html=coverage.out -o coverage.html

coverage_report_name: coverage.html

2. 新增配置文件.codebase/apps.yaml

codecov:
   status: # 准入控制状态相关
     project:
       default: # 可以为任何值,代表了当前 paths 下的配置, 支持一个仓库内的多项目配置
            coverage_strategy: statement # branch or statement, default statement. not support diff coverage. Supported languages: node and c++
            minimum_coverage: 80% # 允许的覆盖率的最小值
            threshold: 0%  # 允许少于目标值的范围             base: "change" # auto or parent or change。 change: 与目标分支覆盖率进行比较,parent:与父 commit 进行比较, 与 minimum_coverage 二选一
            line_limit: 10 # 增量行数少于多少行时,默认置成功
            paths:    # 支持根据不同的路径计算覆盖率。不添加该项时默认为全部路径
              - "project/src" # 匹配以 project/src 开头的路径
              - "!test"       # 匹配所有不以 test 开头的路径
              - "!a/*.go"   # 过滤所有 a 文件夹内的 go 文件
      project_test: #与上面的 default 为同一级
            paths:
              - "project/test"  
     diff: # 具体下级配置同上面的 project, 只对 diff 的代码有效
 

数据库(Mysql)

template: go
enable_mysql: true
mysql_database: my_db
mysql_password: my_password
mysql_image: hub.byted.org/ee/mysql:8.0.25
mysql_script_paths:
  - "resources/db/*.sql"
  - "resources/table/*.sql"
  - "resources/data/*.sql"

其他场景

如何在老代码下写单测

难点

  • 可测性低
  • 业务场景复杂、历史功能耦合
  • 构造数据难,相比于原子性的方法而言

例子

步骤一: 梳理功能

单元测试实践

步骤二:场景

正常场景

单元测试实践

异常场景

单元测试实践

步骤三:单测函数拆分结构

单元测试实践

功能性拆分

单元测试实践

实现能力拆分

单元测试实践

前置依赖拆分

单元测试实践

步骤四:构造单测case

结合场景和函数拆分构造case完成360覆盖

单元测试实践

复杂场景数据构造

接口拦截json 打印,或者从页面请求中获取

为什么要写单元测试

错误恒定定律

程序员的错误产出比是个常数

对某一个程序员来说,实现相同功能会犯的错误(BUG)是固定的,不受程序员自身意愿影响,不受绩效影响,也不受项目紧急程度影响。不考虑程序员水平的成长,错误产出比在很长一段时间(每个项目的间隔)内可以认为是个常数。

场景

  1. 感觉状态很好,多写了一倍代码感觉bug数肯定比昨天还少
  2. 开会时要求程序员尽量不要写bug

规模代价平方定律

单元测试实践

为了减少错误修复的成本,要尽可能早的发现错误,在尽量小的范围内定位并修复错误。由于这是一个平方律而非线性率,所有这方面的努力都是非常划算的

目的

错误率恒定律告诉我们错误是不可避免的,而规模代价平方律告诉我们要尽早发现错误

单元测试的目的是尽早在尽量小的范围内暴露错误

好处

  1. 验证代码的正确性,保证新增、修改、重构后的正确性
  2. 写出可测性、可维护性较高的代码,提升代码质量
  3. 单测有利于我们更早的发现问题,在整个问题排查过程中是ROI最高的

单元测试实践

  1. 可以加深对业务的理解

认知误区

补单测

早会、周会上经常听到"XXX功能已经写完了,今天的工作是补些单测,或者单测覆盖率不达标,需要补单测",甚至我自己都这么说,这样是不对的,亲身体验:自己给自己补单测都觉得费劲,让别人来补更加费劲

单测 应该随着代码同时产生,而不应该是补出来的。

why

  1. 错误恒定定律

  2. 人性问题

项目紧,没时间写单测

这也是我们经常说的话,尤其是没有写单测习惯的时候经常会说的话

不考虑单测框架自身的学习成本,任何情况下写单测都只会降低整体交付时间

Why

  1. 错误率恒定定律
  2. 规模代价平方定律

错误率恒定,需要的调试量也是固定的,如果说项目紧不写单测,看起来编码阶段省了一些时间,但必然会在测试和线上花掉成倍甚至更多的时间来修复。

基本指导原则

【原则】AIR原则:

  • A: Automatic(自动化)

    • 单元测试应该是全自动执行的,并且非交互式的。结果需要「断言」自动判断对错。
    • 测试用例通常是需要和 CICD 集成的,执行过程必须完全自动化才有意义。
  • I: Independent(独立性)

    • 单测之间是独立无耦合的,单测是可以独立运行的,无外部依赖
  • R: Repeatable(可重复)

    • 不修改实现逻辑情况下,重复执行的结果应该一致单元测试是可以重复执行的,不能受到外界环境的影响。
  • 【原则】 BCDE 原则:

  •   B: Boder 边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。
  •   C: Correct 正确的输入,并得到预期的结果。
  •   D: Design 与设计文档相结合,来编写单元测试。
  •   E: Error 强制输入错误数据(如:非法数据、异常流程、非业务允许输入等),并得到预期结果。

如何火力全开

代码复杂度四象限

  1. 领域复杂度,代码的领域复杂度越高,里面的业务逻辑就越多。
  2. 对外依赖度,依赖度越高,说明代码和外部的 Class 交互点多。

分为四个象限:

  1. 业务复杂、依赖又少,是业务逻辑集中的代码。比如业务算法。
  2. 业务简单、依赖又少,是一些简单代码,比如构造函数,数据对象等。
  3. 业务复杂、依赖多,是复杂度高的代码,测试起来也会比较困难。
  4. 业务简单、依赖多,是管理依赖的代码。比如消息总线。

单元测试实践

再看规模代价平方定律

  1. 拆分详细 ,防止笛卡尔集 存在,提升复杂度,无法mock

再看好处

  1. 编写代码时经过重构,依赖管理和业务逻辑、逻辑清晰、易测、易维护、易看懂

再看定义

  1. 最小可测单元: 只专注一件事: 依赖管理、业务、算法

单测阶段

单元测试实践

什么场景下适合单测

FURPS 模型

FURPS 模型来解释一下需求,FURPS 是用 5 个维度来描述一个软件的功能需求,

  • F=Function 功能
  • U=Usability 易用性
  • R=Reliability 可靠性 ps:可用性、准确性和可恢复性等方面——例如,计算或 系统从关闭故障中恢复的能力。
  • P=Performance 性能
  • S=Supportability 可支持性 ps:可测试性、适应性、可维护性、兼容性、可配置性、可安装性、可扩展性、可本地化性
功能易用性可靠性性能可支持性
场景数据算法
单元测试部分部分全部可以可以部分

测试覆盖率真的有效么

代码覆盖率

简单来说,代码覆盖率是指,至少被执行了一次的条目数占整个条目数的百分比。

  • 行覆盖率又称为语句覆盖率,指已经被执行到的语句占总可执行语句(不包含类似 C++ 的头文件声明、代码注释、空行等等)的百分比。这是最常用也是要求最低的覆盖率指标。实际项目中通常会结合判定覆盖率或者条件覆盖率一起使用。

代码覆盖率的局限性

举个极端的例子,如果一个被测函数里面只有一行代码,只要这个函数被调用过了,那么衡量这一行代码质量的所有覆盖率指标都会是 100%,但是这个函数是否真正实现了应该需要实现的功能呢?

代码覆盖率 的计算是基于现有代码的,并不能发现那些“未考虑某些输入”以及“未处理某些情况”形成的缺陷

如何度量单元测试的质量

变异测试覆盖率

单元测试实践

单元测试实践

每一次代码迭代,就是一次变异

Go的编译测试率框架:

github.com/zimmski/go-…

命令

执行结果

PASS "/tmp/go-mutesting-422402775//home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go.0" with checksum b705f4c99e6d572de509609eb0a625be
PASS "/tmp/go-mutesting-422402775//home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go.1" with checksum eb54efffc5edfc7eba2b276371b29836
PASS "/tmp/go-mutesting-422402775//home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go.2" with checksum 011df9567e5fee9bf75cbe5d5dc1c81f
--- /home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go+++ /tmp/go-mutesting-422402775//home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go.3@@ -16,7 +16,7 @@
        }

        if n < 0 {
-               n = 0+
        }

        n++
FAIL "/tmp/go-mutesting-422402775//home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go.3" with checksum 82fc14acf7b561598bfce25bf3a162a2
PASS "/tmp/go-mutesting-422402775//home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go.4" with checksum 5720f1bf404abea121feb5a50caf672c
PASS "/tmp/go-mutesting-422402775//home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go.5" with checksum d6c1b5e25241453128f9f3bf1b9e7741
--- /home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go+++ /tmp/go-mutesting-422402775//home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go.6@@ -24,7 +24,6 @@
        n += bar()

        bar()
-       bar()

        return n
 }
FAIL "/tmp/go-mutesting-422402775//home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go.6" with checksum 5b1ca0cfedd786d9df136a0e042df23a
PASS "/tmp/go-mutesting-422402775//home/zimmski/go/src/github.com/zimmski/go-mutesting/example/example.go.8" with checksum 6928f4458787c7042c8b4505888300a6
The mutation score is 0.750000 (6 passed, 2 failed, 0 skipped, total is 8)

转载自:https://juejin.cn/post/7238917340200910909
评论
请登录