第1章软件测试与单元测试简介
要想最大限度地从单元测试中受益,就必须理解它的目标及它是如何改进软件开发过程的。在本章中,读者将会学到一些通用的软件测试知识,这些知识也适用于单元测试。这一章也会讲到软件测试的优点和缺点。
1.1软件测试的目标
很多软件项目的目标都是盈利,实现这个目标的通常方式即通过应用商店来出售软件或者以其他方式授权给用户使用并收取费用。那种为了程序开发者内部使用所制作的软件,则会通过提高某个业务流程的效率,减少该流程所耗的时间来间接地盈利。如果通过提高业务流程效率节省的成本大于开发该软件的花销,那么这个软件项目就是盈利的。开源软件的开发者通常以出售“支援服务包”(support package)来获利,他们也会使用自己开发的软件,在这种情况下,前面的论断依然成立。
所以说,软件开发经济学的基本原则就是,如果某个软件项目的目标是盈利—不管是向客户出售最终产品还是供开发者内部使用,那么它要想成功地达成此目标,必须创造某种高于软件制作开销的价值才行。笔者也知道这并非一个具有非凡意义的论断,不过可以将它推及到软件测试领域中。
如果软件测试(也叫做“质量保证”(Quality Assurance, QA))是为了支持软件项目,则它必须对实现盈利有帮助才行。这一点很重要,因为它对软件测试做出了限定:如果软件测试开销过大,导致项目亏损,那么这种测试就不适合去做。不过对软件进行测试可以保证产品能正常运行,而产品又包含了客户所需的功能。如果你不能展示这些功能的价值,那么客户就不会购买这个产品。
注意,测试的目标是证明产品能够正常运行,而不是发现bug。软件测试是在做“质量保证”,而不是“质量介入”。查找bug通常是个坏主意。为什么呢?因为要修复bug就必须有开销,而这部分资金本来是付给开发者的,让其一开始就写出无bug的软件,现在却被浪费了。在理想的情况下,大家可能会认为开发者只需写出无bug的软件,通过快速的测试确保它们没有问题,然后将其上传到iTunes Connect账户,就可以坐等财源滚滚而来了。不过别急,这么做也会以另一种方式导致同样的问题:在测试软件之前,需要多长的时间来编写100%无bug的软件呢?这样做的开销是多少?
这么说的话,合适的软件测试方案看起来是一种折中:既要保证对软件开发进度有一定程度的控制,又要在工程开销许可的范围内进行一定程度的检查,以确保产品确实能够正常运行。这种平衡应该着眼于将所发行产品的运行风险降低到一个可以接受的水平上。所以说,“最具风险的组件”,也就是那些对于软件的运行至关重要的组件或者那些最有可能隐藏bug的组件,应当首先测试,然后测试那些风险稍低的组件,依次测试,直到你觉得所有剩下的风险因素都不值得再投入时间和资金去测试为止。最终的结果应该是让客户看到软件实现了预期功能,从而值得为此付费购买才对。
1.2软件测试由谁来做
在早期的软件工程实践中,项目都是以图1-1所示的“瀑布模型”(Waterfall Model)来管理的。在这种模型中,开发过程被划分为一个个独立的”阶段”(phase),上一个阶段的输出即是下一个阶段的输入。所以产品经理或业务分析师可以先建立项目需求,需求建立好之后,将其交给设计师和架构师,依此制作软件需求规格书(software specification)。开发者按照此规格书的要求来编写代码,编好的代码交给测试人员去做质量保证。最后,测试好的软件将会向客户发布(通常会先向一部分预先选定的客户分发,这些人叫做beta tester)。
这种软件项目管理方式将编码者与测试者强制隔开,这对实际的测试工作有利也有弊。好处是通过将代码编写与代码测试的职责分开,可以让更多的人来找bug。有时候开发人员只将注意力集中在其所写的代码上,而旁观者则可以清楚地找出代码中的错误来。同样,如果需求或规格书中有某一部分描述不够明确,那么测试者与编码者就有机会以不同的方式来理解它,从而增加了其被发现的几率。
这么做的坏处则是成本会增加。表1-1是根据Steve McConnell所著的《Code Complete,2nd Edition》(Microsoft Press,2004年出版)一书中的调查数据重制的,它将修复一个bug所需的大致花销表示为该bug在产品中所潜伏时间的函数。由此表可以看出,在项目快要结束时修复bug是开销最大的,这很有道理:测试员找到并汇报某个bug,开发者必须理解并在源代码中将其定位。如果开发者已经从事这个项目一段时间了,那么就必须进行规格书与代码的复查。修复了bug之后的版本必须再次提交给测试者以确保问题确实已经得到解决了。
图1-1瀑布式软件项目管理流程中的各个开发阶段
表1-1在软件开发的不同阶段中修复bug的成本
修复bug的成本 检测bug的时间
产生bug的时间 需求 架构 编码 系统测试 发行之后
需求 1 3 5~10 10 10~100
架构 — 1 10 15 25~100
编码 — — 1 10 10~25
这额外的成本从何而来呢?很大程度上是源于不同团队之间的沟通:开发者和测试者使用不同的术语来描述同一个概念,他们会用完全不同的思维模式来理解应用程序中的同一个功能。如果发生了这种情况,那么就需要再花些时间来澄清这些模糊的问题。
. 从表1-1中还可以看出,在项目结束时修复bug的成本取决于这个bug是在哪个阶段产生的:在需求阶段埋下的错误只能通过重写整个功能来解决,这么做当然会花费很多了。这促使采用瀑布模型的人们在项目的早期持非常保守的态度,他们会彻底检查需求分析及软件规格书,确保每一个细节都准确无误,否则就不向下一个阶段推进。于是这就导致了“分析麻痹”(analysis paralysis),它会增加项目成本的。
以这种方式划分开发者与测试者,就算不采用什么严格的界限,也会对软件测试的类型产生影响。因为测试者对应用程序内部细节的了解并不如开发者那样彻底,所以他们只会将整个产品看成一个不透明的部件,通过从外部与之沟通来进行“黑盒测试”(black box testing)。第三方测试者很少会进行“白盒测试”(white box testing),此种测试可以通过查看及修改内部代码来验证程序的运行结果。
在黑盒中执行的测试通常叫做系统测试(system test)或集成测试(integration test)。这是个正规的术语,意思是将软件产品当成一个整体来测试(也就是,整个软件系统的各个部件集成起来测试),测试主要针对软件的运行结果。这种测试通常按照预订的计划来执行,这也就是测试者谋生的方式:他们根据软件规格书来创建一系列测试用例,每个用例都会描述执行该测试所需的准备工作,以及执行测试之后的预期结果。这种测试通常是要手动执行的,当测试结果需要让测试者翻译时更是如此,因为测试者需要根据网络服务或当前日期等外部状态来判断测试结果。即便这种测试能自动化,通常运行起来也很耗时:每次测试前,都要把整个软件及其运行环境配置到基准状态(baseline state),而且每个步骤都有可能涉及非常耗时的数据库、文件系统或网络服务操作。
beta测试(beta testing)有时也叫做客户环境测试(customer environment testing),它实际上也是一种特殊的系统测试。其特殊之处在于参与测试的人也许并非专业的软件测试员,而是软件的用户。如果客户执行软件所用的系统配置或运行环境同测试者存在差别,或者用户执行用例所产生的结果与项目测试团队有所不同,那么在beta测试阶段都能发现,相关的问题也会回报给项目组。对于小型的开发团队,尤其是无力雇用专职测试员的团队来说,通过beta测试可以让软件首次面对不同的使用方式及运行环境所带来的考验。
因为beta测试是在产品即将发布时进行的,所以处理这种测试反馈信息对于项目开发团队来说是一种煎熬,他们觉得马上就要看到曙光了,都可以闻到庆功宴上比萨饼的香味了。不过话说回来,如果你不打算解决反馈回来的问题,那么也就毫无必要去做beta测试了。
开发者也可以自己来做测试。如果在Xcode中曾经按下Build & Debug按钮运行过程序的话,那么你就等于是做过了某种白盒测试,因为你是在通过检查代码来确定程序的行为是否正确(或者更准确地说,为什么程序运行结果不正确)。编译器产生的警告信息、静态分析器、Instrument等工具都可以用于此种测试。
由开发程序员来做测试所具有的优点和缺点与请专职人员做测试所带来的利弊恰恰是相反的:开发者发现软件有问题后,通常能够以较小的成本轻易地解决它。因为他们已经对代码有了一定的理解,能够发现bug可能的藏身之处。实际上,开发者可以在编码过程中顺带进行测试,这样可以在写好代码之后就及时发现bug。然而,如果bug是由于开发者对软件规格书或者业务范围不理解而产生的,那么必须借他人帮助才能找到这种bug。
设置正确的运行环境
笔者所编写的代码中最值得一提的一个bug(迄今为止,但愿以后也别再出现了)就是由于刚说的“开发者对需求不理解”而产生的。当时笔者在为Mac开发一款系统管理工具,它独立于用户账号而运行,所以并不会根据用户配置来决定其记录消息所使用的语言,而是从一个文件中读入语言设置。文件类似这样:
LANGUAGE=English
这看起来很简单。问题是,有些非英语用户回报说尽管已经设置成了其他语言,然而该工具还是会以英语来记录消息。笔者发现读取配置文件的那部分代码同该工具的其他代码之间耦合度很高,所以决定先打破这种依赖关系,然后再插入单元测试来检视代码的具体运行情况。慢慢地,笔者找到了导致语言检测逻辑失败的那段问题代码并将其修复。这样一来所有的单元测试都通过了,所以似乎代码能正常运行了,是吗?实际上不是的,因为笔者没有意识到有时候客户那边的配置文件可能是这样的格式:
LANGUAGE=en
不只是笔者没发现这个问题,测试人员也没发现,最后是因为程序在用户的操作系统上运行崩溃了才发现这个问题的,这种情况下就算单元测试覆盖了所有代码也发现不了它。
1.3何时进行软件测试
上一节已经给出了这个问题的部分答案—在产品开发中越早执行测试,发现问题后解决它的开销就越低。如果开发过程中的各个阶段所写的代码都能够正确且稳定地运行,那么在后期将它们集成起来或加入新部件的时候,所产生的错误就要比等项目结束后再一次性执行所有测试要少。不过,上一节也提到,软件产品开发的习惯做法还是只在项目最后才进行测试:在实现阶段之后有一个隐式的QA阶段,然后软件会分发给公众测试员试用,最后才会发布正式版。
现今的软件项目管理方法认识到了传统做法的不足之处,力争在产品开发的全过程中对每个部件都进行持续的测试。这就是“敏捷”项目管理方式和传统管理方式之间的主要区别。敏捷管理方式经常将项目划分成数个短期工作阶段,每个阶段叫做一个“迭代”(iteration),有时也叫做“冲刺”(sprint)。每个迭代期都要对需求进行一次复查:将废弃的需求去掉,修改现有需求,根据需要新增一些需求。在该次迭代中,将选择那个最重要的需求,并针对它进行设计、实现和测试。在一轮迭代结束时,要对项目进度进行评估,以决定是将新开发好的这项功能加入到产品中,还是在这项功能上再增加新的需求,在接下来的迭代周期中完善此功能。最为重要的是,“敏捷宣言”(http://agilemanifesto.org/)声称“个体与互动的价值高于流程与工具”,所以在做重要决策时,客户或其代表都会参与。如果能向客户问清楚软件的功能并向其确认开发好的产品确实能正 常运行,那么就不需要再费心思去雕琢冗长的产品功能规格书了。
于是,在敏捷项目的开发过程中,软件产品的所有内容都会持续地测试。在每个实现周期,客户都会被询问哪些需求是当前最重要的,然后开发者、分析员和测试者将会协同工作以完这些需求。敏捷软件项目管理采用的其中一种框架称为极限编程(eXtreme Programming, XP),它更进一步要求开发者对代码进行单元测试,并以结对的方式编码:一个人“抱着”键盘输代码,另一个人在旁边提出修改或完善代码的建议,并指出代码中潜在的缺陷。
所以说,问题的实质是要在项目开发的全过程始终进行软件测试。你并不能完全排除用户以非预期的方式使用软件的可能性,而且用户也可能会发现一些受工程期限或预算等因素制约而并未专门处理的逻辑所产生的bug。不过你仍然可以针对自己所写的这部分代码进行自动化的常规测试,让QA团队和公众测试员去试着执行一些实验性的用例,看他们能不能以新奇而巧妙的方式使程序崩溃。而且你可以在每轮迭代结束之后询问客户接下来将要做的工作是否有助于提升产品的价值,是否能实现营销方案上所说的功能而让用户满意。
1.4测试实践举例
前面已经提到系统测试了,它需要让专业的测试员根据所有用例逐次运行整个软件,以找出异常行为。在开发iOS应用程序时,可以利用Apple所提供的Instrument分析工具中所含的UI Automation instrument功能将这种测试在某种程度上自动化。
系统测试并非总是通过一般性的尝试来找到程序中的bug,有时候测试者会预设一个特定的目标。渗透测试员(penetration tester)通过向程序输入恶意数据、打乱操作顺序或者破坏程序运行环境来寻找安全漏洞。可用性测试员(usability tester)会观察用户如何使用应用程序,记录下让用户操作错误、耗时太久或不知该如何操作的情景。可用性测试中的一个常用技巧就是A/B测试(A/B Testing)。让不同的用户使用不同版本的软件,再对使用情况进行统计和比较。Google就以在软件中运用此种测试而闻名,它甚至会对产品界面所用的色调执行A/B测试。注意,可用性测试并不需要等程序全部开发完才能进行,Interface Builder所创建的界面模型、Keynote软件制作的幻灯片,甚至是纸上的草图都可以用来评判用户对应用程序界面的接受程度。这些真实度较低的界面虽说不能像在iPhone真机上操作那样暴露出一些微妙的细节来,但它们确实是一种及早获取用户反馈的方式。
开发者,尤其是在大型团队中工作的,会在将代码集成入产品之前,先将其提交给同事进行评审。这是一种白盒测试,其他开发者能看到代码,从而可以据此判断这段程序是否处理了某些特定的情况,是否将所有关键的偶发状况都考虑在内了。代码审校并非总能发现逻辑上的bug,笔者觉得自己在参加过的代码审校中经常发现的是一些代码风格上的问题,以及一些无需改变代码行为即可解决的问题。当要求代码审校者查找某种特定的错误时(例如,按照一张包含5~6项常见错误的核查表来查找错误。在Mac与iOS代码的核查表中,通常会包含“保留计数”(retain count)问题),尽管他们可能找不到与这些核查项目不相关的问题来,但他们会更容易发现核查范围之内的bug。
1.5单元测试的适用范围
单元测试是开发者进行软件测试的另一个工具。第3章将会讲解如何设计与编写单元测试,现在只需要知道它是用于测试代码行为的一段小程序即可。这段程序会设置运行环境,然后执行受测代码,最后针对运行状态调用断言(assertion)语句。如果断言成立(也就是说,断言所述的条件得到满足),那么测试就能成功执行。如果实际运行结果与断言所述状态有任何偏差,则会导致测试用例执行失败,这种偏差也包括了由于异常而使程序无法正常运行完毕的情况。
这样看来,单元测试有点儿像微缩版的集成测试用例,它也指定了运行程序的步骤及预期的执行结果,不过是以代码而非手工方式来做的。于是可以让电脑来完成这些工作,不必非得由开发者来手动执行每一个步骤。无论怎样,一个好的测试用例同时也应该是一份好的文档:它描述测试员对受测代码执行结果所持的预期。为程序中的某个类编写代码的开发者也可以编写测试用例以确保该类确实能如预期的那样工作。实际上,在下一章中我们会看到,开发者也能在写完受测类之前就先写好测试用例。
单元测试得名于其所测试的代码“单元”,在面向对象软件开发中,这样的“单元”通常指的就是类。这个专有名词源自编译器领域的术语“编译单元”(translation unit),意思是传递给编译器的单个文件。这意味着单元测试本来就是白盒测试,因为它是将应用程序中的某个类单独拿出来以测试其行为。可以将这个类当做一个黑盒,也可以仅通过公共API来与之通信,这是个人选择问题,然而不管是哪种方式,都只是在同应用程序的某个小部分进行互动。
单元测试的细粒度特质可以使开发者非常快速地解决通过运行单元测试所发现的问题。程序员在编写类代码的同时经常也在写该类的单元测试,所以在写测试代码时,就总是会考虑到受测类。笔者有时候甚至不用运行某个单元测试就可以知道它肯定运行失败,而且也知道该如何修改代码使之成功运行,这就是因为我那时的思维仍然在那个受测类上面。与之相对比的情形是,另外一个人来执行测试用例,而写受测程序的那个开发者已经好几个月没接触代码了。尽管开发者所写的单元测试并不会出现在应用程序中,但写测试代码所带来的好处会抵消掉这种开销,因为它可以在代码提交给专职测试员之前就发现并修复代码中的问题。
修复bug是每个项目经理都觉得最为恐怖的事情:因为必须在修复bug之后产品才能发布,然而又无法为bug的修复制订计划,因为无法确定存在多少个bug及修复它们需要多长时间。回头看看表1-1就知道,在项目即将结束时修复bug的成本最高,而且越往后,修复bug的成本就增长得越快。在估算工期的时候可以将写测试用例所花的时间考虑在内,这样在制作软件的过程中就能修复那些bug,也会减少导致软件延期发行的不确定因素。
几乎可以肯定,单元测试是由程序开发者写的,因为写这种测试需要使用某种测试框架来编写代码,要调用API,还要表述底层逻辑—这些都是程序员擅长做的事情。不过,某个类与其单元测试的代码则未必要由同一个程序员来写,将这两件事分开也有好处。资深程序员可以通过一系列测试来描述某个类API的预期行为,以此指导初级程序员对其编码。有了这些测试之后,初级程序员就编写类的代码依次让这些测试用例成功运行,由此来实现这个类。
集成的过程也可以这么倒置。如果开发者要使用或研究某个已经写好的类,但却不知道它的原理,那么可以将自己对其的假设以测试的形式写出来,由此判断哪些假设是正确的。随着测试用例的增多,这个类的功能与行为会更加完整地呈现出来。不过针对既有类来写测试,通常要比在编码的同时进行并行测试要难。要在测试框架中测试那种依赖于运行环境的类,要花费大量精力,因为必须要将其对周边对象的依赖替换或移除才行。第11章将会讲到如何针对既有代码写测试用例。
协同工作的程序员可以更快速地转换角色:一个人写测试,另一个人针对该测试编写实现代码,然后两人交换,由写实现代码的那个人来写测试。具体是哪两个程序员进行结对编程,并不重要。无论如何,都可以将一个或一组测试用例当做某种形式的文档,将某个开发者对该类的想法表达给其结对伙伴。
单元测试的一个关键优势就是它可以让测试的运行自动化。写一份好的手动测试计划书所花的时间,差不多和写一个好的测试用例所花的时间一样多,而电脑则可以在一秒钟内执行完上百个单元测试。开发者可以把其为某个应用所写的测试用例与该应用的产品代码放在同一个版本控制系统里面,这样无论何时想执行测试都会很方便。这使得检测回归bug(regression bug)的成本变得非常低,这种bug指的是那些原本已修复,然而随着后期的开发工作又重新引入的bug。只要修改了应用程序的代码,就应该将所有的测试用例执行一遍,以确保这次修改没有引入回归bug。甚至可以通过某种持续集成系统(continuous integration system),在将代码提交至代码库时,自动运行测试用例。第4章会讲到这种用法。
反复运行测试不仅有助于找出回归bug,它还会在要编辑源代码而又不改变其行为(也就是要重构应用程序)的时候,提供一张安全保护网。重构的目标是在不引入新功能及新bug的前提下对应用程序的代码进行清理,同时为了使其在将来的工作中更易使用,对其进行结构调整。如果正在重构的代码已经被足够多的单元测试所覆盖,那么任何行为上的改变都会检测到。这意味着可以在重构时就修复软件中的问题,而不是要等到下一个正式版发布之前(或之后)才去修复它们。
然而重构也不是万能的银弹(silver bullet)。如前所述,开发者没办法通过测试来确认他们是否正确地理解了需求。如果测试与实现是由同一个人来完成的,那么其对需求问题的认识和理解,在这两份代码中的体现将会是一致的。还应该意识到,并不存在一种合适的度量指标可以量化地判断某种单元测试方案是否成功。常用的标准(代码覆盖率和成功执行的测试用例数)都可以在受测软件的质量不变的情况下人为地修改。
回到前面说的那个概念上来,测试的目标是要减少软件被部署到客户端时所存在的风险,所以那种能够报告当前测试对软件风险缓解程度的软件就会非常有用。这种软件无法真正知晓当前代码中存在的具体风险,所以它所做出的判断仅仅是一种对风险级别的评估而已。
按照测试数量来评定测试的有效性是一种非常想当然的做法。如果经理按照成功执行的测试用例数来决定员工的年终奖,那么你只用写一份测试,复制很多次就行了。甚至都不需要测试应用程序中的代码,只需要验证“1==1”就可以了,这就能增加测试套件中成功执行的测试用例数。而且一个应用程序到底写多少个测试用例才算合适呢?你能想出一个让所有iOS应用开发者都致力于达到的目标数吗?恐怕不行吧……至少笔者想不出来。就算两个程序员都在编写同一个应用程序,他们也会在不同的部分发现不同的问题,从而在写程序的过程中碰到不同级别的软件风险。
以测试覆盖率来作为衡量标准在部分程度上弥补了以测试数作为衡量标准的不足,它是以测试用例所执行到的应用程序代码数量为依据的。这意味着开发者不能靠编写无意义的测试用例来多拿奖金了,不过另有办法:可以先针对那些容易做测试的代码来写测试用例。比如,可以找到程序中所有以@synthesize关键字定义的属性,然后测试其对应的getter与setter方法就可以提高代码覆盖率了。当然,正如我们所见,这种测试的确有其意义,不过这么做并不是利用工作时间的最佳方式。
实际上,代码覆盖率检测工具对于复杂的受测代码会赋予更高的权重。这里“复杂”(complex)一词的涵义,来源于计算机科学中的一个特有名词“圈复杂度”(cyclomatic complexity)。简言之,某个函数或方法的圈复杂度是和其代码中所包含的循环与分支数相关的,或者换句话说,与代码的执行路径数有关。
比如,有两个方法,一个叫做-methodOne,它有20行代码,其中不含if、switch、:?表达式或循环(也就是说,它具有最小复杂度)。另一个叫做-methodTwo:(BOOL)flag,它有一条if语句,其每个分支又都有10行代码。要完全覆盖-methodOne方法,只需一个测试,但是要完全覆盖-methodTwo方法,则必须写两个测试,每一个测试需要执行一个if条件的分支。测试覆盖率检测工具只会报告受测代码数,所以这两种情况下都会报告同一个数字:20。这么一来的结果就是想要提升复杂方法的测试覆盖率,相对来说会更困难一些。可恰恰是在复杂的方法中才更有可能隐藏bug。
同样,代码覆盖率测试工具也不善于处理特殊情况。比如,某方法接受一个对象作为参数,这时不管用一个初始化的对象还是用nil来测试它,从代码覆盖度测试工具的角度来看,都是一样的。实际上,也许这两种测试各有其意义,然而对代码覆盖率而言,结果却没什么不同。每一种测试都会执行这行代码,所以多写另外一种测试并不会增加代码覆盖度。
最终还是得靠你自己(也许还有客户)来决定某部分代码所含的风险大小,以及在正式发行产品中可以接受的风险度。就算测试度量工具能正常工作,它也不会替你分担这份责任的。所以说,正确做法应该是,在产品能从测试中受益时就进行测试,相反,如果测试对产品没什么帮助了,那么就停止测试。针对“我应该测试软件中的哪一部分?”这个问题,软件工程师与单元测试专家Kent Beck的回答是:“只测试那些你想让其正常运行的代码。”
1.6测试驱动开发对iOS开发者的意义
单元测试带给iOS开发者的主要好处就是可以用很小的投入获得很大的回报。因为在App Store上面成千上万的应用程序之中,有很大一部分都是由微型独立软件开发商(micro-ISV,ISV是Independent Software Vender的缩写)制作的。对于它们来说,任何不需要很多资金又能改善应用程序质量的方法都是个好东西。向iOS开发项目中增加单元测试所用的工具是免费的。实际上,第4章就会提到,测试所需的核心功能就包含在iOS SDK开发包里面。你可以自己编写并运行测试,这意味着不需要再雇用专业的QA人员即可通过运行单元测试来获得有效的运行结果。
运行测试用例几乎不费时间,所以采用单元测试的唯一开销就是必须花时间来设计与编写这些用例。作为回报,你会在编写产品代码的同时,对所写程序的行为有一个更加深入的理解。这种理解可以使程序员避免写出有bug的代码,还可以减少项目完工时间的不确定性,因为公众测试员在beta版中所发现的必须加以修复的bug不会如原来那样多了。
作为iOS应用程序开发者,你要记住,应用的发布权不在你的手里,而是由Apple控制。如果在已经发布的应用程序里面存在一个严重的bug,那么在修复了bug之后,必须等待Apple批准(假设确实批准了),然后更新之后的版本才能在App Store上架,才能在用户的手机和iPad上看到它。单凭这一点,就值得采用一种新的测试流程。发行一款bug很多的软件已经够糟糕的了,如果还不能及时修复它,后果将会更加严重。
当你逐渐习惯了测试驱动开发,也就是边写代码边做测试,你的编码速度就会更快,因为你会把思考代码的设计与其在各种情况下所需处理的逻辑当成一种习惯。很快你就会发现,以测试驱动的方式写产品代码及配套的测试用例,与过去那种仅仅写产品代码的方式所花的时间是一样的,但是这种方式有个好处,就是你对所写代码的正确性更加有信心了。下一章将会介绍测试驱动开发背后的概念,这些概念将会在本书后面部分用到。
第2章测试驱动开发技巧
在第1章中已经讲到,单元测试在软件开发流程中占有一席之地:以电脑反复运行针对产品代码所写的测试,可以确保软件开发在朝着正确的方向发展。在过去几十年中,单元测试框架的开发者们不断地完善测试技术,也建立了一些新手段,用以将单元测试集成入软件开发流程之中。这些开发者中尤其要提到的是一群践行极限编程的人,此种软件工程方法(software engineering methodology)是由Kent Beck发明的。他也是SUnit测试框架的创始人,这个测试框架是用来测试SmallTalk语言程序的,它是世界上首个单元测试框架,也是Java语言测试框架JUnit与Objective-C语言测试框架OCUnit的先驱。
2.1测试先行
极限编程迷恋者们所采用的开发方式就是测试先行(test first)或测试驱动开发,顾名思义,即在写产品代码之前先写测试用例。这听起来有点怪异,不是吗?如何测试并不存在的东西呢?
在制造日常生活用品的行业中,制作产品之前先设计测试是一种常见的工作方式。测试规定了产品的验收标准(acceptance criteria)。除非所有的测试都运行成功了,不然就说明代码还不够好。反过来说,如果有一套全面的测试用例,那么只要其中每个测试都能成功执行,我们就可以说产品代码已经足够好了,不需要再做修改了。
在写产品代码之前先写完所有测试用例,与写好全部产品代码之后再写测试用例,都会面临同样的问题。人们总是擅长于一次解决一个小问题,在解决了之后,才调整思路,去处理另一个问题。如果你先把所有的测试用例都写好,然后回过头去写全部的产品代码,那么你得把应用程序中需要解决的每个问题都考虑两遍,而且这两次之间会隔好久。把几个月前写某一组测试用例时的想法回忆起来可不是一件容易的事情。所以测试驱动开发者不会一次写好所有的测试用例,不过他们还是不会在某个用例写好之前就去写对应的产品代码。
测试驱动开发还有一个好处,就是在给应用程序加入新功能之后,能够得到快速的反馈。每一个成功执行的测试用例,都是一丝鼓励,促使你去写下一个测试用例。你不需要像传统的开发方式那样等一个月的时间,直到下次执行系统测试时才知道新功能是否能正常运作。
测试驱动开发背后的思路是,在设计软件时,需要思考受测代码应该具有的行为。这种思维方式不是先写好一个专门解决某问题的模块或者类,再将它集成到应用程序中,而是先想想应用程序需要解决什么问题,然后再写产品代码来实现它。而且,如果这段实现代码能让描述对应需求的测试用例得以成功执行,那么也就证明了此产品代码确实正确地处理了这个需求。实际上,先写测试用例甚至能够帮你发现某个功能是不是已经被实现过了。如果某个测试用例写好了之后,在不增加任何产品代码的前提下,它能成功地执行,则说明要么应用程序已经解决了该用例所述的需求,要么就是这个用例本身写得有问题。
在测试驱动开发中,应用程序所要解决的“问题”,并不是类似“用户可以把喜欢的食谱发布到Twitter”这样完整的功能,而是一些比较细微的功能,应用程序的这些细枝末节是用于实现某个大功能的一小部分。就举这个“把喜欢的食谱发布到Twitter”的例子来说,其中一个细微的功能就是需要有一个文本框用来输入Twitter用户名。另一个细微的功能则是需要将文本框中的文本作为用户名传递给某个Twitter服务对象。还有一个细微的功能则是从NSUserDefaults对象中读入Twitter用户名。这每一个小小的功能都服务于某个用例的一小部分,对于所要实现的大功能来说,它们都是不可或缺的。
测试驱动开发的常用方法是先写一个测试用例然后运行,确认它执行失败,然后再写产品代码,使该测试成功执行。做完了这件事之后,再去写下一个测试用例。这是一种适应测试驱动开发理念的好方法,因为它让你以测试用例的角度去思考如何实现某个功能,如何修复某个bug。Kent Beck将这种心态称为“测试迷恋症”(test infection),此时你考虑的问题已经不再是“我应该如何调试”,而是“这个测试用例该怎么写”。
测试驱动开发的支持者宣称,与非测试驱动的项目相比,他们使用调试器的次数非常少。测试用例不仅能确保产品代码的行为正确,而且还可以使开发者更容易理解程序的行为,而不必通过调试器单步地执行代码。确实,使用调试器的主要原因就是有时你发现某个用例所描述需求没有做对,可是又找不出来问题究竟在哪。单元测试将应用程序的代码隔离为一个个孤立的小部分,以此来追查程序中的问题,这样很容易就能定位到导致测试用例执行失败的代码。
所以,沉迷于测试的开发者不会想:“我怎么才能找到这个bug?”因为他们所使用的手段比通过调试器来查bug要快得多。他们想的是,“我如何证明这个bug已经被修复了?”或者“我原来的假设需要做什么增加或改动吗?”如此,开发者可以知道为修复bug所做的工作是不是到位了,也能知道是否在开发过程中不小心把别的东西弄坏了。
也许有一天,你会觉得这么编程有些压抑、效率不高。如果你直接就能看出来实现某个功能需要增加一系列紧密关联的新代码,或者修复某个bug需要修改某方法内的几行代码,那么“一次只修改一个地方”的编程方式就会变得有点做作了。所幸没人强迫你一次只能改动一个地方。既然一直在思考:“我应该如何测试这个功能呢?”那你就很有可能在思考的过程中直接把产品代码写好了。一口气写上好几个测试用例,然后再编写能让用例成功执行的产品代码,这样做没错,同样,先写好能解决问题的产品代码,回过头来再补写测试用例,一样很好。只是一定要记住补写测试用例,而且这些用例必须确实能够证明新加入的代码是正确的才行。这些用例的目标就是验证产品代码是否能如预期的运行,而且也能保证在后面的开发中不会引入回归bug。
过些时候,你就会发现,由于写测试的过程能帮你整理思路,并且明确需要编写的产品代码,所以,使用测试驱动开发方式与不写测试的方式相比,所花的时间并没有什么不同,而且还能大大节省在项目快结束时用于调试的时间。
2.2“失败、成功、重构”三部曲
在写产品代码之前先写测试用例,这说起来很简单,可究竟如何写测试呢?针对尚未编写的产品代码而进行的测试是什么样子的呢?先看看需求,然后问问自己,“如果用于解决此问题的产品代码已经写好了,那么我该如何使用它们呢?”将你认为能够获得正确结果的方法调用语句写下来,填上表示真实需求的参数值,然后编写一个测试用例来断言调用了那个方法之后所产生的输出确实是正确的。
现在运行一下这个测试。你可能会问,明知这样的测试必然会运行失败,为何还要运行它呢?实际上,根据你指定API所用方式的不同,有时候这种测试甚至连编译都通不过。然而就是这样一个未能成功执行的测试,依然有其价值,那就是,它至少证明了应用程序应该完成某个任务,但目前还未实现它。同时它还指明了用于完成需求所必须调用的方法。通过这个测试,你不仅以一种可重复、可执行的方式将需求描述出来了,而且还设计了用于满足此需求的产品代码。测试驱动开发不是先写出解决问题的代码,然后考虑如何来调用它,而是先确定应该调用什么样的方法来解决问题。这样做更有可能设计出一套风格一致且易于使用的API来。有时候你还能顺带着发现,某些即将要实现的功能其实早就做好了。如果这个项目是从头开始做的,这并不奇怪,然而如果你是在做一个代码很复杂的遗留应用程序(legacy application),那么有时候光盯着代码看是搞不明白这个软件的功能的。在这种情况下,你可能写了一个测试用例来表述需要增加的新功能,不料测试却直接运行通过了,这就说明当前软件已经把这个功能实现过了。于是你可以针对下一个功能再去写测试用例了,依次写下去,直到已有的测试用例已经完全探明了遗留代码(legacy code)支持的所有功能,新写的测试用例开始无法成功运行为止。
测试驱动开发的实践者们把这种“先写一个描述尚未实现的产品代码所应具备功能的失败测试用例”的开发过程叫做“红色阶段”(red stage)或“红条阶段”(red bar stage)。这么称呼它,是因为很多流行的IDE,例如Visual Studio和Eclipse(虽说最新版的Xcode不是这样),在运行单元测试的视图中采用一个大的红色滚动条来表示当前有测试用例执行失败了。这个红色滚动条是个很明显的视觉指示器(visual indicator),告诉你目前的产品代码并未实现所要求的全部功能。
相对于令人恼火的红色滚动条来说,平和的绿色滚动条则看起来友好很多,这也就是测试驱动开发在第二阶段的目标:编写产品代码使得执行失败的那些测试用例能够成功地执行。如果这意味着需要增加一个新的类或者方法,那就把它添上吧,你会发现这种新增API的方式也变成了应用程序设计的一部分了。
在这个阶段中,你如何写产品代码来实现新的API并不重要,只要能让测试用例成功执行就好了。产品代码只需要能“刚刚好地”(Just Barely Good Enough)完成所需功能即可。任何“多余的努力”都只会写出无用的代码,而不会提升应用程序本身的功能。举例来说,如果某个用例测试的是一段用于生成问候语的程序,那么产品代码在以“Bob”作为名字参数被调用时,应该返回“Hello, Bob!”就好了。如此说来,以下这种写法就可以满足要求:
- (NSString *)greeter: (NSString *)name {
return @"Hello, Bob!";
}
以“当前的状态”来看,再写多余的代码都可能是浪费。当然了,稍后也许会需要一个更为通用的方法实现,也许并不需要。在你写出另外一个测试用例用以证明该方法应该根据不同的输入值而返回不同的字符串(例如,参数如果是“Tim”,那么返回值应该是“Hello, Tim!”)之前,这段代码确实完成了测试用例所要求的功能。恭喜你,现在可以看到绿色的滚动条了(假设写的这段产品代码并没有把原来已经测试成功的那些用例弄坏),这就可以证明你现在的这个程序比起之前的那版,已经有一点点进步了。
你现在可能还在惦记着刚写的那段产品代码吧?也许你会说有种更高效的算法也能产生同样的结果,或者你会觉得为了让所有测试都能成功执行,你用了一些看起来很别扭的“杂技代码”(kludge)。为了让测试用例执行成功,从应用程序的其他地方复制代码贴过来,甚至是将测试代码的一部分直接复制到类方法里面,这都会让新通过测试的这个程序产生“代码坏味”(bad code smell,或者code smell)。这又是一个由Kent Beck造出来的词,它随着极限编程的流行而广泛使用,意思是那种看上去能正常运行,但实际上却包含着某些潜在问题的代码。
现在你有机会来重构了—不要改变应用程序的行为,只修改实现代码把它清理得干净一些就可以了。鉴于已经写好了用于验证代码行为的测试用例,所以如果那个功能被不小心弄坏了,则可以通过测试用例反映出来。测试也有失灵的时候,比如说有时你会不小心将一些应用程序并不需要的新功能加入到产品代码中,而这些新代码又没有干扰到其他已有的功能,那么测试用例当然就没办法发现它们了。不过这是个相对来说无伤大雅的副作用(side effect),因为应用程序没有用到这个功能。如果真的用到了,那肯定会有一个测试用例用来专门测试此功能的。
其实也不是非要在测试成功执行之后就立刻去重构。有时候之所以那么做,主要是因为此时刚刚实现的那个新功能仍然停留在开发者脑海中,如果要进行改动的话,不必再去回顾一下这段代码的工作原理。然而有时候你可能会觉得当前的代码没什么问题,如果是这样,没事,放在那别动就行了。以后再要重构,这些已有的测试用例仍然会保证重构后的代码没有影响到已经实现的功能。记住,最浪费时间的事情就是重构那些已经写得很好的代码了(参见2.5节)。
现在你已经走完测试驱动开发的三个阶段了:先写了一个不能成功执行的测试用例(看到了红色的滚动条),再编写实现代码让它可以成功地执行(看到了绿色的滚动条),然后在不改变程序行为的情况下对代码进行了清理(重构)。这时的应用程序已经比执行这个流程之前的那一版多了一点点功能。这点微不足道的功能也许算不上一个值得为客户发布新版软件的重大改进,不过现在的代码质量已经完全符合“发行候选版”所要求的水准了,因为你可以证明所有新加入的功能都能正常运转,而且它们也没有干扰到已经实现好的功能。回想一下上一章讲到的内容,此时也许还有一些测试要做,比如可能还有尚未解决的集成和易用性问题,或者是你和测试人员之间对于下一个将要加入产品的功能有分歧。有一点可以坚信,只要测试用例已经完全覆盖了应用程序所期望的所有输入值,那么新加入的代码中出现逻辑bug的可能性将会很低。
在一轮“失败、成功、重构”三部曲过后,又该和红色滚动条见面了。这么说的意思是,现在又该加入下一个细微的功能了,也就是再实现一个能够改善应用程序质量的小需求。测试驱动开发本身就是一种迭代式的软件工程方法,因为直到应用程序已有的每一个小功能都达到产品质量的要求之后,才会去开发下一个功能。测试驱动开发不会留下一大堆没有做完而且不能用的功能,而是要么有一组可以正常运行的用例,要么只有一个测试用例还没执行成功—就是现在正在写实现代码的那一个。尽管有时候开发团队的程序员不止一个人,每个程序员都在处理一个不同的用例,不过每个开发者在某个时间点只会解决一个特定的需求,而且其对这个需求何时能够实现会有一个清晰的概念。
2.3设计易于测试的应用程序
学过了“失败、成功、重构”三部曲的技巧之后,你也许现在就想用测试驱动的方式来实现自己要做的应用程序之中的第一个功能,然后以同样的方式渐进地增加后续的功能。这样的话,应用程序的架构与设计会随着加入既有代码的小部件而一点一滴地成长起来。
软件工程师可以从建筑学中获得很多知识。这两个学科有个共同的目标,就是在有限的空间与资源的制约下,构建出既富有美感,又具备实用性的产品来。所不同的是,软件开发可以不用像盖房子那样,非要等墙砌好了,才能撤掉脚手架。
一个在现实世界的工程中使用“聚合”(aggregate)概念的例子就是混凝土。找一片工地,看看正在搅拌的混凝土,它很像一团均匀的糊状物,而且略带腐蚀性,如果在施工的过程中沾到身上了,则会被烧伤。如果在对应用程序的设计没有一个全盘计划的前提下就采用测试驱动开发,那么这个应用程序在很多方面就会变得和混凝土一样,不具备某种大规模的结构,所以很难找出每个新功能之间的关联。这些新功能将会变成一个个孤立的块状物,它们挨得很近,但是就像建筑材料中的石头那样,彼此没有关联。当应用程序没有清晰的结构时,你会发现很难通过寻找共性来复用代码。
所以最好是在对应用程序的整体架构有了全局把握之后再进行测试驱动开发。你不用把需要实现的每个类和方法都详细地建模,这种细粒度的设计会随着测试用例的完成而自动浮现出来。你要做的是想清楚应用程序都需要哪些功能,如何把这些功能组织到一起。要思考是不是可以从这些功能中提取出一些共用的信息或代码,功能之间如何交流,要交流的话需要些什么东西。在极限编程中也有一个术语用于表述此概念,那就是“系统隐喻”(System Metaphor)。更宽泛地说,在面向对象编程中,这叫做“领域模型”(Domain Model),也就是一幅图景,它描述了用户如何使用该应用程序,以及应用程序涉及的服务和对象。
有了这些信息之后,就可以设计测试用例了,让它们验证一下应用程序除了能正常运行之外,是否还遵循了既定的架构方案。如果两个组件应该通过某个类来共享信息,那么就测试一下这个类。如果两个功能都调用同一个方法,那么也写一个测试用例来验证一下这个方法。到了重构阶段,也可以按照全局规划的指导来进行代码清理。
2.4更多有关重构的知识
如何重构代码呢?这可是个大问题,实际上可能没有固定的方法,因为一个人觉得很好的代码另一个人可能觉得很糟糕,反过来也一样。唯一有意义的说法是以下这几条建议:
如果某段代码完成了某个需求,然而你却觉得它不够好,那么就需要重构了。“不够好”的意思可能是看上去不顺眼,使用的办法不合适,或者结构不合理。有些时候重构的执行没有一个明确的原因,只是因为代码“闻起来”有坏味而已。
当这段程序不再存在代码坏味时,即可停止重构了。
重构的过程应该把糟糕的代码变成干净的代码。
以上这些描述太过模糊,所以不能当做你重构代码时的参考手册或教程。如果代码能够遵循一种公认的面向对象设计模式,那么也许就会变得更加可读,更加容易理解。设计模式是一种能够适用于各种场合的通用代码范本。Cocoa框架及Objective-C软件开发所用的模式可以在Buck与Yacktman所著的《Cocoa Design Patterns》(Addison-Wesley, 2009)一书中找到。与语言无关的经典设计模式参考书则是由Gamma、Helm、Johnson与Vlissides所著的《Design Patterns: Elements of Reusable Object-Oriented Software》(Addison-Wesley 1995),这本书也经常被叫做“四人组之书”(Gang of Four book)。
某些特定的代码变换方式会在重构中频繁地使用,因为在许多不同的情况下,它们都可以使代码变得更加整洁。比方说,如果两个类都实现了某个方法,那么可以建立一个公共超类,将该方法上移至超类中。也可以建立一项协议,用其描述多个类都必须遵守的方法约定。由Martin Fowler所著的《Refactoring: Improving the Design of Existing Code》(Addison-Wesley, 1999)一书描述了大量的这种代码变换技巧,不过都是用Java语言写的。
2.5不要实现目前用不到的功能
在前面的章节中,笔者曾经反复提到测试驱动开发所具有的一个特质,这里有必要再说一次:如果你写的测试用例描述了应用程序的需求,同时你写的实现代码仅仅是为了让测试能够成功执行,除此之外一行都没多写,那么,你就绝对不可能写出用不上的代码来。诚然,需求在将来有可能会变,现在正在实现的功能也有可能被废弃掉,不过至少“就目前而言”,这个功能是必需的。你所写的产品代码就是为了实现该功能,此外无他。
遇到过这种情况吗?你或你的同事写了一个很精巧的类或者框架,它采用了一种非常通用的方式来处理某个问题,然而软件产品中需要解决的那个问题领域却非常有限,用不着那么通用。这种情况笔者曾多次遇见。这种通用代码通常会被提交到项目所托管的Github或Google Code代码库中,美其名曰“为开源社区做贡献”,以此来为编写这些无用代码所做的努力正名。但是提交完了之后,该项目就继续按照自己原有的方向发展下去了,与此同时,使用这个通用算法的第三方用户就发现,这个程序库实际上并不能很好地处理那些原项目开发者工作范围之外的问题,于是它们就开始提交各种bug反馈与改善请求。于是那个应用程序项目的开发者很快就会意识到他们成了框架的开发者,因为越来越多的精力要投入到这个通用的框架中来,尽管他们的项目自始至终都只用到了框架的一小部分功能而已。
这种画蛇添足的镀金(gold plating)行为,通常会发生在那些从内而外、自主研发的应用程序身上。比如,应用程序需要处理URL请求,那么你就会写一个用于处理URL请求的类。不过现在还不知道这个应用程序到底要通过URL请求来做什么具体的事情,所以你所写的这个类就得将所有能够想到的用法都考虑进去。当你真正写到了应用程序使用URL请求来实现具体功能的那部分代码时,你才会发现原来它只用到了那个类的一小部分功能。也许应用程序只会以GET方式发出请求,这样的话,该类中用于处理POST请求的那部分代码就用不到了。然而那部分代码却还被放在那里,这就降低了这个类的可读性和可理解性。
测试驱动开发推荐以由外而内的方式构建应用程序。你知道用户现在需要某个特定的功能,所以写一个测试用例来断言应用程序必然可以实现这个功能。该功能需要通过网络服务来取得某些数据,所以再写一个测试用例断言所需的数据确实已被获取到了。获取数据又需要通过URL请求来做,所以又写一个测试用例以证明应用程序使用URL请求的方式是正确的。为了让这些测试都成功执行,现在需要开始写实现程序了,这时候只需要为已经确定必须会用到的那部分功能编写代码即可。这么一来,类代码中就不需要再有通用的处理逻辑了,因为应用程序用不到它。
测试库代码
对于上面说的这个处理URL请求的问题,其实有一种更简单的解决方法,能够让你少写很多代码:那就是采用他人已经写好的代码库。不过是不是应该在把代码库集成到应用程序之前对其进行彻底的测试呢?
不需要。你要明白的是,单元测试只是众多供你选用的工具之一。单元测试,尤其是在测试驱动开发中所用的单元测试,最好是用来测试你自己所编写的代码,这也包括了测试那些与第三方库沟通的类是不是能正常运作。可以使用集成测试来判断应用程序是否能正常运行。如果应用程序不能正常执行,而且你能确定(多亏了那些单元测试)使用第三方库的方法是正确的,那么你就会知道那个程序库里面有bug了。
现在你可以写一个单元测试来暴露这个bug,把它作为一种代码文档,向库的开发者提交一份bug回报。单元测试还有一个有用的地方就是可以帮助你研究库代码的API。你可以写一些单元测试,把你觉得某个库中的类所应该具有的功能描述出来,然后执行测试,看看你做的假设是否正确。
采用极限编程的开发者用一个首字母缩略词(acronym)来描述那种画蛇添足的通用类框架:YAGNI,也就是“你并不需要它”(Ya Ain抰 Gonna Need It)。有些人确实需要编写通用类框架,比如说Apple的Foundation框架就提供了很多通用类。然而我们大部分人都是开发iOS应用程序的,而非开发iOS系统本身,同时这些应用程序所需要的功能通常很少而且很明确,用不着为它开发一个新的通用框架。除此之外,我们也要相信Apple在将某个新的类或方法加入到Foundation框架之前,应该是研究过它的潜在使用方式的,而不会只提供一个学术上来说功能完备的API就不管了。
避免在“你还不需要使用某个功能”(YAGNI)的时候就为它提前编写实现代码。这么做可以节省时间,而且,更为重要的是,无用代码可能成为攻击者的目标,他们会设法找到某种方式,让应用程序去执行这部分代码。有时候你在以后的应用程序开发过程中会用到原来编写的这部分多余代码,而此时你可能已经忘记了这段程序自从写好之后就没被测试过。如果这时你在程序中发现了一个bug,那么你很可能会浪费大量的时间在新写的代码中查找问题。你意识不到其实bug是由原来那部分旧代码引起的,因为你从未使用过它们。
以测试驱动方式开发出来的应用程序不应该包含无用代码,也不应该有(或者几乎没有)未经测试的代码。这样一来,你就能确信所有的代码都能正常运行,当你使用这些既有的类或方法来开发新功能时,所产生的问题就会比较少,而且,此时的应用程序中也就不会包含那些可能会被误用或者导致bug的代码了。所有的产品代码都应该服务于用户所需要的某项服务。若是在重构阶段你发现可以对代码进行一些修改,使其能应对更多的情况,那么你可千万别急着去改。这些预想的状况为什么没出现在测试用例中呢?因为这些情况在应用程序中根本就不会发生,所以用不着浪费时间编写代码去处理它们—你并不需要这些功能。
2.6在编码前、编码中及编码后进行测试
如果你遵循测试驱动开发所倡导的“失败、成功、重构”三部曲,那么就应该在编写产品代码之前先运行测试,确定测试用例无法成功地执行。你可以由此知道应用程序所需要的功能还没有实现,而且可以通过编译器给出的错误提示信息来推断应该如何写实现代码才能让测试执行成功,尤其有些时候是因为应用程序缺少必要的代码而导致测试用例无法编译或者无法正确运行。在编写实现代码的过程中,也要持续运行测试用例,以确保在通往绿色滚动条的路上没有破坏已经写好的那部分功能。在实现完所需功能,进行代码重构的时候,还是要不停地执行测试用例,以确保重构的时候没有干扰到原来的代码。整个流程以一种细粒度的方式再现了第1章中所说的“软件测试应该在开发流程中的每一个阶段都得到执行”。
在整个开发周期中,让所有测试都能自动运行,这真是个好主意,因为就算你忘记去执行测试了,那个自动执行系统也能帮你执行它。有些开发者在每次构建完项目时都会执行测试用例,哪怕要等好一阵子才能把它们执行完。另一些程序员则使用持续构建服务器或者构建机器人(buildbot,第4章将会讲到)在幕后执行测试用例,有时甚至是将项目签入到版本控制系统或者推送到主代码库中,然后在那台托管项目代码的电脑上运行测试用例。在这种情况下,执行测试所花的时间长短就不是很要紧了,你可以继续在IDE中进行编码,等待测试结果的通知。这种通知一般都是以电子邮件的形式发送到邮箱中的,不过你也可以对构建系统进行配置,让它弹出一条Growl通知或者通过iChat软件发一条消息给你。笔者见过一个开发团队,他们甚至将一个单片机连在了构建服务器上,让单片机根据测试执行结果发出绿色或红色的灯光。本章草稿的一位审阅者提出了一个更好的想法:设计一个工作区(workspace),让它在测试执行失败时打开一盏闪烁的警灯,拉响警笛,这样开发团队的每个人都能知道测试运行失败了,他们就会赶紧跑过来修改产品代码。
另一个很重要的保险手段就是在准备公布产品的发行候选版之前先运行一遍测试用例。如果此时测试执行失败了,那就没有理由把它发布给测试者或客户了。很明显,程序有写得不对的地方,需要修复。在理想状态下,发布软件的过程应该是一劳永逸的,只需按下构建按钮,等着软件的正式版被构建好就行了。然而一个执行失败的测试用例就会使构建过程中断。在这种时候,测试所花的时间已经不那么重要了,因为发行候选版的构建频率相对来说比较低,对正确性的要求比对发布时间的要求更为强烈。如果有些测试需要花好几分钟甚至更长的时间来执行,那么就可以把它们放到这个阶段来做。应当承认,这些测试往往不是真正意义上的单元测试:运行耗时很久的测试通常是一些集成测试,它们需要设定应用程序的运行环境,例如连接到某个服务器。这种情况很有必要进行测试,不过并不适合包含在单元测试套件中,因为如果运行环境发生改变,它们可能会莫名其妙地运行失败。
通常来说,如果你不打算让等待测试执行完毕的时间比编写代码的时间还要长的话,你就应当尽可能地将测试运行自动化。笔者自己在编写代码的同时会持续地运行测试用例,而且当笔者把代码提交到git代码库的“主分支”(master branch,在subversion等其他源代码控制系统里面叫做trunk)上面时,测试用例也会自动地执行。增加某个新功能与发现测试用例运行失败之间所隔的时间越短,就越容易找到问题的原因。这就是为什么应该在“失败、成功、重构”三阶段中反复执行测试用例的原因所在:你可以得到即时的反馈,以了解接下来应该做什么,同时也能知道自己已经做完多少功能了。
第3章如何写单元测试
前两章讲述了软件测试的目标,以及如何利用测试驱动开发与单元测试来达到这个目标。但是测试用例具体怎么写呢?本章将会告诉你编写测试用例的基本原则。通过本章,你会了解到测试用例的不同组成部分,以及如何通过一个或一套测试用例来引领你写出产品代码。本章给出的代码不是某个项目的一部分,它只是为了演示如何从需求出发,最终写出成功通过测试的产品代码这个开发全过程。并不需要去运行本章的代码。
3.1需求
要记住,写单元测试的第一步就是先明确应用程序的需求。当弄明白了应用程序需要什么功能之后,才有可能知道实现这个功能需要用到哪些代码。针对那些代码的调用也就构成了测试用例的主体部分。
这一章所举的例子,是一个有史以来所有范例代码作者都乐此不疲的程序:温度换算器。这个肯定无法获得Apple Design Award大奖的简单应用程序是这样的:用户在文本框中输入一个摄氏温度值,然后按一下键盘上的Go按钮,其对应的华氏温度值就会在下面显示出来。用户界面如图3-1所示。
这个界面包含了足够多的信息,用以指示应该如何设计API。因为按下转换按钮时焦点是在文本框中,所以肯定要用到UITextFieldDelegate类的-textFieldShouldReturn:方法。由该方法的声明可知,它的参数是一个文本框对象,在本程序中,指的就是包含摄氏温度值的那个文本框。因此,对于需要实现的那个方法,它的签名应该是这样的:
- (BOOL)textFieldShouldReturn: (id)celsiusField;
图3-1 温度转换器的界面截图
3.2使用已知的输入数据来运行代码
单元测试的特性之一是可重复性:不论何时、在何种平台上运行,只要受测代码是正确的,那么测试用例就能成功执行,否则就必然运行失败。运行测试的计算机配置、与测试同时运行的其他程序、数据库等外部软件或者磁盘文件系统的内容等因素都不应该影响测试结果。具体到本例来说,这意味着不能先用代码构建一套用户界面,然后让测试人员向文本框中输入一个数字,最后再查看结果。如果那样做了,不仅会导致测试人员不在场或者发生误操作时无法进行测试,而且作为构建过程的一部分来说,那种测试所花的运行时间太长了。
好在要测的那个方法不需要太过复杂的预先设置(只需要用到一个名为celsiusField的参数就行了),所以笔者可以直接写出一份能反复执行的测试用例代码来。已知-40℃与-40癋是相等的,所以首先测这种情况。考虑需要实现的那个方法的API之后,笔者发现,其实只要知道了文本框中的文本内容即可,不需要配置一个完整的UITextField对象,而只需要创建一个含有text属性的简单对象即可。不需要创建一个文本框对象那种具备全部功能的视图,那样做会带来很多不必要的复杂度。以下是这个仿造的(fake)文本框类。
@interface FakeTextContainer : NSObject
@property (nonatomic, copy) NSString *text;
@end
@implementation FakeTextContainer
@synthesize text;
@end
现在可以写测试用例了。既然已经知道了输入值,就可以先用这个值把文本框对象配置好,然后再将其传给要开发的那个实现方法(尽管现在还没写好)。笔者倾向于使用一个非常长的名字作为测试方法的名称,不管在单元测试还是产品代码中,这么做都很有用,因为它可以完整地揭示出方法的意图。
@interface TemperatureConversionTests : NSObject
@end
@implementation TemperatureConversionTests
- (void)testThatMinusFortyCelsiusIsMinusFortyFahrenheit {
FakeTextContainer *textField = [[FakeTextContainer alloc] init];
textField.text = @"-40";
[self textFieldShouldReturn: textField];
}
@end
大家注意到没有,这里假设最终要实现的那个方法和测试用例代码都写在同一个类中,所以这里是通过self来调用方法的。在实际工作中几乎不太可能这么做—应该将测试代码与应用程序代码分开来写才对。不过在本例中,这么做没关系,现在我们一边做测试,一边构建这个方法,等到以后,可以再把它移动到产品代码的某个类中(可能是UIViewController类的某个子类)。可以等所有的测试都成功地执行完了,也就是看到那个测试结果指示条变绿的时候,再进行重构。但是现在我们看到的那个指示条仍然是红色的。实际上这段代码根本就无法在不生成警告信息的情况下编译成功,除非把那个受测方法的实现代码写好了才行。
- (BOOL)textFieldShouldReturn: (id)celsiusField {
return YES;
}
我们不需要一个比上面这段程序功能更为丰富的版本(至少现在还不需要)。实际上,当前的实现代码还对一个本来应该先讨论清楚的问题提前做了决定,这个问题是“此方法究竟应该返回YES还是NO呢?”本章稍后将会重新审视这个决定。
3.3查看运行结果是否符合预期
现在可以用已知的输入值来调用刚才实现的那个方法了,我们需要检查测试的运行结果,以确认它是否符合应用程序的需求。可以肯定的是,调用该方法之后,某个标签(label)控件的文本应该会改变,所以需要通过某种方式来检视那个控件的内容。不能直接把这个标签控件作为参数传入到方法中,因为这不符合UITextFieldDelegate类的API方法约定。于是,温度转换方法所在的这个类,就应该有一个用于表示那个标签控件的属性,让实现方法能够使用它。
与用户输入数值所用的那个文本框一样,这个显示换算结果的控件,也是只有其text属性才被测试方法用到,所以,可以利用已经写好的FakeTextContainer类来表示它。修改之后的测试用例代码如下:
@interface TemperatureConversionTests
@property (nonatomic, strong) FakeTextContainer *textField;
@property (nonatomic, strong) FakeTextContainer *fahrenheitLabel;
- (BOOL)textFieldShouldReturn: (id)celsiusField;
@end
@implementation TemperatureConversionTests
@synthesize fahrenheitLabel; // a property containing the output label
- (void)testThatMinusFortyCelsiusIsMinusFortyFahrenheit {
FakeTextContainer *textField = [[FakeTextContainer alloc] init];
fahrenheitLabel = [[FakeTextContainer alloc] init];
textField.text = @"-40";
[self convertToFahrenheit: textField];
}
@end
像这种通过特殊对象来提供输入值,并观察输出值的用法,在很多测试中都会碰到。这种模式就叫做“伪造对象”(Fake Object)或“仿造对象”(Mock Object),它会在本书中多次出现。你可以看一下在这个测试使用中它所带来的好处:通过创建这种仿制的对象,就不需要再创建在真正的应用程序运行过程中所用到的那种文本标签控件了,因为仿制对象的行为与那个控件是一样的,而且通过仿制对象可以更为容易地观察到应用程序代码的执行结果是否正确。
3.4验证结果
现在可以通过查看fahrenheitLabel对象的属性来观察-convertToFahrenheit这个方法的运行结果了,并且可以利用测试框架所提供的功能来验证运行结果了。那种把运行结果与期望的正确值都输出到控制台,然后通过逐行比对以确认测试是否成功运行的办法,并不合适,因为那么做效率低而且易出错。对单元测试的一个基本要求就是,它应该能具备自我测试(self-testing)的能力,也就是说,每个测试用例都能够自行判断其后置条件(postcondition)是否得到满足,并依此来回报测试的执行是成功还是失败。测试框架的使用者仅仅需要知道,是否有测试执行失败?如果有的话,是哪一个?现在我们就来修改一下测试用例的代码,让它能够自行回报测试是否执行成功了。
- (void)testThatMinusFortyCelsiusIsMinusFortyFahrenheit {
FakeTextContainer *textField = [[FakeTextContainer alloc] init];
textField.text = @"-40";
fahrenheitLabel = [[FakeTextContainer alloc] init];
[self textFieldShouldReturn: textField];
if ([fahrenheitLabel.text isEqualToString: @"-40"]) {
NSLog(@"passed: -40C == -40F");
} else {
NSLog(@"failed: -40C != -40F");
}
}
现在,整个测试用例已经写好了。它先设置好用于调用受测方法的输入值,然后调用受测方法,最后自动地输出预期的后置条件是否成立。不过如果这个时候运行的话,会发现测试用例肯定执行失败,因为受测方法并没有正确地设置标签控件的文本值。经过快速修复之后,方法代码变成了下面这样:
- (BOOL)textFieldShouldReturn: (id)celsiusField {
fahrenheitLabel.text = @"-40";
return YES;
}
这段用于执行温度转换的实现代码,看起来写得并不是很好,不过它确实能让刚才那个测试用例成功地执行,就目前的需求来看,它已经完成了所有的功能。
如果单元测试也能包含其他单元测试的话,那么每个用例应该测试多少个条件呢?
现在可以看到,-testThatMinusFortyCelsiusIsMinusFortyFahrenheit这个测试方法只测试了一个条件。如果要让它再去测试几个条件,其实也简单得很—比如,可以给用于输入的文本框中设置不同的输入值,然后测试一下每次在转换结果标签控件中所显示的输出值是否都是正确的,或者测试我们所忽视的那个BOOL型的返回值。然而,这可不是一个设计测试用例的好办法,笔者不推荐这么做。
如果按照上述的方式来写测试,那么只要其中的一个条件不成立,就得花很多时间来检查到底是哪一个条件导致测试用例失败的。然而,如果每个用例只测试一个条件的话,就不会花这么多时间了。更为糟糕的用法是,将用例中获取到的某个执行结果保存起来,用于稍后的其他测试代码中。如果这么设计用例的话,那么某个断言的失败将导致本来可以成功执行的一系列其他测试都执行失败,或者掩盖了那些受测代码中真正有错误的地方。这样一来,你要么会在不存在逻辑错误的代码中浪费时间去找bug,要么则会忽视掉受测代码中真正包含bug的地方。
一个设计良好的测试用例,应该只设定好某一种使用情境(scenario)所需的前置条件(precondition),然后判断应用程序的代码是否能在此种情境下正确地执行。每一个测试用例之间都应该是独立的,而且具备原子性(atomic)—它要么执行成功,要么执行失败,不会存在中间状态。
3.5使测试代码更具可读性
刚才写好的测试用例已经有了一个能解释其测试内容的方法名了,不过我们并不能很容易地从代码中看出这个测试的工作原理。一个特别值得注意的问题就是,测试的核心逻辑被埋没在if语句块之中了。这部分逻辑是测试中最为重要的部分,然而现在很难找到它。如果将包围核心逻辑的那些代码,也就是条件判断语句和用于报告测试是否成功执行的语句,都缩到一行代码中,那么就能更方便地理解这个测试用例想要表达的需求。可以定义一个宏,将条件语句和测试判断放在一行代码中执行。
#define FZAAssertTrue(condition) do {\
if (condition) {\
NSLog(@"passed:" @ #condition);\
} else {\
NSLog(@"failed:" @ #condition);\
}\
} while(0)
// ...
- (void)testThatMinusFortyCelsiusIsMinusFortyFahrenheit {
FakeTextContainer *textField = [[FakeTextContainer alloc] init];
textField.text = @"-40";
fahrenheitLabel = [[FakeTextContainer alloc] init];
[self textFieldShouldReturn: textField];
FZAAssertTrue([fahrenheitLabel.text isEqualToString: @"-40"]);
}
这段代码还有改进的空间。首先,可以在测试失败时输出一条消息,以提醒开发者这个测试存在的目的。另外,在测试成功执行时,并不需要输出消息,因为在绝大部分情况下,一个执行成功的测试并不应该对开发者发出提示。这两处改进都可以通过修改FZAAssertTrue()宏的定义来完成。
#define FZAAssertTrue(condition, msg) do {\
if (!condition) {\
NSLog(@"failed:"@ #condition @""msg);\
}\
} while(0)
//...
FZAAssertTrue([fahrenheitLabel.text isEqualToString: @"-40"], @"-40C should
蔱qual -40F");
//...
提前说一下,现在的这个FZAAssertTrue()宏,看起来很像是OCUnit的STAssertTrue。OCUnit框架在测试用例执行失败时会输出开发者所提供的消息:
[...]/TestConverter.m:34: error: -[TestConverter
testThatMinusFortyCelsiusIsMinusFortyFahrenheit] : "[fahrenheitText
蔵sEqualToString:
@"-40"]"should be true. In both Celsius and Fahrenheit -40 is the same
蕋emperature
输出的消息能够给开发者提供更多的信息,让他们明白写这个测试用例的目的,这有助于理解执行失败的原因。另外,测试所判断的具体条件应该写得更加明确一些,不然的话,应用程序中的所有测试用例,不管是判断两个值是否相等,还是判断某个对象是不是nil,抑或是断言某个方法要抛出异常,它们都得使用STAssertTrue(),这会导致雷同。实际上,OCUnit框架分别为判断这些情况提供了不同的宏,所以测试中所判断的具体条件可以往前移,这样代码意图看起来就更加明显了。本章稍后将详细介绍这些宏的用法。
3.6将多个测试用例组织起来
本章到目前为止所写的测试用例其实真的没测试什么东西。它就是确认温度换算器程序在输入为–40癈的时候,会输出–40癋而已。但事实上温度换算器程序要实现的功能远远不止这么简单,它必须能接受所有合法范围内的输入值,并精确地计算出结果,所以测试用例要检测的输入条件还有很多。必须定义一个可以接受的合法输入值范围,同时也要定义,如果输入值超出此范围,程序应该做何反应。举例来说,如果输入的摄氏温度值会导致输出的华氏温度值超出了NSInteger类型所能表示的数值范围,那该怎么办?如果输入的内容不是数字,又该怎么办?
除了要彻底地测试温度换算器这个组件以外,应用程序的其他组件也许也要测试。如果在传递给换算器之前,先要对用户输入的摄氏温度值做一些调整和处理,那么这部分代码也需要测试。同理,那些用于格式化转换器输出结果的代码,以及用于处理应用程序异常行为的代码,都必须经过测试。
针对以上说的每一种状况,都需要单独写一个用例来测试(参见前一节的附加栏里面所讲的内容)。这样算下来,类似温度换算器这种简单的应用程序,也需要写上数十个不同的测试方法。对于那种功能复杂、内容丰富、“有模有样”的应用程序来说,其单元测试的数量可达数千个之多。所以,需要有一种便于组织管理单元测试的方案,这就好比应用程序的代码需用方法和类的形式组织起来一样。
实际上,对单元测试的管理可以比照应用程序里面类的组织结构来进行。针对每一个应用程序中的类,都有一个对应的单元测试,用于测试该类中的各个方法。所以,可以采用图3-2所示的这种办法来组织温度换算器这个应用程序中的单元测试。需要注意的是,尽管应用程序的各个类之间可以有彼此的依赖关系,但是用于单元测试的类之间则不应存在这种依赖。每一个用来做“单元”测试的类,只能依赖于它要检验的那个受测类。可以使用前面讲过的类似FakeTextContainer这种仿制对象来避免对其他应用程序类的依赖。避免这种依赖关系,可以减少具有欺骗性的或者出乎意料的情况发生,因为如果某个测试用例类的代码不能成功地执行,那么问题只可能出在受测类之中。如果某测试用例需要依靠其他的应用程序类才能运作,那么当这个测试运行失败时,就要检查很多源代码,才能确定问题所在。
图3-2 按照应用程序中类的结构来组织测试用例类
鉴于某一个测试用例类中的诸多测试方法都是从不同的角度来测试应用程序的同一个行为的,所以它们之间很有可能出现重复的代码。确实是这样,回顾温度换算器,笔者觉得可能需要测试一下,用于换算温度的那个方法是否能正确地计算出绝对零度(–273.15癈/–459.67癋)和沸点(100癈/212癋)时的华氏温度,以确保应用程序能够应对众多不同的输入值。这种测试方法的工作流程都是一样的:先将输入值设置到一个仿造的文本框对象之中,并建立一个用于展示转换结果的仿造标签控件,然后调用温度换算方法。不同的测试方法所需要改变的部分只是输入值与输出值而已。
使用类似OCUnit这种单元测试框架时,可以通过建立“测试固件”(test fixture)而避免每个测试类的相似测试方法之间出现重复代码。测试固件提供了每一组测试用例运行所需的共同环境。多个测试用例所用到的相同运行环境因素,会抽取到这个测试固件中,每个测试都会运行在这个测试固件的某一份副本之中。采用这种方法之后,每个测试都会有自己的运行环境,不受其他测试的干扰。在OCUnit框架中,测试固件是通过继承SenTestCase类而搭建的,而且需要提供两个方法:用于配置测试固件运行环境的-setUp方法和用于在测试运行之后清理运行环境的-tearDown方法。现在我们将对刚写好的测试用例进行修改,让它变为某个测试固件的一部分。
- (void)setUp {
[super setUp];
textField = [[FakeTextContainer alloc] init];
fahrenheitLabel = [[FakeTextContainer alloc] init];
}
- (void)testThatMinusFortyCelsiusIsMinusFortyFahrenheit {
textField.text = @"-40";
[self textFieldShouldReturn: textField];
FZAAssertTrue(fahrenheitLabel.text, @"-40", @"In both Celsius and Fahrenheit
?40 is the same temperature");
}
注意,现在的测试方法已经变得极其简单了:首先建立了执行测试的初始条件,然后调用受测方法,最后验证后置条件。所有的“麻烦事”(plumbing)都被分别提取到测试固件类的setup与teardown方法中了,比如伪造对象的创建及其内存管理事宜。实际上,这里还缺两个测试。首先,-textFieldShouldReturn:方法应该返回什么值呢?查阅文档得知,如果要保留通常的文本框编辑行为,则应返回YES,况且在本应用程序中也没理由改变默认的控件行为。看起来原来代码返回的值是对的,不过以测试用例的形式将这个需求正式记录下来,还是有好处的。
- (void)testThatTextFieldShouldReturnIsTrueForArbitraryInput {
textField.text = @"0";
FZAAssertTrue([self textFieldShouldReturn: textField], @"This method should
蕆eturn YES to get standard textField behaviour");
}
在执行上面这个测试方法之前,测试运行程序会先执行setup方法中的代码,所以就不用在此方法里面再重复地编写那些用于设置运行环境的代码了,这个测试方法只需要包含和受测方法有关的特定测试代码即可。为了让整套测试用例完整一些,再写一个测试用例,验证一下100癈能够正确地转换为212癋。这意味着原来那个极其简单的-textFieldShouldReturn:方法之中的代码需要修改。
- (void)testThatOneHundredCelsiusIsTwoOneTwoFahrenheit {
textField.text = @"100";
[self textFieldShouldReturn: textField];
STAssertTrue([fahrenheitLabel.text isEqualToString: @"212"], @"100 Celsius is
?12 Fahrenheit");
}
// ...
- (BOOL)textFieldShouldReturn: (id)celsiusField {
double celsius = [[celsiusField text] doubleValue];
double fahrenheit = celsius * (9.0/5.0) + 32.0;
fahrenheitLabel.text = [NSString stringWithFormat: @"%.0f", fahrenheit];
return YES;
}
3.7重构
此时的受测方法已经可以正常执行了,不过问题是它和那些测试方法在一个类里面实现了。温度换算器与它的测试用例所关注的是两个不同的问题,所以应该分成两个类来写。实际上,将这两个责任互不相同的类分开来写有个好处,那就是我们根本用不着向产品的客户发布测试类了,因为用户不需要运行测试代码。温度换算方法需要用到应用程序视图中的文本框和标签控件,所以应该将该方法移动到一个用于管理视图的视图控制器(view controller)中。
@interface TemperatureConverterViewController : UIViewController
?UITextFieldDelegate>
@property (strong) IBOutlet UITextField *celsiusTextField;
@property (strong) IBOutlet UILabel *fahrenheitLabel;
@end
@implementation TemperatureConverterViewController
@synthesize celsiusTextField;
@synthesize fahrenheitLabel;
- (BOOL)textFieldShouldReturn: (id)celsiusField {
double celsius = [[celsiusField text] doubleValue];
double fahrenheit = celsius * (9.0/5.0) + 32.0;
fahrenheitLabel.text = [NSString stringWithFormat: @"%.0f", fahrenheit];
return YES;
}
@end
将受测方法移动到新的UIViewController子类之后,原来的测试用例就无法成功地执行了,因为在试方法中是使用[self textFieldShouldReturn]来调用受测方法的,而受测方法已经不在那个测试类之中了。应该在测试类的-setUp方法之中配置一个新的视图控制器实例,并通过该实例进行测试。
@interface TestConverter ()
@property (nonatomic, strong) FakeTextContainer *textField;
@property (nonatomic, strong) FakeTextContainer *fahrenheitLabel;
@property (nonatomic, strong) TemperatureConverterViewController
?converterController;
@end
@implementation TestConverter
@synthesize textField;
@synthesize fahrenheitLabel;
@synthesize converterController;
- (void)setUp {
[super setUp];
converterController = [[TemperatureConverterViewController alloc] init];
textField = [[FakeTextContainer alloc] init];
fahrenheitLabel = [[FakeTextContainer alloc] init];
converterController.celsiusTextField = (UITextField *)textField;
converterController.fahrenheitLabel = (UILabel *)fahrenheitLabel;
}
- (void)testThatMinusFortyCelsiusIsMinusFortyFahrenheit {
textField.text = @"-40";
[converterController textFieldShouldReturn: textField];
STAssertEqualObjects(fahrenheitLabel.text, @"-40", @"In both Celsius and
蔉ahrenheit -40 is the same temperature");
}
- (void)testThatOneHundredCelsiusIsTwoOneTwoFahrenheit {
textField.text = @"100";
[converterController textFieldShouldReturn: textField];
STAssertTrue([fahrenheitLabel.text isEqualToString: @"212"], @"100 Celsius is
?12 Fahrenheit");
}
- (void)testThatTextFieldShouldReturnIsTrueForArbitraryInput {
textField.text = @"0";
STAssertTrue([converterController textFieldShouldReturn: textField], @"This
蕀ethod should return YES to get standard textField behaviour");
}
@end
可以在此基础上进行更深入的重构。比方说,现在的换算方法仍然集多项职责于一身,它首先要将代表摄氏温度值的文本从文本框中取出来,然后将这个值转换为浮点数,再换算为华氏温度值,最后将换算结果转回字符串,并显示在那个表示华氏温度值的标签控件中。以上这些职责都可以分别提取到不同的类中。实际上,用于换算温度值的业务逻辑代码应该与执行视图操作的代码分居两个不同的类之中。你可以沿着这个大方向重构下去,直到自己对软件的设计满意为止。在重构过程中,如果某次代码修改破坏了应用程序的逻辑,那么这些既有的测试用例始终可以帮你把错误找出来。
3.8总结
本章讲解了如何进行单元测试的设计与编写。在这个过程中,我们看到了如何测试iOS应用程序的某一部分,具体在此例中就是如何测试Interface Builder所建立控件的事件处理方法。此外,还学到了如何在测试代码中通过建立仿造对象来代替复杂的类(例如,UIKit框架之中的文本框控件和标签控件),并且了解到测试用例是如何确保在对应用程序的设计进行重构时,所进行的代码改动不会破坏到既有功能的。下一章将介绍如何对Xcode的开发项目进行设置,使其支持单元测试,同时也会讲到如何利用OCUnit测试框架来编写更为简洁的测试用例,以及如何更好地从测试用例的运行结果中获得回馈信息。