TDD笔记1
06 Jul 2014 by LelouchHe
缘起
本文是对tdd或单元测试的一个小结.以前尝试过很多次,但都没有具体的文字记录下来,导致的结果就是不停的对相同的概念和流程进行重复学习
重点是掌握怎么写单元测试
其实单元测试是比较容易写的(how),比如利用gtest框架,但写什么,测试什么(what)是个比较难搞的问题.平时工作接触到的代码而言,感觉都非常难于测试,如何分解重构历史代码,如何编写新的功能和测试代码,这些方面都不是很了解,所以希望通过本次的学习,至少有个明确的认识
本文是对Modern C++ Programming with Test-Driven Development的部分笔记,夹杂一些个人想法
TDD循环
典型的TDD是由一系列类似的循环(cycle)构成的,每个循环分为失败(Red)/成功(Green)/重构(Refactor)三个部分
- Red 按需求写一个test,并且保证该test会失败
- Green 编写代码,使新test和以前所有的test都成功
- Refactor 在保证所有test成功的前提下,对现有代码进行重构
基本的流程就是上述循环的不断进行,类似软件工程当中所说的迭代开发,每次循环都会前进一小步,然后根据这一小步,来调整前进的方向
TDD单步思考
TDD循环的每个步骤中,都有一些重要问题需要思考
- 编写test(Red) 目前系统需要开发的最小的功能是什么?该功能是否已经存在呢?该功能可以很容易的利用当前系统的接口实现么?该功能足够小,并且可以用合适的test名字表达么?
- 保证新test失败(Red) 该test失败了么?没失败的话,原因是什么?是否以前的开发步子太大了呢?是否断言的问题是错误的呢?
- 让新test成功(Green) 代码除了让test成功之外,有没有意外的添加其他功能呢?是否过早的考虑到了性能/风格之类的非本步骤的问题呢?
- 让所有test成功(Green) 以前的test是否还能成功呢?不成功的话,是哪里导致的?新加代码是否修改了原有行为?是否本次开发的功能太大了呢?或者需求是矛盾的呢?
- 重构代码(Refactor) 代码风格是否统一呢?系统的结构是否合理呢?代码中是否有bad smell?系统迭代的方向是否合适呢?
- 确保test仍然成功(Refactor) 没有成功的话,是什么原因导致的呢?
TDD循环是需要严格遵守的,一个步骤没有完成,是绝对不能进行下一步的,而且,步骤是要小且单一的,不要妄图同时完成很多任务,并且要保证通过所有test
test结构
所有的test需要遵循固定的pattern,即通常所说的Given-When-Then
- Given test的上下文环境,比如依赖的某些库的引入,使用到的某些变量的初始化,行为需要的前提条件之类的
- When 执行需要测试的行为.通常来说,是单个的函数调用
- Then 验证行为的结果是否正确.通常来说,就是对函数调用的结果进行验证
一般来说,test要小,5-6行足以,如果太多的话,可能暗示该test并非足够小,需要进一步的拆分
三条规则
- 没有test失败,就不需要编写新的代码
- 编写一个足以失败的test即可(编译失败也算)
- 编写代码,仅能让失败的test通过即可
步骤1表示,test集是明确和完善开发需求的唯一准则,所有test都成功了,就表明所有需求满足,也就不需要开发(当然,重构还是可以的).而且在后续的开发中,也是通过test集来保证需求一直是满足的(即不会break以前的需求).同时也说明,添加功能,第一步是编写对应的test,无test,无开发
步骤2的重点在于足以,即编写的test越小越好(当然不一定指test的代码多少),迭代的步骤越小越不容易出错,越容易开发.个人感觉,很好的指标是test代码行数和编译器的error/warning数量,行数一般不要多于5-6行,仅包涵一个关键断言即可,error/warning也只能有一个
步骤3的重点在于仅能,即编写足够的代码让这唯一的test成功即可,不多不少.太少的话,肯定无法让test成功,太多的话,肯定是实现了多余的功能,但这些功能又没有相关的test来保证,所以可以说是做了无用功了
当然,这些规则并不是教条,比如在很大的C++代码集中,编译成本很高,可能编译失败就无法很实际的作为失败的test了.
开发是件艺术的工作,如果真的有一步一步的确定的规则的话,那岂不是可以编写一个算法,让计算机来帮我们搞定了么?
新test没有失败
一般来说,编写新test是为了给系统增加新的功能,但在实际开发中,会碰到这样的情况,新test直接成功了.这大部分时间不是个好的情况,一般来说,可以从以下角度来分析看看
- 没有运行新test 这种情况的话比较silly,编写的新test,但是没有把新test包含进去.我们可以通过比对运行的test名称和数量来判断
- 被测试的代码有误 比如代码build就失败了,所以可能没有test的情况(所以就没有test失败),再比如说,重构代码中,test集编译时连接了旧的代码,此时自然肯定是成功的.这就需要谨慎的处理各种编译信息,对于各种warning/error和编译路径都需要看清
- 错误的test 新test要引入功能,需要断言来确保功能的正确,但是如果我们错误理解,或错误编写了断言逻辑,那么有可能本来错误的test,就直接成功pass了.这个时候,需要重新对照需求/todo list,看测试逻辑是否有误(这也是为什么test需要小的原因,小才容易发现逻辑上的问题)
- 系统假设错误 新test可能依赖于系统的其他部分,而我们做出的test逻辑的依赖可能和系统的假设不同.比如test依赖的接口的功能,可能不止我们想象的一样,所以依赖于此,使我们做了错误假设.这个一般比较容易验证
- 测试顺序错误 系统功能之间可能有重叠(比如需求不明或者功能切割有误,或者迭代时选择方向有误),以文中例子而言,isEmpty()和size()的功能上是有耦合的(size() == 0 <=> isEmpty() == true),当开发任一功能时,很可能就直接把另一个功能直接实现了,当测试另一个功能时,可能就是直接pass.此处的解决方案有二,一是回顾下test集,思考是否有必要加入该test,比如相互重叠甚至重复的test是可以去掉的;二是严格遵守Green原则,不要过度开发代码,仅仅让test成功即可,比如isEmpty就可以使用is_empty_来表示,而不是更通用的size_,以后重构时再考虑这些问题
- 测试功能耦合 同上一个角度类似,此处表示的是功能的耦合,不管是出于需求也好,实现角度也好,功能之间有时会有耦合的地方,可能新test测试的正好是这些耦合功能,所以直接就成功了.此时的处理方法就是用test来保证这种耦合性.比如新建isEmptyWhenSizegt0来确保二者的关系,这样就可以一劳永逸的放心了
- 过度编码 也就是说在修复旧test集时,已经将其他非test功能一并实现了,所以此时新test直接成功了.过度编码并不是说不对,因为可能不论从设计还是开发角度来看,有些test非常trival(比如缺少类/接口导致的编译错误),我们直接倾向于一下子就搞定,但这样就会带来一系列不需要或者没有test覆盖的功能添加到系统中来,这个问题比多花些时间来小步骤迭代前进要严重的多.所以还是要小步前进,严格遵循Red/Green/Refactor的循环来做
- 信心测试 除去新加功能外,test集还可以帮助我们增强多系统的信心,比如功能涉及某一复杂算法,本来已经通过所有的test集了,但我们不放心,此时,我们可以添加新的test来验证算法的正确性.不过注意,此时我们运行测试前就预计会成功,没有成功反而说明原先test集不完善,属于验证性质的测试
除去以上的问题,时刻要牢记TDD三段循环迭代的方式,test要足够小,实现代码也要足够小(仅仅让test成功即可),只有迭代步骤小才能保证系统功能的正确性和系统结构的合理性
(恩,号称:步子大了,容易扯着蛋)
实行TDD的准则
有一些抽象的原则,可以在实行TDD时,确保不会偏离方向,或者引入不必要的错误.需要注意的是,TDD不只是测试系统功能,还可以辅助系统设计的演化
渐进迭代
TDD的核心是迭代,通过小步快走,来完成系统功能的开发.系统原有的功能通过旧test集来保证,只要旧test集没有break,就保证了系统原有功能的正确;新的功能通过新的test来验证,开发时,我们面对的不再是无穷的需求,而仅仅是这一个小的失败的test,所做的也仅仅是让这一个test成功pass即可,no more, no less
在开发的某个阶段,test集就不仅是功能的保证,还是代码化的系统需求.当然,有人也许会在系统开发初期把所有的test集写好,但一来无法保证系统需求是否变化,二来整个系统都不存在,凭空抽象出对应test集,基本上对于系统设计的开发一点帮助都没有,三来这样的难度太大,而且很容易最后什么都得不到
这就是敏捷实践中的迭代的由来,小步快走,灵活应变.当然,TDD不仅可以在敏捷实践中使用,但在任何实践中,渐进迭代都是系统开发的一个关键性步骤
测试行为,而非函数
使用测试时,一个常见错误是添加了一个函数,然后编写测试来验证函数功能.且不说这个顺序就错了(应该是先编写test,再开发对应的功能,函数/类之类的都随意),这样的test往往很复杂,因为函数中免不了要处理各种情况,这些都要集合在一个test里
因此测试的重点是行为,即一个test,对应一种情况,最后来看的话,可能一个函数对应若干描述其行为的test集,这样不仅test集更简单,而且更加容易理解对应的实际需求(毕竟,需求其实就是各种行为,你用几个函数实现,是后话了)
注意,永远要让test简单(最好5-6行代码,验证一个行为)
使用测试集来描述行为
类似于上一个,测试集不止需要描述对应实现的行为,同时还是整个系统行为的描述.一般而言,系统的功能和行为是通过所谓需求文档来表达的,但需求文档是虚无缥缈的,无法证实和除错,此时就需要通过测试集来保证这一点(如果我们的测试都是描述行为的话)
着眼于行为,而不是背后的实现,这是测试集的一个重点
比如,我们要实现一个复数加法系统,有哪些行为呢?
- 构建复数,分三种行为,纯实数/纯序数/复合形式
- 复数复制和赋值
- 复数相等判断
- 复数相加,同样进行区分,复数之间,复数与纯实数之间,复数与纯序数之间,纯实数和纯虚数之间
这样,系统大的行为有4种,继续拆分下去,就有10种小的行为,那么就会对应至少10个test来分别验证其功能
测试集就是这样逐渐演变为需求文档的
(当然,此处直接的列出所有test集是不对的,还是要记住小步快走的渐进迭代原则)
保持简单
编写test和实现代码,大多数时间是比较容易的,困难的部分在于如何遵循TDD循环,编写简单的test和实现.
由于简单的test有可能很trival,导致了我们开发时往往一上手就开始从复杂入手.比如不是从单一功能入手,而是一开始就实现复合功能.这样看起来可能非常快,但最后如果需求变动或理解错误或其他原因,返工的比例非常的高,而且丢失了测试集的需求说明性质,有可能做到最后,我们仍然不太清楚的了解系统究竟是个什么样子
时间/项目压力,历史遗留问题,看似简单不变的需求等等,都不能作为以复杂方式处理问题的借口.复杂的代码只能通过简单的代码来简化,层叠起来的复杂,只会变得越来越复杂,而不是反之.
Simple Design和Simple Implementation才是解决问题的方案,保持简单,应对隐晦和变动的需求,以及复杂的历史问题,才会带来最终问题的简化
K.I.S.S.
坚持循环
坚持实践TDD的Red/Green/Refactor循环,直到它变成开发的一个必须步骤:
- 分解问题,将需求变为一系列的todo项
- (Red)为新的功能编写test,能fail即可
- (Green)编写代码,让该test成功即可
- (Refactor)重构代码,改善bad smell,同时保证已有test全部成功
- 回到步骤1
无测试,不开发;小测试,小开发;全通过,就重构
保证TDD的机制
准则是一系列抽象的思维方式,下面介绍一些常见的开发手段,用以解决开发中经常碰到的问题
下一个测试是什么?
对于下一个test的选择并没有特别的准则,一般来说要问自己以下几个问题:
- 下一个逻辑上最有意义的行为是什么?
- 关于这个行为,能验证的最小子行为是什么?
- 是否能编写一个test,证明现有系统无法满足该行为?
这三个问题的最后答案,就是下一个要编写的test.我们仍然以复数相加来作为例子:
- 下一个逻辑上最有意义的行为是什么? 复数相加的行为
- 关于这个行为,能验证的最小子行为是什么? 复数相加包括复数,纯实数,纯虚数的相加,最简单的应该是复数之间的相加
- 是否能编写一个test,证明现有系统无法满足该行为? 可以,就是两个数都是复合复数(有实有虚),进行相加
这样,我们的下一个test就出来了
当然,这还是一个比较抽象的规则,并不能满足所有的情况,也只能通过不断的练习来培养寻找下一个test的感觉
十分钟限制
给每次TDD循环设置一个时间限制,因为每次TDD循环都是小步快走型的,所以每次的时间不应该太久,如果在某此实现/重构中耗时太多,那么有2种可能性:
- 新加test过于复杂,或者对新加test的实现过于复杂.对于小步快走的TDD循环来说,二者都是bad smell,都是错误的行为.一定要清楚,此处的复杂并不代表你的聪明,相反,你会为现在的小聪明付出大代价
- 系统重构过于复杂.也就是在Refactor阶段,将系统的复杂度进行了过高的估计,导致重构无法顺利完成.一定要记住,我们遵循的是Simple Design和Simple Implementation,只要enough且没有bad smell即可
这两种情况都告诉我们应该放弃,所以理智的操作是直接revert,从上次全Green的代码重新开始TDD循环,顺便休息休息,放松下脑子
这个需要我们同时使用一个版本控制系统,在Green和Refactor两个阶段都要提交代码,一旦某次超时,就直接放弃即可.此处的10min只是一个估值,为的是保证这段时间能编写的代码量不多,可以随时revert,而没有太大的损失
处理bug
bug是开发中不可避免的,不过正确且规范的使用TDD,可以保证至少逻辑上的bug是不存在的(除非是那些我们自己也没有理解的逻辑,否则这些肯定就写入test集了),可能存在的应该就是一些很难避免的bug了,比如意外情况,外部数据有误,需求理解有误,需求变动之类的.这些问题就需要更严密的讨论和研究,也可能需要借助QA团队和更高层面的测试辅助
当遇到类似bug反馈时,我们不应该直接来修改代码,还是要遵循TDD循环,通过写test来确定行为,通过小步快走的方式来进行改进,以此来保证不会再产生类似的bug
TDD循环,是始终要遵照执行的,即便在修改bug
禁用测试
在Green/Refactor阶段修改和新增代码时,有可能修改了系统的其他行为,导致其他test失败,此时我们就有多余1个的test需要成功通过,很多时候就不能完成既定的循环了,此时就要面临选择:
- 测试之间是耦合的,所以修改才会导致其他test失效.既然耦合,就需要重新确定测试的逻辑了(还记得上文提到的测试间耦合的情况么?),或者调整测试顺序,修改原test集,或者新增test,确保test之间的耦合是可测试的
- 测试之间是无关的,只是我们错误的修改其代码.这种情况要不然是我们改错了,要不然就是系统结构的问题,导致牵一发而动全身.所以看看是否应该先revert,继续重构代码,再进入下一轮的循环中
这两种情况,都需要我们禁掉某些测试,来保证循环可以进行下去.一般来说,test集是不能变动的(除非test集出错,看清楚,不是逻辑出错,而是test集本身描述的行为错了),如果试图暂时不要处理某个test,禁用即可.在gtest中,只需要将test名称前加上”DISABLED_“即可
但注意,禁用测试不代表不进行处理,只是在处理完毕当前的test之后,马上需要解决的,所以一般不能入库
除去这种手动修改代码的方法外,gtest还提供了一种过滤器(filter)的方式來决定执行哪些test.使用方法是在执行test文件时,添加”–gtest_filter”参数,比如:
./sample_test --gtest_filter=*Run*.*:*AnotherRun*.*:-NoRun.*
gtest_filter以”:”分割为独立的部分,每部分都是”xx.yy”的格式,xx表示test suite的 名称,yy表示具体test case名称,可以使用通配符”*“,如果该部分以”-“开头,表示忽略这部分test.只有匹配的test才会执行
通常来讲,这种办法不同于”DISABLE”测试,即我们还是想运行所有测试的,只不过由于某些原因(比如某些test太慢了),需要把test集分成几个部分(test suite的好处就在于此),此时频繁修改代码是不好的,就可以采用过滤器的方式
断言类型
断言主要用于在test中确认验证某种为真的情况,即给定条件下(Given),运行一定代码(When),得到确定的行为(Then),否则就会输出错误
结果分类
以断言结果分类的话,有2种断言类型:
- Fatal断言 以ASSERT_*开头的断言,一旦断言失败,该test就不再继续运行下去(但其他独立的test仍然继续)
- NonFatal断言 以EXPECT_*开头的断言,断言失败只是输出错误信息,但本test还是继续下去
一般来说,都用Fatal断言,NonFatal断言的情景很少,除非是想进行类似调试工作時才使用,比如用NonFatal來输出错误时的更多信息.不过这个工作不应该用test來做,而是通过调试程序来做
断言格式
断言格式也分为几种,gtest框架支持2种不同风格的断言格式(好吧,是gmock,编译時需要把gmock.h包含进来)
- 经典风格 即使用断言來表示逻辑判断关系.比如
ASSERT_TRUE
就是通过断言名称來告诉测试人员,此处是要验真,或者ASSERT_EQ
就是表示参数是要相等的.甚至可以表示大小关系的ASSERT_GT(a, b)
,但是要注意是a>b,不是相反(很容易搞混) - Hamcrest风格 即断言只是assert作用,具体的逻辑有外部提供的谓词Matcher提供,如
ASSERT_THAT(a, Eq(2))
其实两种风格是完全等价的,只不过从测试可读性和错误输出可读性来讲,Hamcrest风格更加可取,断言直接就变成了简化版的英文句子(“assert that a [is] equal [to] 2”),比经典风格少了很多歧义
而且,自带谓词Matcher如果无法满足需求時,可以自行定制(估计情况不多)
所以以后的使用尽量使用后者(注意需要包含gmock.h,并在using namespace testing)
选择断言
理论上来讲,我们只需要一种断言即可ASSERT_TRUE
,但这样做法最大的缺点是可读性,断言内容需要思考,错误输出同样需要思考,一点也不直观,这个对于小步快走的TDD流程来说,是比较恶心的,而且也大大降低了测试集作为”系统行为描述文档”的功能
此处并没有固定的要求,但断言越接近逻辑本身越好,比如ASSERT_TRUE(a == 2)
就没有ASSERT_THAT(a, Eq(2))
直接
浮点数断言
浮点数是不能直接比较的,如果使用Eq或ASSERT_EQ应该都是不行的.一般来说,此时就不要自己搞了,浮点比较是大学问,可以参看此处,尽量使用测试框架提供的,比如DoubleEq之类的
其实,这也提示我们,在平时开发过程中,对于浮点比较也是需要慎重考虑的.对于精度要求比较高的场景,还是要使用一些第三方库來处理浮点运算之类的,不要盲目的造轮子
浮点数的轮子不是随便能造好的
异常断言
对于需要测试异常的情况,尽量使用框架提供的机制,比如gtest就提供2种:
- ASSERT_ANY_THROW(statement) statement表示确定抛出异常的语句(不需要结尾的”;”,不过加上应该也没错),此处异常可以是任意异常
- ASSERT_THROW(statement, ExceptionType), ExceptionType是要抛出的异常类型,此时,如果statement不抛出异常,或异常类型有误,都会被断言掉的
如果测试框架没有提供类似机制的话,可以利用try-catch來模拟类似效果
TEST(Sample, Exception) {
try {
// statement
// 不抛出异常,直接失败即可
FAIL();
} catch (const ExceptionType& e) {
// 抛出预期异常
} catch (...) {
// 抛出其他异常
}
}
这样就没有了内置断言可以提供的好的可读错误信息了,优点是我们可以拿到异常來进一步的进行断言判断了
test和TDD的区别
TDD的主要目的是通过一系列描述系统行为的测试集,更好的理解系统需求,从而完成一个clean的系统设计,辅助的测试功能仅仅是作为验证系统行为和强行保证良好涉及的,用来测试正确性应该仅仅是其副作用
而test则主要是为了验证功能的正确性,自然可能需要用到穷举之类的方式,将边边角角的情况都要考虑进去.用书中原话来说的话,就是
You create tests for five types of cases: zero, one, many, boundary, and exceptional cases
分别是:
- zero 空输入
- one 单个输入
- many 批量输入
- boundary 边界输入
- exceptional 异常输入
5种类型会分别对应若干测试,通过这种全面的测试来保证正确性
所以可以看到,TDD关注于系统行为,一旦行为确定,则不需要新增test;而test的话,主要是针对同一行为(算法之类)的所有情况的验证
个人感觉二者分的不是很开,比如行为的各种情况,能否看做不同行为呢?粒度不同,肯定是可以有不同的看法的,这个还需要进一步思考