Home

TDD笔记1

06 Jul 2014 by LelouchHe

缘起

本文是对tdd或单元测试的一个小结.以前尝试过很多次,但都没有具体的文字记录下来,导致的结果就是不停的对相同的概念和流程进行重复学习

重点是掌握怎么写单元测试

其实单元测试是比较容易写的(how),比如利用gtest框架,但写什么,测试什么(what)是个比较难搞的问题.平时工作接触到的代码而言,感觉都非常难于测试,如何分解重构历史代码,如何编写新的功能和测试代码,这些方面都不是很了解,所以希望通过本次的学习,至少有个明确的认识

本文是对Modern C++ Programming with Test-Driven Development的部分笔记,夹杂一些个人想法

TDD循环

典型的TDD是由一系列类似的循环(cycle)构成的,每个循环分为失败(Red)/成功(Green)/重构(Refactor)三个部分

基本的流程就是上述循环的不断进行,类似软件工程当中所说的迭代开发,每次循环都会前进一小步,然后根据这一小步,来调整前进的方向

TDD单步思考

TDD循环的每个步骤中,都有一些重要问题需要思考

TDD循环是需要严格遵守的,一个步骤没有完成,是绝对不能进行下一步的,而且,步骤是要小且单一的,不要妄图同时完成很多任务,并且要保证通过所有test

test结构

所有的test需要遵循固定的pattern,即通常所说的Given-When-Then

一般来说,test要小,5-6行足以,如果太多的话,可能暗示该test并非足够小,需要进一步的拆分

三条规则

  1. 没有test失败,就不需要编写新的代码
  2. 编写一个足以失败的test即可(编译失败也算)
  3. 编写代码,仅能让失败的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直接成功了.这大部分时间不是个好的情况,一般来说,可以从以下角度来分析看看

除去以上的问题,时刻要牢记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行代码,验证一个行为)

使用测试集来描述行为

类似于上一个,测试集不止需要描述对应实现的行为,同时还是整个系统行为的描述.一般而言,系统的功能和行为是通过所谓需求文档来表达的,但需求文档是虚无缥缈的,无法证实和除错,此时就需要通过测试集来保证这一点(如果我们的测试都是描述行为的话)

着眼于行为,而不是背后的实现,这是测试集的一个重点

比如,我们要实现一个复数加法系统,有哪些行为呢?

  1. 构建复数,分三种行为,纯实数/纯序数/复合形式
  2. 复数复制和赋值
  3. 复数相等判断
  4. 复数相加,同样进行区分,复数之间,复数与纯实数之间,复数与纯序数之间,纯实数和纯虚数之间

这样,系统大的行为有4种,继续拆分下去,就有10种小的行为,那么就会对应至少10个test来分别验证其功能

测试集就是这样逐渐演变为需求文档

(当然,此处直接的列出所有test集是不对的,还是要记住小步快走的渐进迭代原则)

保持简单

编写test和实现代码,大多数时间是比较容易的,困难的部分在于如何遵循TDD循环,编写简单的test和实现.

由于简单的test有可能很trival,导致了我们开发时往往一上手就开始从复杂入手.比如不是从单一功能入手,而是一开始就实现复合功能.这样看起来可能非常快,但最后如果需求变动或理解错误或其他原因,返工的比例非常的高,而且丢失了测试集的需求说明性质,有可能做到最后,我们仍然不太清楚的了解系统究竟是个什么样子

时间/项目压力,历史遗留问题,看似简单不变的需求等等,都不能作为以复杂方式处理问题的借口.复杂的代码只能通过简单的代码来简化,层叠起来的复杂,只会变得越来越复杂,而不是反之.

Simple Design和Simple Implementation才是解决问题的方案,保持简单,应对隐晦和变动的需求,以及复杂的历史问题,才会带来最终问题的简化

K.I.S.S.

坚持循环

