极狐GitLab自动化测试指南04——单元测试

1 理论篇

1.1 什么是单元测试

WIKI百科对于单元测试的解释是:

单元测试又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

熟悉敏捷开发,尤其是XP极限编程的小伙伴对单元测试应该不陌生,XP是TDD(测试驱动开发),这里面的测试就是指单元测试。

狭义上来看,单元测试主要测的是一个函数、方法的功能是否正常可用。一般要通过多个测试用例,覆盖这个函数、方法的所有或主要变量、条件、路径,验证这个函数、方法的输入输出是否符合预期。

单元测试属于白盒测试,也就是根据已知的代码来编写测试用例,所以在实际应用中,推荐开发人员自己来做单元测试。虽然开发人员一般缺少测试思维,而且自己的问题很难自己发现,但相比让测试人员去熟悉开发代码,并具备一定的开发能力,可行性上会更高。

此外,单元测试的代码一般会伴随开发代码一起递交至代码库,所以结合CI/CD可以实现自动化单元测试,并作为代码合并请求(PR、MR)、代码评审(Code Review)的依据。

1.2 为什么要做单元测试

在某种意义上来讲,所有的开发人员已经在做“单元测试”了。比如通过Postman等API测试工具,或在最终的应用程序UI进行操作,目的是为了作为调试某个函数、方法的入口。也有在程序里某个地方临时写一些方法或者变量,来进行自测。这种马甲程序的目的是为了测试函数、方法的功能正确性,但是缺少规范性,效率低下,且容易引起混乱。

关于要不要做单元测试,一直都有两种声音:

类 别 支持 反对
时间与效率 单元测试有助于提高Bug处理效率。下图来自微软的数据统计,在单元测试中发现的Bug平均处理时间为3.25小时,在系统测试中发现的Bug平均处理时间为11.5小时 开发人员已经很忙了,单元测试可能会占据开发人员20~40%的工作时间
作用 单元测试与系统测试是互补而非代替关系,单元测试注重“独立性”,系统测试注重“相关性” 单元测试仅仅证明了这些函数做了什么,并不能保证系统功能正确,还是得交给后面的系统或集成测试
意义 单元测试对产品质量非常重要,它是软件测试中,最底层的一类测试 简单的程序没必要写单元测试,复杂的程序写了单元测试也覆盖不全,单纯是忽悠领导
文化思想 很多西方企业都写单元测试,对于开发人员来说这是理所应当的事 对于国内企业来说,测试工作基本上都是交给测试团队,而且整体更关注功能

所以到底要不要做单元测试?又要祭出小学课文《小马过河》了,人云亦云和纸上谈兵终究没有任何意义,觉得有必要就可以先进性尝试,买不了吃亏买不了上当。最终作出决定无非是看它产生的价值和付出的代价是否符合预期或者能够承受。这个没有绝对的标准:

  • 如果你的项目是做一架飞机,需要考虑到每个螺丝钉的品质,那么就要去做单元测试,毕竟人命关天。当然也可以赌一把,万一不出事呢。
  • 如果你的项目和团队追求尽善尽美,当然可以去做单元测试。
  • 如果你的项目是个创新型、演示型项目,那么不做也无伤大雅,做了也锦上添花。

1.3 如何评估单元测试

做单元测试不容易,如果决定要去做单元测试,如何去评估这项工作的效果,一般分两个方面的指标:

直接指标:

  • 单元测试通过率:即单元测试用例的通过率,只要有没通过的用例,后面的都免谈。
  • 单元测试用例数量:只写1个单元测试用例,过了也是100%的通过率。所以只靠通过率还不够,测试用例的数量也是辅助度量指标。
  • 单元测试覆盖率:也叫代码覆盖率(Code Coverage),描述程序中源代码被测试的比例和程度,是单元测试中最重要的度量指标。实际工作中一般不需要追求100%覆盖,可参考2-8原则,覆盖主要的、重要的函数方法即可。

间接指标:

  • Bug总数趋势:主要是用于对比写了单元测试和没写单元测试的项目Bug趋势,以及对该项目的历史Bug趋势进行长期跟踪。
  • 千行Bug率:同上。

虽然不能肯定,但目前任何有关效能评估和度量的指标,对于国内的企业来说,大部分是用来量化考核员工的工具,而非真正为了提升整体效率,对国内的开发者来说都会被认为是卷。这也是单元测试以及企业效能评估很难开展或难见成效的原因。这也需要国内企业和员工很长时间的磨合,也需要文化、流程、法规的不断建立和完善。

2 实践篇

2.1 极狐GitLab单元测试

2.1.1 创建项目

以Golang开发的项目为例,Golang官方使用go test命令进行单元测试,推荐测试文件和源代码文件放在一块,测试文件以 _test.go 结尾,如:

1
2
3
4
5
6
project/
controller/
|--func.go
|--func_test.go
|--main.go
|--main_test.go

main.go为例,实现了加减乘除四个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

func main() {
fmt.Println(Add(1, 2))
}

func Add(a int, b int) int {
return a + b
}

func Sun(a int, b int) int {
return a - b
}

func Mul(a int, b int) int {
return a * b
}

func Div(a int, b int) *int {
var res int
if b == 0 {
// 除数为0,应返回nil,此处故意返回0,引起单元测试失败
res = 0
} else {
res = a / b
}
return &res
}

main_test.go需要对main.go的四个方法进行测试用例的编写,测试用例的名称一般命名为Test加上待测试的方法名,如TestAdd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "testing"

