单元测试实践
我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。
本文作者:飞书商业应用研发部 陆长青
欢迎大家关注飞书技术,每周定期更新飞书技术团队技术干货内容,想看什么内容,欢迎大家评论区留言~
单测投入成本以及收益
- 单测成本一开始投入极大, 但随着时间的推移、经验的积累单测成本/研发投入成本,在逐渐降低
- 单测一定程度上降低了提测bug数,提升对代码质量的信心
如何写单测
SmartUtil
优点
- 快速生成模版代码
- 针对路径中需要mock 方法(第一层函数调用、数据库、redis) 会自动识别出来进行生成
- 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 结果统计
- 被mock函数调用次数
API : Times() int
- 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集成
增量覆盖率生成
- 新增配置文件
.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)是固定的,不受程序员自身意愿影响,不受绩效影响,也不受项目紧急程度影响。不考虑程序员水平的成长,错误产出比在很长一段时间(每个项目的间隔)内可以认为是个常数。
场景
- 感觉状态很好,多写了一倍代码感觉bug数肯定比昨天还少
- 开会时要求程序员尽量不要写bug
规模代价平方定律
为了减少错误修复的成本,要尽可能早的发现错误,在尽量小的范围内定位并修复错误。由于这是一个平方律而非线性率,所有这方面的努力都是非常划算的
目的
错误率恒定律告诉我们错误是不可避免的,而规模代价平方律告诉我们要尽早发现错误
单元测试的目的是尽早在尽量小的范围内暴露错误
好处
- 验证代码的正确性,保证新增、修改、重构后的正确性
- 写出可测性、可维护性较高的代码,提升代码质量
- 单测有利于我们更早的发现问题,在整个问题排查过程中是ROI最高的
- 可以加深对业务的理解
认知误区
补单测
早会、周会上经常听到"XXX功能已经写完了,今天的工作是补些单测,或者单测覆盖率不达标,需要补单测",甚至我自己都这么说,这样是不对的,亲身体验:自己给自己补单测都觉得费劲,让别人来补更加费劲
单测 应该随着代码同时产生,而不应该是补出来的。
why
-
错误恒定定律
-
人性问题
项目紧,没时间写单测
这也是我们经常说的话,尤其是没有写单测习惯的时候经常会说的话
不考虑单测框架自身的学习成本,任何情况下写单测都只会降低整体交付时间
Why
- 错误率恒定定律
- 规模代价平方定律
错误率恒定,需要的调试量也是固定的,如果说项目紧不写单测,看起来编码阶段省了一些时间,但必然会在测试和线上花掉成倍甚至更多的时间来修复。
基本指导原则
【原则】AIR原则:
-
A: Automatic(自动化)
- 单元测试应该是全自动执行的,并且非交互式的。结果需要「断言」自动判断对错。
- 测试用例通常是需要和 CICD 集成的,执行过程必须完全自动化才有意义。
-
I: Independent(独立性)
- 单测之间是独立无耦合的,单测是可以独立运行的,无外部依赖
-
R: Repeatable(可重复)
- 不修改实现逻辑情况下,重复执行的结果应该一致单元测试是可以重复执行的,不能受到外界环境的影响。
-
【原则】 BCDE 原则:
- B: Boder 边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。
- C: Correct 正确的输入,并得到预期的结果。
- D: Design 与设计文档相结合,来编写单元测试。
- E: Error 强制输入错误数据(如:非法数据、异常流程、非业务允许输入等),并得到预期结果。
如何火力全开
代码复杂度四象限
- 领域复杂度,代码的领域复杂度越高,里面的业务逻辑就越多。
- 对外依赖度,依赖度越高,说明代码和外部的 Class 交互点多。
分为四个象限:
- 业务复杂、依赖又少,是业务逻辑集中的代码。比如业务算法。
- 业务简单、依赖又少,是一些简单代码,比如构造函数,数据对象等。
- 业务复杂、依赖多,是复杂度高的代码,测试起来也会比较困难。
- 业务简单、依赖多,是管理依赖的代码。比如消息总线。
再看规模代价平方定律
- 拆分详细 ,防止笛卡尔集 存在,提升复杂度,无法mock
再看好处
- 编写代码时经过重构,依赖管理和业务逻辑、逻辑清晰、易测、易维护、易看懂
再看定义
- 最小可测单元: 只专注一件事: 依赖管理、业务、算法
单测阶段
什么场景下适合单测
FURPS 模型
FURPS 模型来解释一下需求,FURPS 是用 5 个维度来描述一个软件的功能需求,
- F=Function 功能
- U=Usability 易用性
- R=Reliability 可靠性 ps:可用性、准确性和可恢复性等方面——例如,计算或 系统从关闭故障中恢复的能力。
- P=Performance 性能
- S=Supportability 可支持性 ps:可测试性、适应性、可维护性、兼容性、可配置性、可安装性、可扩展性、可本地化性
功能 | 易用性 | 可靠性 | 性能 | 可支持性 | |||
---|---|---|---|---|---|---|---|
场景 | 数据 | 算法 | |||||
单元测试 | 部分 | 部分 | 全部 | 不 | 可以 | 可以 | 部分 |
测试覆盖率真的有效么
代码覆盖率
简单来说,代码覆盖率是指,至少被执行了一次的条目数占整个条目数的百分比。
- 行覆盖率又称为语句覆盖率,指已经被执行到的语句占总可执行语句(不包含类似 C++ 的头文件声明、代码注释、空行等等)的百分比。这是最常用也是要求最低的覆盖率指标。实际项目中通常会结合判定覆盖率或者条件覆盖率一起使用。
代码覆盖率的局限性
举个极端的例子,如果一个被测函数里面只有一行代码,只要这个函数被调用过了,那么衡量这一行代码质量的所有覆盖率指标都会是 100%,但是这个函数是否真正实现了应该需要实现的功能呢?
代码覆盖率 的计算是基于现有代码的,并不能发现那些“未考虑某些输入”以及“未处理某些情况”形成的缺陷
如何度量单元测试的质量
变异测试覆盖率
每一次代码迭代,就是一次变异
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