从头到脚说单测——谈有效的单元测试

导语 非常幸运的是,从4月份至今,我能够全身心投入到腾讯新闻的单元测试专项任务中,从无知懵懂,到不断深入理解的过程,与开发同学互帮互助,受益匪浅。在此过程中,得到了质量总监、新闻总监和乔帮主的倾囊指导,真心感谢!!我希望把所有心得,总结成一篇较为全面的文章,分享给其他团队。时刻牢记:1. 不要滥用mock 2. 基于意图。

在我们谈到单元测试,大都清楚是测试函数符合预期,国外很多大公司都将单测执行的很好,国内成功的案例则相对有限。在本文中,笔者将在腾讯新闻项目中亲身经历单测从无到有的实践过程梳理为可读可参考的经验分享出来。在实践的过程我发现,单测可以推动产品质量转为优秀,推动实行它的过程更需要对它有真实的认识以及一套方法论。

为单元测试“正名”

我曾经认为,单元测试面向的是一个函数。任何走出一个函数的测试,都不是单元测试。

其实,对“单元”的定义取决于自己。如果你正在使用函数式编程,一个单元最有可能指的是一个函数。你的单元测试将使用不同的参数调用这个函数,并断言它返回了期待的结果;在面向对象语言里,下至一个方法,上至一个类都可以是一个单元(从一个单一的方法到一整个的类都可以是一个单元)。意图很重要(“意图”二字是本文中第一次提到,它很重要)

我们有单元测试、增量测试、集成测试、回归测试、冒烟测试等等,名字非常多。谷歌看到这种“百家争鸣”的现象,创立了自己的命名方式,只分为小型测试中型测试大型测试

  • 小型测试,针对单个函数的测试,关注其内部逻辑,mock所有需要的服务。小型测试带来优秀的代码质量、良好的异常处理、优雅的错误报告
  • 中型测试,验证两个或多个制定的模块应用之间的交互
  • 大型测试,也被称为“系统测试”或“端到端测试”。大型测试在一个较高层次上运行,验证系统作为一个整体是如何工作的。

资源

小型测试

中型测试

大型测试

网络访问

仅访问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中去掉。

越是底层的测试,牵扯到相关内容越少,而高层测试则涉及面更广。比如单元测试,它的关注点只有一个单元,而没有其它任何东西。所以,只要一个单元写好了,测试就是可以通过的;而集成测试则要把好几个单元组装到一起才能测试,测试通过的前提条件是,所有这些单元都写好了,这个周期就明显比单元测试要长;系统测试则要把整个系统的各个模块都连在一起,各种数据都准备好,才可能通过。

另外,因为涉及到的模块过多,任何一个模块做了调整,都有可能破坏高层测试,所以,高层测试通常是相对比较脆弱的,在实际的工作中,有些高层测试会牵扯到外部系统,这样一来,复杂度又在不断地提升。

为什么做单测

这个问题我们规避不掉。新闻是这次研发模式改革的主力军之一,所以自上而下的推动让这个问题不那么棘手:做了就是做了。不做,却又有那么多的理由:(搜集到的吐槽真实声音)

  • 单元测试浪费了太多的时间
  • 单元测试仅仅是证明这些代码做了什么
  • 我是很棒的程序员,我是不是可以不进行单元测试?
  • 后面的集成测试将会抓住所有的bug
  • 单元测试的成本效率不高我把测试都写了,那么测试人员做什么呢?
  • 公司请我来是写代码,而不是写测试
  • 测试代码的正确性,并不是我的工作

我觉得我们总监指导的很到位:改革,一是工作方式的改革,更难的是思想上的改革。

单元测试的意义

新闻的总监dot老师是至始至终推进单测的好领导,他讲述了螺丝钉与飞机的故事:干货 | 测试扁平化之必备神器:好的单元测试

  • 单元测试对我们的产品质量是非常重要的。
  • 单元测试是所有测试中最底层的一类测试,是第一个环节,也是最重要的一个环节,是唯一一次有保证能够代码覆盖率达到100%的测试,是整个软件测试过程的基础和前提,单元测试防止了开发的后期因bug过多而失控,单元测试的性价比是最好的。
  • 据统计,大约有80%的错误是在软件设计阶段引入的,并且修正一个软件错误所需的费用将随着软件生命期的进展而上升。错误发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。作为编码人员,也是单元测试的主要执行者,是唯一能够做到生产出无缺陷程序这一点的人,其他任何人都无法做到这一点
  • 代码规范、优化,可测试性的代码
  • 放心重构
  • 自动化执行three-thousand times

下面这张图,来自微软的统计数据:bug在单元测试阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时。