坚持实践TDD的Red/Green/Refactor循环,直到它变成开发的一个必须步骤:

  1. 分解问题,将需求变为一系列的todo项
  2. (Red)为新的功能编写test,能fail即可
  3. (Green)编写代码,让该test成功即可
  4. (Refactor)重构代码,改善bad smell,同时保证已有test全部成功
  5. 回到步骤1

无测试,不开发;小测试,小开发;全通过,就重构

保证TDD的机制

准则是一系列抽象的思维方式,下面介绍一些常见的开发手段,用以解决开发中经常碰到的问题

下一个测试是什么?

对于下一个test的选择并没有特别的准则,一般来说要问自己以下几个问题:

  1. 下一个逻辑上最有意义的行为是什么?
  2. 关于这个行为,能验证的最小子行为是什么?
  3. 是否能编写一个test,证明现有系统无法满足该行为?

这三个问题的最后答案,就是下一个要编写的test.我们仍然以复数相加来作为例子:

  1. 下一个逻辑上最有意义的行为是什么? 复数相加的行为
  2. 关于这个行为,能验证的最小子行为是什么? 复数相加包括复数,纯实数,纯虚数的相加,最简单的应该是复数之间的相加
  3. 是否能编写一个test,证明现有系统无法满足该行为? 可以,就是两个数都是复合复数(有实有虚),进行相加

这样,我们的下一个test就出来了

当然,这还是一个比较抽象的规则,并不能满足所有的情况,也只能通过不断的练习来培养寻找下一个test的感觉

十分钟限制

给每次TDD循环设置一个时间限制,因为每次TDD循环都是小步快走型的,所以每次的时间不应该太久,如果在某此实现/重构中耗时太多,那么有2种可能性:

  1. 新加test过于复杂,或者对新加test的实现过于复杂.对于小步快走的TDD循环来说,二者都是bad smell,都是错误的行为.一定要清楚,此处的复杂并不代表你的聪明,相反,你会为现在的小聪明付出大代价
  2. 系统重构过于复杂.也就是在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需要成功通过,很多时候就不能完成既定的循环了,此时就要面临选择:

  1. 测试之间是耦合的,所以修改才会导致其他test失效.既然耦合,就需要重新确定测试的逻辑了(还记得上文提到的测试间耦合的情况么?),或者调整测试顺序,修改原test集,或者新增test,确保test之间的耦合是可测试的
  2. 测试之间是无关的,只是我们错误的修改其代码.这种情况要不然是我们改错了,要不然就是系统结构的问题,导致牵一发而动全身.所以看看是否应该先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种断言类型:

  1. Fatal断言 以ASSERT_*开头的断言,一旦断言失败,该test就不再继续运行下去(但其他独立的test仍然继续)
  2. NonFatal断言 以EXPECT_*开头的断言,断言失败只是输出错误信息,但本test还是继续下去

一般来说,都用Fatal断言,NonFatal断言的情景很少,除非是想进行类似调试工作時才使用,比如用NonFatal來输出错误时的更多信息.不过这个工作不应该用test來做,而是通过调试程序来做

断言格式

断言格式也分为几种,gtest框架支持2种不同风格的断言格式(好吧,是gmock,编译時需要把gmock.h包含进来)

  1. 经典风格 即使用断言來表示逻辑判断关系.比如ASSERT_TRUE就是通过断言名称來告诉测试人员,此处是要验真,或者ASSERT_EQ就是表示参数是要相等的.甚至可以表示大小关系的ASSERT_GT(a, b),但是要注意是a>b,不是相反(很容易搞混)
  2. 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种:

  1. ASSERT_ANY_THROW(statement) statement表示确定抛出异常的语句(不需要结尾的”;”,不过加上应该也没错),此处异常可以是任意异常
  2. 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

分别是:

5种类型会分别对应若干测试,通过这种全面的测试来保证正确性

所以可以看到,TDD关注于系统行为,一旦行为确定,则不需要新增test;而test的话,主要是针对同一行为(算法之类)的所有情况的验证

个人感觉二者分的不是很开,比如行为的各种情况,能否看做不同行为呢?粒度不同,肯定是可以有不同的看法的,这个还需要进一步思考