func TestAdd(t *testing.T) {
if ans := Add(1, 2); ans != 3 {
t.Errorf("1 + 2 expected be 3, but %d got", ans)
}

if ans := Add(1, -1); ans != 0 {
t.Errorf("1 + -1 expected be 0, but %d got", ans)
}
}

func TestDiv(t *testing.T) {
if ans := Div(4, 2); *ans != 2 {
t.Errorf("4 / 2 expected be 2, but %d got", *ans)
}

if ans := Div(2, 0); ans != nil {
t.Errorf("2 / 0 expected be nil, but %d got", *ans)
}
}

运行go test -v -cover命令即可运行单元测试,并输出通过率和覆盖率。

1
2
3
4
5
6
7
8
9
10
$ go test -v -cover         
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestDiv
main_test.go:21: 2 / 0 expected be nil, but 0 got
--- FAIL: TestDiv (0.00s)
FAIL
coverage: 75.0% of statements
exit status 1
FAIL unit-test-golang 3.162s

2.1.2 创建CI/CD

基于上面的例子,结合极狐GitLab CI/CD,就可以实现自动化单元测试:

需Docker/K8S类型的GitLab Runner,详见:GitLab Runner Executors | GitLab。参考示例.gitlab-ci.yaml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
stages:          
- build
- test
- deploy
# 编译构建打包
build-job:
stage: build
image: golang:1.17
script:
- go build -o calc
artifacts:
paths:
- calc
# 单元测试
unit-test-job:
stage: test
image: golang:1.17
script:
- go test -v -cover
# 模拟发布运行
deploy-job:
stage: deploy
image: centos:7
script:
- ./calc

但测试报告和详细信息需要点进Job看,相对比较麻烦。即使可以通过邮件或者钉钉、企业微信的Webhook发送测试报告,但这与合并请求(Merge Requests)和代码评审(Code Review)割裂了。在实际应用中,我们更希望在代码评审时直接看到一个汇总的单元测试报告,不需要去别的地方翻数据。

2.1.3 查看测试报告

基于GitLab提供的单元测试报告和可视化的功能,稍作修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 单元测试
unit-test-job:
stage: test
image: golang:1.17
script:
# 生成通过率报告
- go get gotest.tools/gotestsum
- gotestsum --junitfile report.xml --format testname
# 生成覆盖率可视化
- go get github.com/t-yuki/gocover-cobertura
- go test . -coverprofile=coverage.txt -covermode count
- gocover-cobertura < coverage.txt > coverage.xml
- sed -i 's;filename=\"unit-test-golang/;filename=\";g' coverage.xml
# 生成覆盖率报告
coverage: '/coverage: \d+.\d+% of statements/'
artifacts:
when: always
reports:
# 上传覆盖率可视化
cobertura: coverage.xml
# 上传通过率报告
junit: report.xml

这里主要实现了3方面的功能,其中:

  • 通过率报告:

    • 其原理是将GitLab CI/CD中的单元测试报告导出为JUnit的XML格式,并以制品(Artifacts)的方式上传到GitLab,GitLab就可以进行解析,然后汇总和展示,详见:Unit test reports | GitLab

    • 通过率报告可在CI/CD流水线的“测试”页面中查看,如下图所示

    • 通过率报告可在合并请求中查看,做代码评审时无需进入CI/CD Job中查看日志,提高代码评审效率

  • 覆盖率可视化:

    • 其原理是将GitLab CI/CD中的覆盖率报告导出为Cobertura XML格式,并以制品(Artifacts)的方式上传到GitLab,GitLab就可以进行解析,然后汇总和展示,详见:Test coverage visualization | GitLab

    • 覆盖率可视化主要在合并请求中体现。在“变更”页面,对于覆盖到单元测试的方法,以绿色色块进行标识。对于未覆盖到单元测试的方法,以橙色色块进行标识。当鼠标移到色块上时,显示命中次数,即对该方法进行了几个测试用例的单元测试。在代码评审时,可以较直观的反应单元测试覆盖的情况。

  • 覆盖率报告:

    • 其原理是将GitLab CI/CD Job日志中的覆盖率信息以正则表达式的方法抠取出来,然后在合并请求页面进行展示,详见:Coverage in .gitlab-ci.yml file | GitLab

    • 覆盖率报告可在合并请求中查看,并可查看增量代码是否会引起单元测试覆盖率的下降,从而提高代码评审效率

就呈现效果来看,将单元测试的结果汇总并展示,便于辅助代码评审,这个整体的目的是基本达到了。但还有一些待优化的功能点,如:

  • 是否可以通过单元测试覆盖率、通过率、测试用例数、Bug数等来综合对开发人员的单元测试效果进行评估
  • 是否可以在合并请求过程中设置一个覆盖率指标,比如单元测试覆盖率达不到70%就不允许合并代码

对于前者,GitLab可以查看项目级(免费版)或群组级(专业版)的单元测试历史覆盖率,详见:View code coverage history | GitLab。而综合其他指标进行评估,目前看来只能通过API的方式获取,然后自己做一些二次开发。毕竟每家企业分析的维度可能不同,数据也不一定都在GitLab上。

对于后者,极狐GitLab专业版提供了“合并请求批准”功能,可将单元测试覆盖率作为合并请求的门禁。启用该功能后,若在合并请求中出现了覆盖率降低的情况,会强制审批人对覆盖率进行审核确认,得到批准后才可以进行合并,否则无法直接进行代码的合并。详见:Coverage check approval rule | GitLab


参考资料

极狐GitLab自动化测试指南04——单元测试

https://wurang.net/auto-test04/

作者

Wu Rang

发布于

2022-03-08

更新于

2022-03-23

许可协议

评论