下面这张图,旨在说明两个问题:85%的缺陷都在代码设计阶段产生,而发现bug的阶段越靠后,耗费成本就越高,指数级别的增高。所以,在早期的单元测试就能发现bug,省时省力,一劳永逸,何乐而不为呢

单元测试特别耗时?

不能一刀切,不能只盯着单测阶段的耗时

我采访了新闻客户端、后台的开发,首先肯定的是,单测会增加开发量、增加开发时长。

在《单元测试的艺术》这本书提到一个案例:找了开发能力相近的两个团队,同时开发相近的需求。进行单测的团队在编码阶段时长增长了一倍,从7天到14天,但是,这个团队在集成测试阶段的表现非常顺畅,bug量小,定位bug迅速等。最终的效果,整体交付时间和缺陷数,均是单测团队最少。

单测,存在即合理。一方面,需要把单测放在整个迭代周期来观测其效果;一方面,写单测也是技术活,写得好的同学,时间少代码质量高(也即,不是说写了单测,就能写好单测)

谁来写单测呢?

  • 开发同学写单测
  • 测试同学具有写单测的能力。重点在于开发脚手架、分层测试/端到端测试

增量还是存量

  • 单测case针对增量代码
  • 当存量代码出现大规模重构,后者质量暴露出极大风险时,都是推动补全单测的好时机

单元测试的阶段

一. 广义的单元测试,我们指这三部分的有机组合:

  • code review
  • 静态代码扫描
  • 单元测试用例编写

二. 结合新闻的实践,我把单测成长的过程分为4个目标,分别为:

  • 会写,全员可写
  • 写的好,同时关注可测性问题,试点解决
  • 识别可测性问题,熟练使用重构方法进行重构;识别代码架构设计问题;case与业务代码同步编写
  • TDD。但这个目标是期望,不能作为必须实现的目标。

截至发稿当天,新闻处于第三阶段,即,每个迭代均能产出高质量的case,人数覆盖和需求覆盖均较高;关注重点在于可测性,时刻注重重构。

单元测试的指标

还挺尴尬的,不太有直接的指标去衡量单测的效果。我们也经常被问到,“怎么证明你们新闻单测的作用呀?”

  • bug类指标(间接指标):连续迭代的bug总数趋势、迭代内新建bug的趋势、千行bug率
  • 单测的需求覆盖度(50%以上),参与人员覆盖度(80%以上)
  • 单测case总数趋势,代码行增量趋势
  • 增量代码的行覆盖率(接入层80%,客户端30%)
  • 单函数圈复杂度(低于40),单函数代码行数(低于80),扫描告警数

在迭代需求持续高吞吐量的前提下,以新闻iOS的数据为例:

go单元测试框架选型

基本选型:testify + gomonkey

附加:httptest + sqlmock

前提

  • 测试文件,以_test.go结尾,与被测文件放于相同目录
  • 测试函数,函数名以Test开头,并且随后的第一个字符必须为大写字母或下划线,如:TestParseReq_CorrectNum_TableDriven
  • 测试函数,参数为t *testing.T;对于bench测试,参数为b *testing.B
  • 运行命令行,我的文章有深入讲解:go test命令行

testify常规用法

https://github.com/stretchr/testify

testify基于gotesting编写,所以语法上、执行命令行与go test完全兼容

  • 支持大量高效的api,比如:

    assert.Equal:常规对比,是把两者分别换成[]byte去严格比对

    assert.Nil:判断对象为nil时,有时对err判空时也用

    assert.Error:判断err的具体类型和内容

    assert.JSONEq:这个比较有用,对比map时;或者对比struct的时候,也会先转为map,在用这个api去做对比,如下面这个例子,我封装了建议的方法去将struct转换为string(json):

  • 支持suite,用例集管理
  • 运行时,可以指定用例集执行
  • 自带mock工具,但只支持接口方法的mock,而且用法相对复杂
  • table-driven

gomonkey用法(加粗字体表示常用)

https://github.com/agiledragon/gomonkey

https://studygolang.com/articles/15034

  • 支持为一个函数打一个桩
  • 支持为一个成员方法打一个桩
  • 支持为一个全局变量打一个桩
  • 支持为一个函数变量打一个桩
  • 支持为一个函数打一个特定的桩序列
  • 支持为一个成员方法打一个特定的桩序列
  • 支持为一个函数变量打一个特定的桩序列
  • table-driven的方式定义一系列stub

注意,对内联函数的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了

test doubles

