导语 非常幸运的是,从4月份至今,我能够全身心投入到腾讯新闻的单元测试专项任务中,从无知懵懂,到不断深入理解的过程,与开发同学互帮互助,受益匪浅。在此过程中,得到了质量总监、新闻总监和乔帮主的倾囊指导,真心感谢!!我希望把所有心得,总结成一篇较为全面的文章,分享给其他团队。时刻牢记:1. 不要滥用mock 2. 基于意图。
在我们谈到单元测试,大都清楚是测试函数符合预期,国外很多大公司都将单测执行的很好,国内成功的案例则相对有限。在本文中,笔者将在腾讯新闻项目中亲身经历单测从无到有的实践过程梳理为可读可参考的经验分享出来。在实践的过程我发现,单测可以推动产品质量转为优秀,推动实行它的过程更需要对它有真实的认识以及一套方法论。
为单元测试“正名”
我曾经认为,单元测试面向的是一个函数。任何走出一个函数的测试,都不是单元测试。
其实,对“单元”的定义取决于自己。如果你正在使用函数式编程,一个单元最有可能指的是一个函数。你的单元测试将使用不同的参数调用这个函数,并断言它返回了期待的结果;在面向对象语言里,下至一个方法,上至一个类都可以是一个单元(从一个单一的方法到一整个的类都可以是一个单元)。意图很重要(“意图”二字是本文中第一次提到,它很重要)
我们有单元测试、增量测试、集成测试、回归测试、冒烟测试等等,名字非常多。谷歌看到这种“百家争鸣”的现象,创立了自己的命名方式,只分为小型测试、中型测试和大型测试。
资源 | 小型测试 | 中型测试 | 大型测试 |
---|---|---|---|
网络访问 | 否 | 仅访问localhost | 是 |
数据库访问 | 否 | 是 | 是 |
访问文件 | 否 | 是 | 是 |
访问用户界面 | 否 | 否 | 是 |
使用外部服务 | 否 | 不鼓励,可mock | 是 |
多线程 | 否 | 是 | 是 |
使用sleep语句 | 否 | 是 | 是 |
使用系统属性设置 | 否 | 是 | 是 |
运行时间限制(毫秒) | 60 | 300 | 900+ |
强制时间限制(分钟) | 1 | 5 | 15 |
小型测试 | 中型测试 | 大型测试 | |
---|---|---|---|
对应测试类型 | 单元测试 | 单元测试+逻辑层测试(泛单元或分层测试) | UI测试或接口测试 |
结论:我们的单元测试,既可以针对一个函数写case,也可以按照函数的调用关系串起来写case。
金字塔模型
在金字塔模型之前,流行的是冰淇淋模型。包含了大量的手工测试、端到端的自动化测试及少量的单元测试。造成的后果是,随着产品壮大,手工回归测试时间越来越长,质量很难把控;自动化case频频失败,每一个失败对应着一个长长的函数调用,到底哪里出了问题?单元测试少的可怜,基本没作用。
Mike Cohn 在他的着作《Succeeding with Agile》一书中提出了“测试金字塔”这个概念。这个比喻非常形象,它让你一眼就知道测试是需要分层的。它还告诉你每一层需要写多少测试。
测试金字塔本身是一条很好的经验法则,我们最好记住Cohn在金字塔模型中提到的两件事:
同时,我们对金字塔的理解绝不能止步于此,要进一步理解:
我把金字塔模型理解为——冰激凌融化了。就是指,最顶部的“手工测试”理论上全部要自动化,向下融化,优先全部考虑融化成单元测试,单元测试覆盖不了的 放在中间层(分层测试),再覆盖不了的才会放到UI层。因此,UI层的case,能没有就不要有,跑的慢还不稳定。按照乔帮主的说法,我不分单元测试还是分层测试,统一都叫自动化测试,那就应该把所有的自动化case看做一个整体,case不要冗余,单元测试能覆盖,就要把这个case从分层或ui中去掉。
越是底层的测试,牵扯到相关内容越少,而高层测试则涉及面更广。比如单元测试,它的关注点只有一个单元,而没有其它任何东西。所以,只要一个单元写好了,测试就是可以通过的;而集成测试则要把好几个单元组装到一起才能测试,测试通过的前提条件是,所有这些单元都写好了,这个周期就明显比单元测试要长;系统测试则要把整个系统的各个模块都连在一起,各种数据都准备好,才可能通过。
另外,因为涉及到的模块过多,任何一个模块做了调整,都有可能破坏高层测试,所以,高层测试通常是相对比较脆弱的,在实际的工作中,有些高层测试会牵扯到外部系统,这样一来,复杂度又在不断地提升。
为什么做单测
这个问题我们规避不掉。新闻是这次研发模式改革的主力军之一,所以自上而下的推动让这个问题不那么棘手:做了就是做了。不做,却又有那么多的理由:(搜集到的吐槽真实声音)
我觉得我们总监指导的很到位:改革,一是工作方式的改革,更难的是思想上的改革。
新闻的总监dot老师是至始至终推进单测的好领导,他讲述了螺丝钉与飞机的故事:干货 | 测试扁平化之必备神器:好的单元测试
下面这张图,来自微软的统计数据:bug在单元测试阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。
下面这张图,旨在说明两个问题:85%的缺陷都在代码设计阶段产生,而发现bug的阶段越靠后,耗费成本就越高,指数级别的增高。所以,在早期的单元测试就能发现bug,省时省力,一劳永逸,何乐而不为呢
不能一刀切,不能只盯着单测阶段的耗时。
我采访了新闻客户端、后台的开发,首先肯定的是,单测会增加开发量、增加开发时长。
在《单元测试的艺术》这本书提到一个案例:找了开发能力相近的两个团队,同时开发相近的需求。进行单测的团队在编码阶段时长增长了一倍,从7天到14天,但是,这个团队在集成测试阶段的表现非常顺畅,bug量小,定位bug迅速等。最终的效果,整体交付时间和缺陷数,均是单测团队最少。
单测,存在即合理。一方面,需要把单测放在整个迭代周期来观测其效果;一方面,写单测也是技术活,写得好的同学,时间少代码质量高(也即,不是说写了单测,就能写好单测)
单元测试的阶段
一. 广义的单元测试,我们指这三部分的有机组合:
二. 结合新闻的实践,我把单测成长的过程分为4个目标,分别为:
截至发稿当天,新闻处于第三阶段,即,每个迭代均能产出高质量的case,人数覆盖和需求覆盖均较高;关注重点在于可测性,时刻注重重构。
单元测试的指标
还挺尴尬的,不太有直接的指标去衡量单测的效果。我们也经常被问到,“怎么证明你们新闻单测的作用呀?”
在迭代需求持续高吞吐量的前提下,以新闻iOS的数据为例:
go单元测试框架选型
基本选型:testify + gomonkey
附加:httptest + sqlmock
https://github.com/stretchr/testify
testify基于gotesting编写,所以语法上、执行命令行与go test完全兼容
assert.Equal:常规对比,是把两者分别换成[]byte去严格比对
assert.Nil:判断对象为nil时,有时对err判空时也用
assert.Error:判断err的具体类型和内容
assert.JSONEq:这个比较有用,对比map时;或者对比struct的时候,也会先转为map,在用这个api去做对比,如下面这个例子,我封装了建议的方法去将struct转换为string(json):
https://github.com/agiledragon/gomonkey
https://studygolang.com/articles/15034
注意,对内联函数的Stub,go test命令行一定要加上参数才可生效。见官方文档。所以,我的命令行默认加上-gcflags=all=-l就行了。
我设置了一些goland的代码模板,放在附件中。
ApplyFunc是对外部函数Stub(非类方法)
/* 用法:gomonkey.ApplyFunc(被stub函数名, 被stub函数签名) 函数返回值 *例子: patches := gomonkey.ApplyFunc(fake.Exec, func(_ string, _ ...string) (string, error) { return outputExpect, nil })*/
patches := gomonkey.ApplyFunc(lcache.GetCache, func(_ string) (interface{}, bool) { return getCommentsResp() })defer patches.Reset()
(左滑可查看完整代码,下同)
ApplyMethod是对类函数Stub。但这里注意,要被stub的方式是私有方法,gomonkey通过反射是找不到的,有两种解决方法:
1)使用增强版的gomonkey;
2)不Stub它,而是选择走进这个函数,这个话题在后面专题谈mock的时候说。
/* 用法:gomonkey.ApplyMethod(反射类名, 被stub函数签名) 函数返回值 *例子: var s *fake.Slice patches := ApplyMethod(reflect.TypeOf(s), "Add", func(_ *fake.Slice, _ int) error { return nil })*/
var ac *auth.AuthCheckpatches := gomonkey.ApplyMethod(reflect.TypeOf(ac), "PrepareWithHttp", func(_ *auth.AuthCheck, _ *http.Request, _ ...auth.AuthOption) error { return fmt.Errorf("prepare with nil object") })defer patches.Reset()
ApplyMethodSeq是对同一个Stub的函数返回不同的结果
/* 用法:gomonkey.ApplyMethodSeq(类的反射,"被stub函数名", 返回结构体); Params{info1},中括号内为被stub函数的返回值列表; Times为生效次数 *例子: e := &fake.Etcd{} info1 := "hello cpp" info2 := "hello golang" info3 := "hello gomonkey" outputs := []OutputCell{ {Values: Params{info1, nil}}, {Values: Params{info2, nil}}, {Values: Params{info3, nil}}, } patches := ApplyMethodSeq(reflect.TypeOf(e), "Retrieve", outputs) defer patches.Reset()*/conn := &redis.RedisConn{}patch1 := gomonkey.ApplyFunc(redis.NewRedisHTTP, func(serviceName string, _ string) *redis.RedisConn { conn := &redis.RedisConn{ redis.RedisConfig{}, &redis.RedisHelper{}, } return conn }) defer patch1.Reset()
// mock redis data. 返回空和不为空的情况 outputCell := []gomonkey.OutputCell{ {Values: gomonkey.Params{"12", nil}, Times: 1}, {Values: gomonkey.Params{"", nil}, Times: 1}, }patchs := gomonkey.ApplyMethodSeq(reflect.TypeOf(conn.RedisHelper), "Get", outputCell)defer patchs.Reset()
先举这几个例子,详细的可以在上面的链接文章中全面得到。
这里补充一点,对类方法进行stub,必须要找到该方法对应的真实的类(结构体),举个例子:
//被测函数中有如下一段,其中的Get方法我们想stub掉,只要找到Get方法对应的类就好了readCountStr, _ := conn.Get(redisKey)if len(readCountStr) == 0 { return 0, nil }
定位conn,是RedisConn类型的structtype RedisConn struct { RedisConfig *RedisHelper}
所以第一次,我用gomonkey.AppleyMethod时这么写:
patches := gomonkey.ApplyMethod(reflect.TypeOf(*RedisConn),"Get", func(_ *redis.RedisHelper,_ string, _ []string) ([]string, error){ return info,err_notNil })defer patches.Reset()
运行时报了空指针panic,提示RedisConn没有Get方法。
继续追,原来Get是*RedisHelper的方法,组合到了RedisConn结构体中,共用方法。但我们使用gomonkey时,需要指向真正定义它的类
func (this *RedisHelper) Get(key string) (string, error) { return redigo.String(this.Do("GET", key))
最终这么写:
patches := gomonkey.ApplyMethod(reflect.TypeOf(giftData.rankRedisRD.RedisHelper),"Get", func(_ *redis.RedisHelper,_ string, _ []string) ([]string, error){ return info,err_notNil })defer patches.Reset()
必须说一说mock了
在《xUnit Test Patterns》一书中,作者首次提出test doubles(测试替身)的概念。我们常挂在嘴边的mock只是其中一种,而且是最容易与Stub(打桩)混淆的一种。在上一节中对gomonkey的介绍,你可以注意到了,我没有使用mock,全部是Stub。是的,gomonkey不是mock工具,只是一个高级打桩的工具,适配了我们大部分的使用场景。
测试替身,共有五种:可以参考这篇翻译《xUnit Test Patterns》学习笔记6 - Test Double
用于传递给调用者但是永远不会被真实使用的对象,通常它们只是用来填满参数列表
Stubs通常用于在测试中提供封装好的响应,譬如有时候编程设定的并不会对所有的调用都进行响应。Stubs也会记录下调用的记录,譬如一个email gateway就是一个很好的例子,它可以用来记录所有发送的信息或者它发送的信息的数目。简而言之,Stubs一般是对一个真实对象的封装
Test Spy像一个间谍,安插在了SUT内部,专门负责将SUT内部的间接输出(indirect outputs)传到外部。它的特点是将内部的间接输出返回给测试案例,由测试案例进行验证,Test Spy只负责获取内部情报,并把情报发出去,不负责验证情报的正确性
针对设定好的调用方法与需要响应的参数封装出合适的对象
Fake对象常常与类的实现一起起作用,但是只是为了让其他程序能够正常运行,譬如内存数据库就是一个很好的例子。
打桩和mock应该是最容易混淆的,而且习惯上我们统一用mock去形容模拟返回的能力,习惯成自然,也就把mock常挂在嘴边了。
就我的理解,stub可以理解为mock的子集,mock更强大一些:
只不过,go的mock工具gomock只基于接口生效,不适合新闻、企鹅号项目,而gomonkey的stub覆盖了大部分的使用场景。
不要滥用mock
我把这一部分单独放一章节,表现出它重要的意义。需要读懂肖鹏的《mock七宗罪》,在gitchat上。
约从2004-2005年间,江湖上形成两大门派:经典测试驱动开发派 和 mockist(mock极端派)。
先说mockist。他主张将被测函数 所有 调用的外面函数,全部mock。也即,只关注被测函数自己的一行行代码,只要调用其他函数,全都mock掉,用假数据来测试。
再说经典测试驱动开发派,他们主张不要滥用mock,能不mock就不mock,被测单元也不一定是具体的一个函数,可能是多个函数,串起来。必要的时候再mock。
两个门派相争多年,理论各有利弊,至今仍然共存。存在即合理。比如mockist,使用了过多的mock,无法覆盖函数接口,这部分又是很容易出错的;经典派,串的太多,又被质疑是集成测试。
对于我们实际应用,不必强制遵从某一派,结合即可,需要的时候mock,尽量少mock,不用纠结。
如果一个对象具有以下特征,比较适合使用mock对象:
因此,不要滥用mock(stub),当被测方法中调用其他方法函数,第一反应应该走进去串起来,而不是从根部就mock掉了。
用例设计法
乔帮主介绍了一篇文章:像机器一样思考
文章讲述思考程序设计的根本思路——考虑输入输出。我们设计case,想要得到最全面的设计,根本是考虑全输入全输出的组合,当然,一方面,这么做耗时太大,很多时候是不可执行的;一方面,这不是想要的结果,要考虑投入产出比。这时,需要理论与实践相结合,理论指导实践,实践精细理论。
1. 还是从上篇文章说起,考虑输入、输出,就要先知道哪些属于输入输出:
2. 白盒&黑盒设计
白盒法:
黑盒法:
等价类:正确的,错误的(合法的,非法的)
边界法:[1,10] ==> 0,1,2,9,10,11(是等价类的有效补充)
3. 结合应用
全输入输出,实施难度较大,转而我们思考到业内大神们设计出白盒黑盒设计法,通过仔细思考,可以判断出是对全输入全输出的方法论体现。
因此,白盒&黑盒用例设计法,每一种我都亲自实践,理解其优缺点,从设计覆盖角度,条件组合>最小线性无关路径>条件>分支>语句。
下面这张图,是我早期思考用例设计时的一次实践,现在回忆起来,它过度设计了。
但实际中,我们担心“过度设计”,也还无法给出答案“用什么方法设计保证万无一失”。
1. 小函数&重要(计算,对象处理):尽量设计全面
2. 逻辑较重,代码行数较多:分支、语句覆盖 + 循环 + 典型的边界处理(我们看个例子:GetUserGiftList)
3. 引出“基于实现”与“基于意图”的设计:过多去Stub被测函数内部的调用,就越接近“基于实现”(第二次提到“基于意图”)
基于意图与基于实现
这个话题是非常重要的。
基于意图:思考函数最终想做什么,把被测函数当做黑盒,考虑其输出输出,而不要关注其中间是怎样实现的,究竟生成了什么临时变量,循环了几次,有什么判断等。
基于实现:输入输出我也考虑,中间怎么实现的我也考虑。mock就是一个好例子,比如我们写一个case,我们会用mock去验证函数内是否调用了哪个外部方法、调用了几次,语句的执行顺序是怎样的。程序的变动比需求还快,重构随时都有,稍有一变,case大批量失败,这也是《mock七宗罪》中提到的一种情况。
我们要的是基于意图,远离基于实现。
dot老师和乔帮主给我们上了课程,结合实战经验,我总结如下:
可以结合新闻几次单测case review记录,来详细理解。详见我的KM文章
我们看一个具体的case:
被测函数:
ret := make(map[int]int) now := library.UnixNow() for record, numStr := range giftRecord { hasNum, err := strconv.Atoi(numStr) if err != nil || hasNum < 0 { continue } detail := strings.Split(record, ":") if len(detail) != 2 { continue } itemExpire, err := strconv.ParseInt(detail[1], 10, 64) if err != nil { continue } //星星过期 if itemExpire != 0 && now > itemExpire { continue } //统计可用数目 giftId, err := strconv.Atoi(detail[0]) if err != nil { continue } if _, ok := ret[giftId]; !ok { ret[giftId] = hasNum } else { ret[giftId] += hasNum } }
正常路径的单测case
func TestNum_CorrectRet(t *testing.T) { giftRecord := map[string]string{ "1:1000": "10", "1:2001": "100", "1:999": "20", "2": "200", "a": "30", "2:1001": "20", "2:999": "200", }
expectRet := map[int]int{ 1: 110, 2: 20, }
var s *redis.xxx patches := gomonkey.ApplyMethod(reflect.TypeOf(s), "Getxxx", func(_ *redis.xxx, _ string)(map[string]string, error) { return giftRecord, nil }) defer patches.Reset()
p := &StarData{xxx } userStarNum, err := p.GetNum(10000)
assert.Nil(t, err) assert.JSONEq(t, Calorie.StructToString(expectRet), Calorie.StructToString(userStarNum))}
有同学会问到:但是你最终还是看的代码呀?看到代码的正确逻辑是怎么处理的,再去设计的case和构造数据吧?而且你不看代码,怎么知道有哪些异常分支要覆盖呢?
答:1. 我现在作为测试同学写开发同学的case,确实需要知道有哪些异常分支要处理, 但不局限于代码中的几种,还应该包括我理解到的异常分支,都要体现在case中。我们的case绝不是为了证明代码是怎么实现的!通过单测,我们经常能够发现bug。但是将来是开发来写单测的,他自己设计的函数肯定知道要覆盖哪些异常分支。
2. 嗯,我需要看代码的正常流程是怎样的,但不代表着把代码扒下来以设计出case。case实际上是通过与开发的沟通后,了解输入数据的结构,输出的格式,数据校验和计算的过程,去设计输入输出的。
用例编写的策略
对于怎么个顺序去写单测,我们重点实践了一番,基本上也就三种情况吧:
因此,考虑两方面,我们选择自下而上设计来选择函数编写case:
可测性问题的解决——重构
导致无法写单测的重要原因是,代码可测性不好。如果一个函数八九十行、二三百行,基本就是不可测的,或者说“不好测的”。因为里面逻辑太多了,从第一行到最后一行都经历了什么,各种函数调用外部依赖,各种if/for,各种异常分支处理,写一个case的代码行数可能是原函数的几倍。
因此,推动单测走下去,重构提升可测性是必须环节。而且,通过重构,代码结构间接清晰了,更可读可维护,更容易发现和定位问题。
常见的问题:重复代码、魔法数字、箭头式的代码等
推荐的理论书籍是《重构:改善既有代码的设计》第二版、《clean code》
我输出了一篇关于重构的文章。
使用codecc(腾讯代码检查中心)的圈复杂度、函数长度来评估代码结构质量,我们与开发一起学习,一起实践,不断有成果输出。
对于箭头式的代码,可考虑如下步骤:
用例维护,可读性、可维护性、可信赖性
单元测试,小而且运行快,它不是为了发现本次的bug,更是为了放在流水线上 努力发现每一次MR是否产生了bug。单测运行失败,唯一的原因只应该是出现bug,而不是因为外部依赖不稳定、基于实现的涉及等,长期的失败将失去单元测试的警示作用,“狼来了”的故事是惨痛的教训。
新闻单元测试的推动过程
我们提到,对单元测试的实践分为4个阶段,每阶段均有目标。
这里的结对是灵活的:有的开发,只需用半天的时间给他讲框架使用,同他练习,他就可以上手了不需要再担心;有的开发,会分给测试同学需求,测试同学写完case后,开发review学习,并尝试写出自己的第一个case;有的开发,一开始可能不太接受,以需求不适合单测为理由,观察了一段时间,他发现其他人都写了,也没那么难,对团队也有利,他甚至会主动找到测试同学教他写case。
流水线
单测要放在流水线上跑,客户端和后台都配好了流水线,保证每次push和MR都运行一次,发报告。
对于go的单测,新闻接入层各模块是通过MakeFile来编译,因为要导入一些环境变量,所以我将go test集成在MakeFile中,执行make test即可运行该模块下所有的测试用例。
GO = go
CGO_LDFLAGS = xxxCGO_LDFLAGS += xxxCGO_LDFLAGS += xxxCGO_LDFLAGS += xxx
TARGET =aaa
export CGO_LDFLAGS
all:$(TARGET)
$(TARGET): main.go $(GO) build -o $@ $^test: CFLAGS=-g export CFLAGS $(GO) test $(M) -v -gcflags=all=-l -coverpkg=./... -coverprofile=test.out ./...clean: rm -f $(TARGET)
注:上述做法,只能生成被测试的代码文件的覆盖率,无法拿到未被测试覆盖率情况。可以在根目录建一个空的测试文件,就能解决这个问题,拿到全量代码覆盖率。
//main_test.gopackage main
import ( "fmt" "testing")
func TestNothing(t *testing.T) { fmt.Println("ok")}
流水线加上流程
# cd ${WORKSPACE} 可进入当前工作空间目录export GOPATH=${WORKSPACE}/xxxpwd
echo "====================work space"echo ${WORKSPACE}cd ${GOPATH}/srcfor file in `ls`:do if [ -d $file ] then if [[ "$file" == "a" ]] || [[ "$file" == "b" ]] || [[ "$file" == "c" ]] || [[ "$file" == "d" ]] then echo $file echo ${GOPATH}"/src/"$file cp -r ${GOPATH}/src/tools/qatesting/main_test.go ${GOPATH}/src/$file"/." cd ${GOPATH}/src/$file make test cd .. fi fidone
附录. 资料