在《xUnit Test Patterns》一书中,作者首次提出test doubles(测试替身)的概念。我们常挂在嘴边的mock只是其中一种,而且是最容易与Stub(打桩)混淆的一种。在上一节中对gomonkey的介绍,你可以注意到了,我没有使用mock,全部是Stub。是的,gomonkey不是mock工具,只是一个高级打桩的工具,适配了我们大部分的使用场景。

测试替身,共有五种:可以参考这篇翻译《xUnit Test Patterns》学习笔记6 - Test Double

  • Dummy Object:

    用于传递给调用者但是永远不会被真实使用的对象,通常它们只是用来填满参数列表

  • Test Stub

    Stubs通常用于在测试中提供封装好的响应,譬如有时候编程设定的并不会对所有的调用都进行响应。Stubs也会记录下调用的记录,譬如一个email gateway就是一个很好的例子,它可以用来记录所有发送的信息或者它发送的信息的数目。简而言之,Stubs一般是对一个真实对象的封装

  • Test Spy

    Test Spy像一个间谍,安插在了SUT内部,专门负责将SUT内部的间接输出(indirect outputs)传到外部。它的特点是将内部的间接输出返回给测试案例,由测试案例进行验证,Test Spy只负责获取内部情报,并把情报发出去,不负责验证情报的正确性

  • Mock Object

    针对设定好的调用方法与需要响应的参数封装出合适的对象

  • Fake Object

    Fake对象常常与类的实现一起起作用,但是只是为了让其他程序能够正常运行,譬如内存数据库就是一个很好的例子。

stub与mock

打桩和mock应该是最容易混淆的,而且习惯上我们统一用mock去形容模拟返回的能力,习惯成自然,也就把mock常挂在嘴边了。

就我的理解,stub可以理解为mock的子集,mock更强大一些:

  • mock可以验证实现过程,验证某个函数是否被执行,被执行几次
  • mock可以依条件生效,比如传入特定参数,才会使mock效果生效
  • mock可以指定返回结果
  • 当mock指定任何参数都返回固定的结果时,它等于stub

只不过,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对象:

  • 该对象提供非确定的结果(比如当前的时间或者当前的温度)
  • 对象的某些状态难以创建或者重现(比如网络错误或者文件读写错误)
  • 对象方法上的执行太慢(比如在测试开始之前初始化数据库)
  • 该对象还不存在或者其行为可能发生变化(比如测试驱动开发中驱动创建新的类)
  • 该对象必须包含一些专门为测试准备的数据或者方法(后者不适用于静态类型的语言,流行的Mock框架不能为对象添加新的方法。Stub是可以的。)

因此,不要滥用mock(stub),当被测方法中调用其他方法函数,第一反应应该走进去串起来,而不是从根部就mock掉了。

用例设计法

乔帮主介绍了一篇文章:像机器一样思考

文章讲述思考程序设计的根本思路——考虑输入输出。我们设计case,想要得到最全面的设计,根本是考虑全输入全输出的组合,当然,一方面,这么做耗时太大,很多时候是不可执行的;一方面,这不是想要的结果,要考虑投入产出比。这时,需要理论与实践相结合,理论指导实践,实践精细理论。

先说理论

1. 还是从上篇文章说起,考虑输入、输出,就要先知道哪些属于输入输出:

2. 白盒&黑盒设计

白盒法:

  • 逻辑覆盖(语句、分支、条件、条件组合等)
  • 路径(全路径、最小线性无关路径)
  • 循环:结合5种场景(跳过循环、循环一次,循环最大次,循环m次命中、循环m次未命中)

黑盒法:

等价类:正确的,错误的(合法的,非法的)

边界法:[1,10] ==> 0,1,2,9,10,11(是等价类的有效补充)

3. 结合应用

全输入输出,实施难度较大,转而我们思考到业内大神们设计出白盒黑盒设计法,通过仔细思考,可以判断出是对全输入全输出的方法论体现。

因此,白盒&黑盒用例设计法,每一种我都亲自实践,理解其优缺点,从设计覆盖角度,条件组合>最小线性无关路径>条件>分支>语句。

下面这张图,是我早期思考用例设计时的一次实践,现在回忆起来,它过度设计了。

但实际中,我们担心“过度设计”,也还无法给出答案“用什么方法设计保证万无一失”。

  • 过度设计,也会使case脆弱
  • 在有限的时间内,我们寻求收益较大化

1. 小函数&重要(计算,对象处理):尽量设计全面

2. 逻辑较重,代码行数较多:分支、语句覆盖 + 循环 + 典型的边界处理(我们看个例子:GetUserGiftList)

3. 引出“基于实现”与“基于意图”的设计:过多去Stub被测函数内部的调用,就越接近“基于实现”(第二次提到“基于意图”)

基于意图与基于实现

这个话题是非常重要的。

基于意图:思考函数最终想做什么,把被测函数当做黑盒,考虑其输出输出,而不要关注其中间是怎样实现的,究竟生成了什么临时变量,循环了几次,有什么判断等。

基于实现:输入输出我也考虑,中间怎么实现的我也考虑。mock就是一个好例子,比如我们写一个case,我们会用mock去验证函数内是否调用了哪个外部方法、调用了几次,语句的执行顺序是怎样的。程序的变动比需求还快,重构随时都有,稍有一变,case大批量失败,这也是《mock七宗罪》中提到的一种情况。

我们要的是基于意图,远离基于实现

dot老师和乔帮主给我们上了课程,结合实战经验,我总结如下:

  1. “要么写好,要么不写”。case也是代码,也需要维护,也有工作量,所以要写的到位,而不是写得多。写了一堆没用的,你还得维护,不如删了。
  2. 拿到一个函数,先问问自己,这个函数要实现什么功能,最终输出是什么;然后,问自己,这个函数的风险在哪里,哪部分逻辑不太自信,最容易出错(计算、复杂的判断、某异常分支的命中等)。这些才是我们case要覆盖的点。
  3. 内联函数、直接get/set,没几行没什么逻辑的,只要你判断没什么风险,就不用写case。
  4. 确定了要写的case,再用分支条件组合、边界等核心方面设计出具体用例,实施编写。

可以结合新闻几次单测case review记录,来详细理解。详见我的KM文章

我们看一个具体的case:

  1. 拿到这个函数,作为测试同学的我先向开发了解该函数的意图:对符合格式、符合时间的用户礼物进行加和
  2. 读代码,了解了代码流程、几个异常分支,先做了code review
  3. 根据必要的异常分支,设计case覆盖
  4. 对正常的业务流程,是按照开发讲述的函数意图,进行设计,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实际上是通过与开发的沟通后,了解输入数据的结构,输出的格式,数据校验和计算的过程,去设计输入输出的。

用例编写的策略

对于怎么个顺序去写单测,我们重点实践了一番,基本上也就三种情况吧:

  • 独立原子:mockist,被我们推翻了。当然,最底部的函数可能没有外部依赖,那单测它就够了。
  • 自上而下(红线):从入口函数往下测。实践的过程中,我发现很难执行,因为我从入口处就要想好每一次调用都需要返回哪些数据及格式,串起来一个case已经非常不易。
  • 自下而上(黄线):我们发现,入口函数,往往没什么逻辑,调用另一个函数然后拿到响应返回。所以入口函数,也许不用写?我们继续往下看,每一次调用的函数都看,也调出了以往的线上线下bug,我们发现出现问题的代码部分往往是调用链的底端,尤其是涉及计算、复杂分支循环等。而且,底端的函数往往可测性较好。

因此,考虑两方面,我们选择自下而上设计来选择函数编写case

  1. 底部的函数可测性通常很好
  2. 核心逻辑比较多,尤其涉及计算、拼接,分支的。

可测性问题的解决——重构

导致无法写单测的重要原因是,代码可测性不好。如果一个函数八九十行、二三百行,基本就是不可测的,或者说“不好测的”。因为里面逻辑太多了,从第一行到最后一行都经历了什么,各种函数调用外部依赖,各种if/for,各种异常分支处理,写一个case的代码行数可能是原函数的几倍。

因此,推动单测走下去,重构提升可测性是必须环节。而且,通过重构,代码结构间接清晰了,更可读可维护,更容易发现和定位问题。

常见的问题:重复代码、魔法数字、箭头式的代码等

推荐的理论书籍是《重构:改善既有代码的设计》第二版、《clean code》

我输出了一篇关于重构的文章。

使用codecc(腾讯代码检查中心)的圈复杂度、函数长度来评估代码结构质量,我们与开发一起学习,一起实践,不断有成果输出。

对于箭头式的代码,可考虑如下步骤:

  1. 多使用卫语句,先判断异常,异常return
  2. 将判断语句抽离
  3. 将核心部分抽离为函数

用例维护,可读性、可维护性、可信赖性

用例设计要素

  • 将内部逻辑与外部请求分开测试
  • 对服务边界(interface)的输入和输出进行严格验证
  • 用断言来代替原生的报错函数
  • 避免随机结果
  • 尽量避免断言时间的结果
  • 适时使用setup和teardown
  • 测试用例之间相互隔离,不要相互影响
  • 原子性,所有的测试只有两种结果:成功和失败
  • 避免测试中的逻辑,即不该包含if、switch、for、while等
  • 不要保护起来,try…catch…
  • 每个用例只测试一个关注点
  • 少用sleep,延缓测试时长的行为都是不健康的
  • 3A策略:arrange,action,assert

用例可读性

  • 标题要明确表明意图,如Test+被测函数名+condition+result。case失败后,通过名字就知道哪个场景失败,而不用一行行再读代码。将来维护这个测试代码的,可能是其他人,我们需要让别人容易读懂
  • 测试代码的内容要清晰,3A原则:arrange,action,assert 分成三部分。数据准备部分arrange如果代码行较多,考虑抽离出去。
  • 断言的意图明显,可以考虑将魔法数字变为变量,命名通俗易通
  • 一个case,不要做过多的assert,要专一
  • 和业务代码的要求一致,都要可读

用例可维护性

  • 重复:文本字符串重复、结构重复、语义重复
  • 拒绝硬编码
  • 基于意图的设计。不要因为业务代码重构一次,就导致一批case失败
  • 注意代码的各种坏味道,可参见《重构》第二版

用例可信赖性

单元测试,小而且运行快,它不是为了发现本次的bug,更是为了放在流水线上 努力发现每一次MR是否产生了bug。单测运行失败,唯一的原因只应该是出现bug,而不是因为外部依赖不稳定、基于实现的涉及等,长期的失败将失去单元测试的警示作用,“狼来了”的故事是惨痛的教训。

  • 非被测程序缺陷,随机失败的case
  • 永不失败的case
  • 没有assert的case
  • 名不副实的case

 新闻单元测试的推动过程

我们提到,对单元测试的实践分为4个阶段,每阶段均有目标。

第一阶段  会写,全员写,不要求写好

  • 由上而下的推动,从总监到组长,极力支持,毫无犹豫,使组员情绪高涨
  • 快速确定单测框架,熟练使用
  • 结合开发需求,输出各场景下 单测框架的使用方法,包括assert、mock,table-driven等
  • 封装http2WebContext,方便生成context对象
  • 多次培训,讲解单测理论及框架使用
  • 各团队(终端、接入层)指定单测接口人,由他先尝螃蟹。他是最熟悉框架使用,在前期写最多case的人
  • 在磨合好单测框架的集成使用后,启动会,部分同学先试点使用,确保连续两个迭代,这几个同学都有case输出
  • 每个迭代总结数据中,加入单测相关数据:组长和总监非常关注单测数据信息,针对性鼓励提升case数量和代码行数

第二阶段 写好,有效,全员写

  • 测试同学探索出mock的正确使用方法、用例设计的正确思路,分享给团队,经过探讨达成一致
  • 结对编程,每迭代结对2-3个开发,共同写case,互相提升。

这里的结对是灵活的:有的开发,只需用半天的时间给他讲框架使用,同他练习,他就可以上手了不需要再担心;有的开发,会分给测试同学需求,测试同学写完case后,开发review学习,并尝试写出自己的第一个case;有的开发,一开始可能不太接受,以需求不适合单测为理由,观察了一段时间,他发现其他人都写了,也没那么难,对团队也有利,他甚至会主动找到测试同学教他写case。

  • 测试同学对开发提交的case进行review,跟进开发修改后重新MR
  • 连续两个迭代,邀请dot老师、乔帮主进行case review,效果非常好
  • 对迭代的单测数据分析,关注需求覆盖度、人员覆盖度,case增量
  • 组长持续鼓励支持单测
  • 每迭代的需求增加“单元测试”字段,由组长评估后置位。不带单测的MR不予通过,单测也要被review

第三阶段 可测性提升

  • 测试和开发共同学习《重构》第二版,每周有分享会
  • 某些骨干同学优先重构自己的代码
  • 测试同学严格要求,先保证有单测,然后小步重构,每一步均有单测保障
  • 通过流水线的codecc扫描,圈复杂度和函数长度必须达标,不可人工干预其通过

第四阶段 TDD

  • 先不保证开发同学做到TDD,门槛还是挺高的,而且需要在线下熟练之后再运用到业务开发中
  • 逐步推动开发将业务代码和测试代码同步编写,而不是完成业务代码后再补case
  • 测试同学练成TDD

流水线

单测要放在流水线上跑,客户端和后台都配好了流水线,保证每次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

 附录. 资料

  • 《测试驱动开发》
  • 《单元测试的艺术》
  • 《有效的单元测试》
  • 《重构,改善既有代码的设计》
  • 《修改代码的艺术》
  • 《测试驱动开发的三项修炼》
  • 《xUnit Test Patterns》
  • mock七宗罪

原文发布于微信公众号 - 腾讯技术工程(Tencent_TEG)

原文发表时间:2019-08-20

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券